Compare commits
25
Commits
54ffe89445
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407cc22911
|
||
|
|
5fc79197f6
|
||
|
|
73b279d7fe
|
||
|
|
1844b973e9
|
||
|
|
6cee1135a8
|
||
|
|
4f2cf08b27
|
||
|
|
9bb02463da
|
||
|
|
0b34e66d81
|
||
|
|
d180eb3c90
|
||
|
|
5c70c38523
|
||
|
|
026d592483
|
||
|
|
87e6e38291
|
||
|
|
723da9aa38
|
||
|
|
421106bf71
|
||
|
|
f42649b888
|
||
|
|
f75a1b0338
|
||
|
|
1c6d9202fc
|
||
|
|
bc3d8158ec
|
||
|
|
6e7a68d509
|
||
|
|
2c4dac628e
|
||
|
|
cf51b187d2
|
||
|
|
b5716d6dcd
|
||
|
|
ad7726d978
|
||
|
|
89fb745e74
|
||
|
|
2f8fdb755b
|
@@ -16,6 +16,8 @@
|
|||||||
DJANGO_SECRET_KEY=
|
DJANGO_SECRET_KEY=
|
||||||
DJANGO_DEBUG=false
|
DJANGO_DEBUG=false
|
||||||
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
|
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://polylan-submitter.legrems.xyz
|
||||||
|
DJANGO_BEHIND_TLS_PROXY=true
|
||||||
|
|
||||||
POSTGRES_DB=
|
POSTGRES_DB=
|
||||||
POSTGRES_USER=
|
POSTGRES_USER=
|
||||||
|
|||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Zero-downtime prod deploy: pull, sync deps, build front, migrate, then
|
||||||
|
# gracefully reload gunicorn (see polylan-submitter.service, installed and
|
||||||
|
# restarted instead when it changed) and restart the RQ worker (see
|
||||||
|
# polylan-submitter-worker.conf).
|
||||||
|
#
|
||||||
|
# Run from anywhere as the user owning the checkout, needs sudo for
|
||||||
|
# systemctl/supervisorctl (no-op when already root):
|
||||||
|
# ./deploy/systemd/deploy.sh
|
||||||
|
#
|
||||||
|
# Migrations run while the old code still serves requests, so keep them
|
||||||
|
# backward compatible (nullable/default columns, drop/rename in a later deploy).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE="${SERVICE:-polylan-submitter}"
|
||||||
|
WORKER="${WORKER:-polylan-submitter-worker}"
|
||||||
|
SUPERVISOR_CONF_DIR="${SUPERVISOR_CONF_DIR:-/etc/supervisor/conf.d}"
|
||||||
|
WEB_URL="${WEB_URL:-https://polylan-submitter.legrems.xyz/}"
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
DEPLOY_DIR="$REPO_DIR/deploy/systemd"
|
||||||
|
APP_DIR="$REPO_DIR/polylan_submitter"
|
||||||
|
PYTHON="$REPO_DIR/.venv/bin/python"
|
||||||
|
# Same as the unit's Environment=, so migrate/collectstatic see the prod settings.
|
||||||
|
# Relative to $APP_DIR, where manage.py runs.
|
||||||
|
export SETTINGS_ENV_FILE_LOCATION="${SETTINGS_ENV_FILE_LOCATION:-polylan_submitter/.env}"
|
||||||
|
|
||||||
|
# gunicorn's master doesn't reload itself on HUP, so a server upgrade needs a
|
||||||
|
# full restart instead.
|
||||||
|
server_versions() {
|
||||||
|
"$PYTHON" - <<'EOF' 2>/dev/null || true
|
||||||
|
import importlib.metadata as m
|
||||||
|
for pkg in ("gunicorn", "uvicorn", "uvicorn-worker"):
|
||||||
|
try:
|
||||||
|
print(pkg, m.version(pkg))
|
||||||
|
except m.PackageNotFoundError:
|
||||||
|
print(pkg, "missing")
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_for <description> <command...>: retry for ~30s, fail the deploy if it
|
||||||
|
# never succeeds.
|
||||||
|
wait_for() {
|
||||||
|
local what="$1"
|
||||||
|
shift
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
echo " $what: ok"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " $what: FAILED" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
web_responds() {
|
||||||
|
# 000 = nothing listening, 5xx = Caddy up but gunicorn down (502) or broken.
|
||||||
|
local code
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' "$WEB_URL")"
|
||||||
|
[ "$code" != "000" ] && [ "$code" -lt 500 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
worker_running() {
|
||||||
|
sudo supervisorctl status "$WORKER" | grep -q RUNNING
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
echo "==> git pull"
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
before="$(server_versions)"
|
||||||
|
|
||||||
|
echo "==> uv sync"
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
after="$(server_versions)"
|
||||||
|
|
||||||
|
echo "==> frontend build"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
echo "==> migrate"
|
||||||
|
"$PYTHON" manage.py migrate --noinput
|
||||||
|
|
||||||
|
echo "==> collectstatic"
|
||||||
|
"$PYTHON" manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
echo "==> $SERVICE (systemd)"
|
||||||
|
unit_changed=false
|
||||||
|
if ! cmp -s "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"; then
|
||||||
|
sudo install -m 644 "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE"
|
||||||
|
unit_changed=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HUP re-forks workers with the master's original args, so a new ExecStart
|
||||||
|
# (unit change) or a new gunicorn/uvicorn needs a full restart.
|
||||||
|
if [ "$unit_changed" = true ]; then
|
||||||
|
echo "==> unit file changed, full restart of $SERVICE"
|
||||||
|
sudo systemctl restart "$SERVICE"
|
||||||
|
elif [ "$before" != "$after" ]; then
|
||||||
|
echo "==> server packages changed, full restart of $SERVICE"
|
||||||
|
sudo systemctl restart "$SERVICE"
|
||||||
|
else
|
||||||
|
echo "==> graceful reload of $SERVICE"
|
||||||
|
sudo systemctl reload "$SERVICE"
|
||||||
|
fi
|
||||||
|
wait_for "$SERVICE active" systemctl is-active --quiet "$SERVICE"
|
||||||
|
wait_for "$SERVICE responds on $WEB_URL" web_responds
|
||||||
|
|
||||||
|
echo "==> $WORKER (supervisor)"
|
||||||
|
sudo install -m 644 "$DEPLOY_DIR/$WORKER.conf" "$SUPERVISOR_CONF_DIR/$WORKER.conf"
|
||||||
|
# reread+update picks up config changes (and starts it on first install),
|
||||||
|
# restart loads the new code - TERM is a warm shutdown, the running job ends first.
|
||||||
|
sudo supervisorctl reread
|
||||||
|
sudo supervisorctl update "$WORKER"
|
||||||
|
sudo supervisorctl restart "$WORKER"
|
||||||
|
wait_for "$WORKER running" worker_running
|
||||||
|
|
||||||
|
echo "==> done"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
; Prod RQ worker (submission OCR, AAndT sync, Steam refresh...), run by the
|
||||||
|
; host's supervisord next to the polylan-submitter systemd unit (web).
|
||||||
|
;
|
||||||
|
; Installed to /etc/supervisor/conf.d/ by deploy/systemd/deploy.sh, which also
|
||||||
|
; restarts it on every deploy so it picks up the new code.
|
||||||
|
;
|
||||||
|
; Logs: supervisorctl tail -f polylan-submitter-worker
|
||||||
|
|
||||||
|
[program:polylan-submitter-worker]
|
||||||
|
command=/home/caddy/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||||
|
directory=/home/caddy/polylan-submitter/polylan_submitter
|
||||||
|
; Relative to directory, same as the systemd unit.
|
||||||
|
environment=SETTINGS_ENV_FILE_LOCATION="polylan_submitter/.env",PYTHONUNBUFFERED="1"
|
||||||
|
user=root
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=5
|
||||||
|
; rqworker does a warm shutdown on TERM: it finishes the current job (OCR can
|
||||||
|
; take a while) before exiting, only SIGKILLed after stopwaitsecs.
|
||||||
|
stopsignal=TERM
|
||||||
|
stopwaitsecs=120
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
redirect_stderr=true
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Prod web process: gunicorn master + uvicorn ASGI workers, behind Caddy.
|
||||||
|
#
|
||||||
|
# `systemctl reload polylan-submitter` sends HUP to the gunicorn master: it
|
||||||
|
# keeps the :7777 socket open, boots new workers on the new code, then
|
||||||
|
# gracefully stops the old ones - no downtime. See deploy/systemd/deploy.sh.
|
||||||
|
#
|
||||||
|
# Installed to /etc/systemd/system/ by deploy.sh: when this file changed it
|
||||||
|
# does daemon-reload + a full restart (HUP keeps the old ExecStart args).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=PolyLAN submitter (gunicorn + uvicorn workers)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/caddy/polylan-submitter/polylan_submitter
|
||||||
|
# Relative to WorkingDirectory, loaded by settings.py via python-dotenv.
|
||||||
|
Environment="SETTINGS_ENV_FILE_LOCATION=polylan_submitter/.env"
|
||||||
|
|
||||||
|
# No --preload: with it, HUP re-forks from the already imported (old) code.
|
||||||
|
# --forwarded-allow-ips replaces daphne's --proxy-headers: trust
|
||||||
|
# X-Forwarded-* only from Caddy (local here).
|
||||||
|
ExecStart=/home/caddy/polylan-submitter/.venv/bin/gunicorn polylan_submitter.asgi:application \
|
||||||
|
--worker-class uvicorn_worker.UvicornWorker \
|
||||||
|
--workers 2 \
|
||||||
|
--bind 0.0.0.0:7777 \
|
||||||
|
--graceful-timeout 20 \
|
||||||
|
--forwarded-allow-ips 127.0.0.1
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
|
||||||
|
# SIGTERM to the master only, it then drains its workers (graceful-timeout).
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -7,7 +7,7 @@ from django_object_actions import (
|
|||||||
|
|
||||||
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||||
|
|
||||||
from .models import EventGame, Game
|
from .models import EventGame, Game, PlanningSlot
|
||||||
from .tasks import refresh_steam_games_task
|
from .tasks import refresh_steam_games_task
|
||||||
|
|
||||||
|
|
||||||
@@ -51,13 +51,35 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||||
list_filter = ["event", "enabled"]
|
list_filter = ["event", "enabled"]
|
||||||
search_fields = ["game__name", "event__name"]
|
search_fields = ["game__name", "event__name"]
|
||||||
actions = ["sync_scopes", "stop_scope_sync"]
|
actions = ["enable", "disable", "sync_scopes", "stop_scope_sync"]
|
||||||
change_actions = ["sync_scopes", "stop_scope_sync"]
|
change_actions = ["sync_scopes", "stop_scope_sync"]
|
||||||
|
|
||||||
@admin.display(description="AAndT token", boolean=True)
|
@admin.display(description="AAndT token", boolean=True)
|
||||||
def has_apibot_token(self, obj):
|
def has_apibot_token(self, obj):
|
||||||
return bool(obj.apibot_token)
|
return bool(obj.apibot_token)
|
||||||
|
|
||||||
|
def _set_enabled(self, request, queryset, enabled: bool):
|
||||||
|
# save() per row (not update()) so post_save fires, e.g. the clock heartbeat
|
||||||
|
changed = 0
|
||||||
|
for event_game in queryset.select_related("game").filter(enabled=not enabled):
|
||||||
|
event_game.enabled = enabled
|
||||||
|
event_game.save(update_fields=["enabled"])
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{'Enabled' if enabled else 'Disabled'} {changed} EventGame(s)"
|
||||||
|
f" ({queryset.count() - changed} already {'enabled' if enabled else 'disabled'}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Enable selected EventGames")
|
||||||
|
def enable(self, request, queryset):
|
||||||
|
self._set_enabled(request, queryset, enabled=True)
|
||||||
|
|
||||||
|
@admin.action(description="Disable selected EventGames")
|
||||||
|
def disable(self, request, queryset):
|
||||||
|
self._set_enabled(request, queryset, enabled=False)
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
label="Sync scopes",
|
label="Sync scopes",
|
||||||
description="Sync scopes from AAndT for selected EventGames",
|
description="Sync scopes from AAndT for selected EventGames",
|
||||||
@@ -90,3 +112,10 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
||||||
|
|
||||||
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlanningSlot)
|
||||||
|
class PlanningSlotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
|
||||||
|
list_filter = ["event", "is_multi_anims"]
|
||||||
|
search_fields = ["title"]
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from typing import List
|
|||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
|
||||||
from .models import Game
|
from .models import Game
|
||||||
from .schemas import GameOut, SteamGameOut
|
from .schemas import GameOut, PlanningSlotOut, SteamGameOut
|
||||||
|
from .services.planning import planning_with_games
|
||||||
from .tasks import schedule_steam_refresh
|
from .tasks import schedule_steam_refresh
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -28,3 +29,9 @@ def list_event_steam_games(request, event_path: str):
|
|||||||
return Game.objects.enabled_for_event(request.event).filter(
|
return Game.objects.enabled_for_event(request.event).filter(
|
||||||
steam_app_id__isnull=False
|
steam_app_id__isnull=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
|
||||||
|
def list_event_planning(request, event_path: str):
|
||||||
|
"""The event planning, with the covers of the games we know."""
|
||||||
|
return planning_with_games(request.event.planning_slots.all())
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.services.planning import (
|
||||||
|
create_missing_steam_games,
|
||||||
|
import_planning,
|
||||||
|
slot_game_names,
|
||||||
|
)
|
||||||
|
from games.tasks import refresh_steam_games_task
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Import an event planning from the legacy PolyLAN planning JSON "
|
||||||
|
"(slots missing from the file are deleted)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
|
||||||
|
parser.add_argument("json_file", help="Planning JSON file, '-' for stdin")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-steam",
|
||||||
|
action="store_true",
|
||||||
|
help="Don't look up unknown games on the Steam store",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(path=options["event_path"])
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
raise CommandError(f"No event with path {options['event_path']!r}")
|
||||||
|
|
||||||
|
if options["json_file"] == "-":
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
else:
|
||||||
|
with open(options["json_file"], encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
entries = data["list"] if isinstance(data, dict) else data
|
||||||
|
with transaction.atomic():
|
||||||
|
created, updated, deleted = import_planning(event, entries)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if options["no_steam"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
games = create_missing_steam_games(event, slot_game_names(entries))
|
||||||
|
for game in games:
|
||||||
|
self.stdout.write(f"+ {game.name} (Steam app {game.steam_app_id})")
|
||||||
|
if games:
|
||||||
|
# Prices, blurb, ... for the games page
|
||||||
|
refresh_steam_games_task.enqueue(game_ids=[game.id for game in games])
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"{len(games)} game(s) found on Steam and added to {event}")
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-16 00:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0010_game_steam_store_info"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="library_image",
|
||||||
|
field=models.URLField(blank=True, default="", max_length=500),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-16 03:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("events", "0003_event_is_available"),
|
||||||
|
("games", "0011_game_library_image"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlanningSlot",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("source_pk", models.PositiveIntegerField()),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("begin", models.DateTimeField()),
|
||||||
|
("end", models.DateTimeField()),
|
||||||
|
("column", models.PositiveSmallIntegerField(default=0)),
|
||||||
|
("color", models.CharField(blank=True, default="", max_length=7)),
|
||||||
|
("is_multi_anims", models.BooleanField(default=False)),
|
||||||
|
("game_names", models.JSONField(blank=True, default=list)),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="planning_slots",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["begin", "column"],
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("event", "source_pk"), name="unique_event_planning_slot"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,6 +15,8 @@ class Game(models.Model):
|
|||||||
custom_path = models.BooleanField(default=False)
|
custom_path = models.BooleanField(default=False)
|
||||||
image = models.URLField(null=True, blank=True)
|
image = models.URLField(null=True, blank=True)
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
|
# Vertical (600x900) cover for games/showcase, empty = Steam's library capsule
|
||||||
|
library_image = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
|
||||||
# Steam store info, refreshed in the background (see games.tasks).
|
# Steam store info, refreshed in the background (see games.tasks).
|
||||||
# Prices are in cents, in steam_currency.
|
# Prices are in cents, in steam_currency.
|
||||||
@@ -40,6 +42,19 @@ class Game(models.Model):
|
|||||||
return f"{self.name} ({self.steam_app_id})"
|
return f"{self.name} ({self.steam_app_id})"
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def store_url(self) -> str:
|
||||||
|
if not self.steam_app_id:
|
||||||
|
return ""
|
||||||
|
return f"https://store.steampowered.com/app/{self.steam_app_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def library_image_url(self) -> str:
|
||||||
|
"""Vertical cover, like in the Steam library. Empty if there's none."""
|
||||||
|
if self.library_image or not self.steam_app_id:
|
||||||
|
return self.library_image
|
||||||
|
return f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{self.steam_app_id}/library_600x900.jpg"
|
||||||
|
|
||||||
|
|
||||||
class EventGame(models.Model):
|
class EventGame(models.Model):
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
@@ -74,3 +89,37 @@ class EventGame(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSlot(models.Model):
|
||||||
|
"""One slot of the event planning, imported from the legacy PolyLAN planning
|
||||||
|
(see the import_planning command)."""
|
||||||
|
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="planning_slots",
|
||||||
|
)
|
||||||
|
# pk of the slot in the legacy planning, to update it on re-import
|
||||||
|
source_pk = models.PositiveIntegerField()
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
begin = models.DateTimeField()
|
||||||
|
end = models.DateTimeField()
|
||||||
|
column = models.PositiveSmallIntegerField(default=0)
|
||||||
|
color = models.CharField(max_length=7, blank=True, default="")
|
||||||
|
# Multi-anims (e.g. "Fils rouges") run several games over the whole event
|
||||||
|
is_multi_anims = models.BooleanField(default=False)
|
||||||
|
# Names of the games run in this slot (the legacy "aandts")
|
||||||
|
game_names = models.JSONField(default=list, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["begin", "column"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["event", "source_pk"],
|
||||||
|
name="unique_event_planning_slot",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.title} @ {self.event.name} ({self.begin:%a %H:%M})"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class SteamGameOut(Schema):
|
|||||||
steam_app_id: int
|
steam_app_id: int
|
||||||
store_url: str
|
store_url: str
|
||||||
header_image: str
|
header_image: str
|
||||||
|
library_image: str
|
||||||
short_description: str
|
short_description: str
|
||||||
is_free: bool
|
is_free: bool
|
||||||
currency: str
|
currency: str
|
||||||
@@ -35,7 +36,7 @@ class SteamGameOut(Schema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_store_url(obj) -> str:
|
def resolve_store_url(obj) -> str:
|
||||||
return f"https://store.steampowered.com/app/{obj.steam_app_id}"
|
return obj.store_url
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_header_image(obj) -> str:
|
def resolve_header_image(obj) -> str:
|
||||||
@@ -44,6 +45,10 @@ class SteamGameOut(Schema):
|
|||||||
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
|
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_library_image(obj) -> str:
|
||||||
|
return obj.library_image_url
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_short_description(obj) -> str:
|
def resolve_short_description(obj) -> str:
|
||||||
return obj.steam_short_description
|
return obj.steam_short_description
|
||||||
@@ -71,3 +76,23 @@ class SteamGameOut(Schema):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_updated_at(obj) -> Optional[datetime]:
|
def resolve_updated_at(obj) -> Optional[datetime]:
|
||||||
return obj.steam_info_updated_at
|
return obj.steam_info_updated_at
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningGameOut(Schema):
|
||||||
|
name: str
|
||||||
|
# Empty when the name didn't match any of our games
|
||||||
|
library_image: str = ""
|
||||||
|
store_url: str = ""
|
||||||
|
steam_app_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSlotOut(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
begin: datetime
|
||||||
|
end: datetime
|
||||||
|
column: int
|
||||||
|
color: str
|
||||||
|
is_multi_anims: bool
|
||||||
|
# Single slot: the slot's game. Multi-anims: each of their games.
|
||||||
|
games: list[PlanningGameOut]
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from ..models import EventGame, Game, PlanningSlot
|
||||||
|
from .steam_store import search_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# "Cosmoteer: Starship Architect & Commander" is still "Cosmoteer"
|
||||||
|
SUBTITLE_SEPARATOR = re.compile(r"\s*(?::|\s-\s|\s–\s|\s—\s)")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(name: str) -> str:
|
||||||
|
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
|
||||||
|
no accents, no case, letters and digits only."""
|
||||||
|
# Before NFKD, which turns ™ into "TM"
|
||||||
|
name = re.sub(r"[™®©]", "", name)
|
||||||
|
name = unicodedata.normalize("NFKD", name)
|
||||||
|
name = "".join(c for c in name if not unicodedata.combining(c))
|
||||||
|
return re.sub(r"[^0-9a-z]", "", name.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _steam_match(name: str) -> dict | None:
|
||||||
|
"""The Steam store app named name (or name + a subtitle), if there's one.
|
||||||
|
Strict on purpose: "Jass" or "Crochet" must not end up as some random game."""
|
||||||
|
wanted = normalize_name(name)
|
||||||
|
if not wanted:
|
||||||
|
return None
|
||||||
|
# Steam's search chokes on punctuation ("Puyo Puyo — Tetris" finds nothing)
|
||||||
|
term = " ".join(re.sub(r"[^\w\s]", " ", name).split())
|
||||||
|
for item in search_store(term):
|
||||||
|
steam_name = item.get("name") or ""
|
||||||
|
if wanted in (
|
||||||
|
normalize_name(steam_name),
|
||||||
|
normalize_name(SUBTITLE_SEPARATOR.split(steam_name, maxsplit=1)[0]),
|
||||||
|
):
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_path(name: str) -> str:
|
||||||
|
base = slugify(name)[:90] or "game"
|
||||||
|
path, n = base, 2
|
||||||
|
while Game.objects.filter(path=path).exists():
|
||||||
|
path, n = f"{base}-{n}", n + 1
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def create_missing_steam_games(event, names: list[str]) -> list[Game]:
|
||||||
|
"""For each name without a Game, look it up on the Steam store and create
|
||||||
|
the Game + its EventGame when found. Returns the created games."""
|
||||||
|
known = {normalize_name(game.name) for game in Game.objects.all()}
|
||||||
|
created = []
|
||||||
|
for name in dict.fromkeys(names): # unique, keeps order
|
||||||
|
if normalize_name(name) in known:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item = _steam_match(name)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
logger.warning("Steam store search failed for %r: %s", name, exc)
|
||||||
|
continue
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if Game.objects.filter(steam_app_id=item["id"]).exists():
|
||||||
|
# Already there under another name, the planning won't match it
|
||||||
|
logger.warning(
|
||||||
|
"%r is Steam app %s, already a Game under another name",
|
||||||
|
name,
|
||||||
|
item["id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Named like in the planning, so the slot matches it
|
||||||
|
game = Game.objects.create(
|
||||||
|
name=name, steam_app_id=item["id"], path=_unique_path(name)
|
||||||
|
)
|
||||||
|
EventGame.objects.get_or_create(event=event, game=game)
|
||||||
|
known.add(normalize_name(name))
|
||||||
|
created.append(game)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def slot_game_names(entries: list[dict]) -> list[str]:
|
||||||
|
"""Names to look for in a planning: titles of single slots, game names of
|
||||||
|
multi-anims (their title, like "Fils rouges", isn't a game)."""
|
||||||
|
names = []
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.get("is_multi_anims"):
|
||||||
|
names.append(entry["title"])
|
||||||
|
names += [a["name"] for a in entry.get("aandts") or []]
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _game_out(name: str, game: Game | None) -> dict:
|
||||||
|
if game is None:
|
||||||
|
return {"name": name}
|
||||||
|
return {
|
||||||
|
"name": game.name,
|
||||||
|
"library_image": game.library_image_url,
|
||||||
|
"store_url": game.store_url,
|
||||||
|
"steam_app_id": game.steam_app_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def planning_with_games(slots) -> list[dict]:
|
||||||
|
"""Slots as PlanningSlotOut dicts, with their games matched by name to ours."""
|
||||||
|
games_by_name = {normalize_name(game.name): game for game in Game.objects.all()}
|
||||||
|
|
||||||
|
def match(name: str) -> Game | None:
|
||||||
|
return games_by_name.get(normalize_name(name))
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for slot in slots:
|
||||||
|
if slot.is_multi_anims:
|
||||||
|
games = [_game_out(name, match(name)) for name in slot.game_names]
|
||||||
|
else:
|
||||||
|
# The title is the usual match, the legacy game names are a fallback
|
||||||
|
# ("Golf it!" slot running the "Golf It!" game)
|
||||||
|
game = next(
|
||||||
|
filter(None, map(match, [slot.title, *slot.game_names])), None
|
||||||
|
)
|
||||||
|
games = [_game_out(slot.title, game)]
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": slot.id,
|
||||||
|
"title": slot.title,
|
||||||
|
"begin": slot.begin,
|
||||||
|
"end": slot.end,
|
||||||
|
"column": slot.column,
|
||||||
|
"color": slot.color,
|
||||||
|
"is_multi_anims": slot.is_multi_anims,
|
||||||
|
"games": games,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def import_planning(event, entries: list[dict]) -> tuple[int, int, int]:
|
||||||
|
"""Sync the event planning with the legacy planning entries (its "list").
|
||||||
|
Returns (created, updated, deleted)."""
|
||||||
|
created = updated = 0
|
||||||
|
for entry in entries:
|
||||||
|
_, was_created = PlanningSlot.objects.update_or_create(
|
||||||
|
event=event,
|
||||||
|
source_pk=entry["pk"],
|
||||||
|
defaults={
|
||||||
|
"title": entry["title"],
|
||||||
|
"begin": entry["begin"],
|
||||||
|
"end": entry["end"],
|
||||||
|
"column": entry.get("colonneS") or 0,
|
||||||
|
"color": entry.get("color_code") or "",
|
||||||
|
"is_multi_anims": bool(entry.get("is_multi_anims")),
|
||||||
|
"game_names": [a["name"] for a in entry.get("aandts") or []],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
deleted, _ = (
|
||||||
|
PlanningSlot.objects.filter(event=event)
|
||||||
|
.exclude(source_pk__in=[entry["pk"] for entry in entries])
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
return created, updated, deleted
|
||||||
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
|
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
|
||||||
# which is plenty for a handful of games every few hours.
|
# which is plenty for a handful of games every few hours.
|
||||||
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
|
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
|
||||||
|
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
|
||||||
|
|
||||||
|
|
||||||
class SteamStoreError(Exception):
|
class SteamStoreError(Exception):
|
||||||
@@ -37,6 +38,17 @@ def fetch_app_details(steam_app_id: int) -> dict:
|
|||||||
return entry["data"]
|
return entry["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def search_store(term: str) -> list[dict]:
|
||||||
|
"""Store search results for term, as [{"id": app id, "name": ...}, ...]."""
|
||||||
|
response = httpx.get(
|
||||||
|
STORE_SEARCH_URL,
|
||||||
|
params={"term": term, "cc": settings.STEAM_STORE_COUNTRY, "l": "english"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return (response.json() or {}).get("items") or []
|
||||||
|
|
||||||
|
|
||||||
def refresh_steam_game(game: Game) -> None:
|
def refresh_steam_game(game: Game) -> None:
|
||||||
"""Pull the current store info for a Steam game into its steam_* fields."""
|
"""Pull the current store info for a Steam game into its steam_* fields."""
|
||||||
data = fetch_app_details(game.steam_app_id)
|
data = fetch_app_details(game.steam_app_id)
|
||||||
|
|||||||
@@ -95,7 +95,16 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"Basic Information",
|
"Basic Information",
|
||||||
{"fields": ("steam_id", "url", "title", "description", "is_active")},
|
{
|
||||||
|
"fields": (
|
||||||
|
"event",
|
||||||
|
"steam_id",
|
||||||
|
"url",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,6 +29,7 @@ from .schemas import (
|
|||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PuzzleResponseOut,
|
PuzzleResponseOut,
|
||||||
ValidationIn,
|
ValidationIn,
|
||||||
|
SubmissionFileOut,
|
||||||
SteamCollectionItemOut,
|
SteamCollectionItemOut,
|
||||||
SteamCollectionOut,
|
SteamCollectionOut,
|
||||||
)
|
)
|
||||||
@@ -122,13 +127,17 @@ def create_submission(
|
|||||||
for response_data in data.responses
|
for response_data in data.responses
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Non-admin submissions always go through manual validation
|
||||||
|
needs_validation = (
|
||||||
|
data.manual_validation_requested or not request.user.is_staff
|
||||||
|
)
|
||||||
|
|
||||||
# Create the submission
|
# Create the submission
|
||||||
submission = Submission.objects.create(
|
submission = Submission.objects.create(
|
||||||
event=request.event,
|
event=request.event,
|
||||||
user=request.user if request.user.is_authenticated else None,
|
user=request.user if request.user.is_authenticated else None,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
manual_validation_requested=data.manual_validation_requested
|
manual_validation_requested=needs_validation or auto_request_validation,
|
||||||
or auto_request_validation,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
file_index = 0
|
file_index = 0
|
||||||
@@ -148,7 +157,7 @@ def create_submission(
|
|||||||
cost=response_data.cost,
|
cost=response_data.cost,
|
||||||
cycles=response_data.cycles,
|
cycles=response_data.cycles,
|
||||||
area=response_data.area,
|
area=response_data.area,
|
||||||
needs_manual_validation=data.manual_validation_requested,
|
needs_manual_validation=needs_validation,
|
||||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
||||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
||||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
ocr_confidence_area=response_data.ocr_confidence_area,
|
||||||
@@ -158,7 +167,7 @@ def create_submission(
|
|||||||
"validated_cycles": response_data.cycles,
|
"validated_cycles": response_data.cycles,
|
||||||
"validated_area": response_data.area,
|
"validated_area": response_data.area,
|
||||||
}
|
}
|
||||||
if not data.manual_validation_requested
|
if not needs_validation
|
||||||
else {},
|
else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -298,6 +307,24 @@ def list_responses_needing_validation(request, event_path: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
f"{PREFIX}/responses/{{response_id}}/files",
|
||||||
|
response={200: List[SubmissionFileOut], 403: dict},
|
||||||
|
)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
def list_response_files(request, event_path: str, response_id: int):
|
||||||
|
"""Get the solution files of any player's response (admin only)"""
|
||||||
|
|
||||||
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
|
response = get_object_or_404(
|
||||||
|
PuzzleResponse, id=response_id, submission__event=request.event
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.files.all()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
f"{PREFIX}/submissions/{{submission_id}}/validate",
|
f"{PREFIX}/submissions/{{submission_id}}/validate",
|
||||||
response={200: SubmissionOut, 403: dict},
|
response={200: SubmissionOut, 403: dict},
|
||||||
@@ -372,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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Django management command to fetch Steam Workshop collections
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from opus_magnum.utils import create_or_update_collection
|
from opus_magnum.utils import create_or_update_collection
|
||||||
|
from events.models import Event
|
||||||
from opus_magnum.models import SteamAPIKey, SteamCollection
|
from opus_magnum.models import SteamAPIKey, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
||||||
|
parser.add_argument(
|
||||||
|
"--event",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="Path of the event the collection belongs to",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -22,6 +29,11 @@ class Command(BaseCommand):
|
|||||||
url = options["url"]
|
url = options["url"]
|
||||||
force = options["force"]
|
force = options["force"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(path=options["event"])
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
raise CommandError(f"No event with path '{options['event']}'")
|
||||||
|
|
||||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||||
|
|
||||||
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
||||||
@@ -53,7 +65,7 @@ class Command(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Fetch and create/update collection
|
# Fetch and create/update collection
|
||||||
collection, created = create_or_update_collection(url)
|
collection, created = create_or_update_collection(url, event)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 0015 copied rows with their ids via raw INSERT ... SELECT *, which doesn't
|
||||||
|
# bump postgres sequences -> new inserts collide on the pkey. Resync them.
|
||||||
|
|
||||||
|
from django.core.management.color import no_style
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
MODEL_NAMES = [
|
||||||
|
"SteamAPIKey",
|
||||||
|
"SteamCollection",
|
||||||
|
"SteamCollectionItem",
|
||||||
|
"Submission",
|
||||||
|
"PuzzleResponse",
|
||||||
|
"SubmissionFile",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def reset_sequences(apps, schema_editor):
|
||||||
|
connection = schema_editor.connection
|
||||||
|
models = [apps.get_model("opus_magnum", name) for name in MODEL_NAMES]
|
||||||
|
statements = connection.ops.sequence_reset_sql(no_style(), models)
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for sql in statements:
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0018_event_required"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(reset_sequences, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-23 21:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0019_reset_sequences"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="area",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Area value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="cost",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Cost value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="cycles",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Cycles value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -400,9 +400,9 @@ class PuzzleResponse(models.Model):
|
|||||||
puzzle_name = models.CharField(
|
puzzle_name = models.CharField(
|
||||||
max_length=255, help_text="Puzzle name as detected by OCR"
|
max_length=255, help_text="Puzzle name as detected by OCR"
|
||||||
)
|
)
|
||||||
cost = models.IntegerField(blank=True, help_text="Cost value from OCR")
|
cost = models.IntegerField(null=True, blank=True, help_text="Cost value from OCR")
|
||||||
cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR")
|
cycles = models.IntegerField(null=True, blank=True, help_text="Cycles value from OCR")
|
||||||
area = models.IntegerField(blank=True, help_text="Area value from OCR")
|
area = models.IntegerField(null=True, blank=True, help_text="Area value from OCR")
|
||||||
|
|
||||||
# Validation flags
|
# Validation flags
|
||||||
needs_manual_validation = models.BooleanField(
|
needs_manual_validation = models.BooleanField(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import requests
|
import requests
|
||||||
|
from events.models import Event
|
||||||
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -373,12 +374,15 @@ def fetch_steam_collection(url: str) -> Dict:
|
|||||||
return fetcher.fetch_collection_data(url)
|
return fetcher.fetch_collection_data(url)
|
||||||
|
|
||||||
|
|
||||||
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
def create_or_update_collection(
|
||||||
|
url: str, event: Event
|
||||||
|
) -> Tuple[SteamCollection, bool]:
|
||||||
"""
|
"""
|
||||||
Create or update a Steam collection in the database
|
Create or update a Steam collection in the database
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Steam Workshop collection URL
|
url: Steam Workshop collection URL
|
||||||
|
event: Event the collection belongs to
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (SteamCollection instance, created_flag)
|
Tuple of (SteamCollection instance, created_flag)
|
||||||
@@ -395,6 +399,7 @@ def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
|||||||
collection, created = SteamCollection.objects.update_or_create(
|
collection, created = SteamCollection.objects.update_or_create(
|
||||||
steam_id=data["steam_id"],
|
steam_id=data["steam_id"],
|
||||||
defaults={
|
defaults={
|
||||||
|
"event": event,
|
||||||
"url": data["url"],
|
"url": data["url"],
|
||||||
"title": data["title"],
|
"title": data["title"],
|
||||||
"description": data["description"],
|
"description": data["description"],
|
||||||
|
|||||||
@@ -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",)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import csv
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
from django.db.models import Count
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
from ninja.decorators import decorate_view
|
from ninja.decorators import decorate_view
|
||||||
@@ -8,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 .models import PlaceSettings
|
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()
|
router = Router()
|
||||||
|
|
||||||
@@ -25,15 +36,116 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{PREFIX}/evolution.gif", auth=django_auth_is_staff)
|
@router.get(f"{PREFIX}/evolution.gif", auth=django_auth_is_staff)
|
||||||
@decorate_view(require_game_enabled(path="place"))
|
@decorate_view(require_game_enabled(path="place"))
|
||||||
def evolution_gif(request: HttpRequest, event_path: str):
|
def evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
|
||||||
"""Admin-only: replays the full placement history as an animated GIF."""
|
"""Admin-only: replays the full placement history as an animated GIF.
|
||||||
return HttpResponse(build_evolution_gif(request.event), content_type="image/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}/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)
|
||||||
|
@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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -25,22 +25,25 @@ _PALETTE_RGB = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def build_evolution_gif(event: Event) -> bytes:
|
def build_evolution_gif(event: Event, usernames: list[str] | None = None) -> bytes:
|
||||||
"""Renders `event`'s full placement history as an animated GIF, upscaled
|
"""Renders `event`'s full placement history as an animated GIF, upscaled
|
||||||
for visibility. Returns GIF bytes, even if there are zero placements.
|
for visibility. Returns GIF bytes, even if there are zero placements.
|
||||||
|
|
||||||
|
If `usernames` is given, only those players' placements are replayed
|
||||||
|
(combined on the same board).
|
||||||
|
|
||||||
Always replays at the *current* grid dimensions - a placement made
|
Always replays at the *current* grid dimensions - a placement made
|
||||||
before a resize that now falls outside them is skipped rather than
|
before a resize that now falls outside them is skipped rather than
|
||||||
raising."""
|
raising."""
|
||||||
place_settings = PlaceSettings.current(event)
|
place_settings = PlaceSettings.current(event)
|
||||||
width, height = place_settings.width, place_settings.height
|
width, height = place_settings.width, place_settings.height
|
||||||
|
|
||||||
placements = (
|
placements = PixelPlacement.objects.filter(event=event)
|
||||||
PixelPlacement.objects.filter(event=event)
|
if usernames:
|
||||||
.order_by("placed_at")
|
placements = placements.filter(player__username__in=usernames)
|
||||||
.values_list("x", "y", "color_index")
|
placements = list(
|
||||||
|
placements.order_by("placed_at").values_list("x", "y", "color_index")
|
||||||
)
|
)
|
||||||
placements = list(placements)
|
|
||||||
|
|
||||||
grid = bytearray(width * height)
|
grid = bytearray(width * height)
|
||||||
frames: list[Image.Image] = []
|
frames: list[Image.Image] = []
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ ALLOWED_HOSTS = [
|
|||||||
if host
|
if host
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# TLS is terminated by the reverse proxy in front of the container, so daphne
|
||||||
|
# only ever sees plain http. Without these, Django compares the browser's
|
||||||
|
# `Origin: https://...` against `http://...` and rejects every POST (admin
|
||||||
|
# login included) with "CSRF verification failed - Origin checking failed".
|
||||||
|
# Full origins with scheme, e.g. "https://submitter.polylan.ch".
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
origin
|
||||||
|
for origin in (os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS") or "").split(",")
|
||||||
|
if origin
|
||||||
|
]
|
||||||
|
|
||||||
|
# Trust the proxy's X-Forwarded-Proto so request.is_secure() /
|
||||||
|
# build_absolute_uri() report https. Only safe if the proxy overwrites that
|
||||||
|
# header (client-supplied values must not pass through) - opt-in for that
|
||||||
|
# reason.
|
||||||
|
if os.environ.get("DJANGO_BEHIND_TLS_PROXY", "false").lower() == "true":
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@@ -382,6 +402,6 @@ sentry_sdk.init(
|
|||||||
dsn=SENTRY_DSN,
|
dsn=SENTRY_DSN,
|
||||||
# Set traces_sample_rate to 1.0 to capture 100%
|
# Set traces_sample_rate to 1.0 to capture 100%
|
||||||
# of transactions for tracing.
|
# of transactions for tracing.
|
||||||
traces_sample_rate=1.0,
|
traces_sample_rate=0.1,
|
||||||
environment=SENTRY_ENVIRONMENT,
|
environment=SENTRY_ENVIRONMENT,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ def games_home(request: HttpRequest, event_path: str):
|
|||||||
return render(request, "app.html")
|
return render(request, "app.html")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def planning_home(request: HttpRequest, event_path: str):
|
||||||
|
get_object_or_404(Event, path=event_path)
|
||||||
|
return render(request, "app.html")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
||||||
def opus_magnum_home(request: HttpRequest, event_path: str):
|
def opus_magnum_home(request: HttpRequest, event_path: str):
|
||||||
@@ -102,6 +108,7 @@ urlpatterns = [
|
|||||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
path("<slug:event_path>/games", games_home, name="games.home"),
|
path("<slug:event_path>/games", games_home, name="games.home"),
|
||||||
|
path("<slug:event_path>/planning", planning_home, name="planning.home"),
|
||||||
path("<slug:event_path>/market", market_home, name="market.home"),
|
path("<slug:event_path>/market", market_home, name="market.home"),
|
||||||
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||||
path("<slug:event_path>/noita", noita_home, name="noita.home"),
|
path("<slug:event_path>/noita", noita_home, name="noita.home"),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useTitle } from "@vueuse/core";
|
import { useStorage, useTitle } from "@vueuse/core";
|
||||||
import { gamesApiListEventSteamGames } from "./api";
|
import { gamesApiListEventSteamGames } from "./api";
|
||||||
import type { SteamGameOut } from "./api/types.gen";
|
import type { SteamGameOut } from "./api/types.gen";
|
||||||
|
import SteamLibraryCard from "./components/SteamLibraryCard.vue";
|
||||||
import SteamStoreCard from "./components/SteamStoreCard.vue";
|
import SteamStoreCard from "./components/SteamStoreCard.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,6 +18,8 @@ useTitle(computed(() => t("games.title")));
|
|||||||
|
|
||||||
const games = ref<SteamGameOut[]>([]);
|
const games = ref<SteamGameOut[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
// Library (vertical covers) by default, the store cards show the prices
|
||||||
|
const showPrices = useStorage("games.showPrices", true);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const response = await gamesApiListEventSteamGames({
|
const response = await gamesApiListEventSteamGames({
|
||||||
@@ -33,11 +36,18 @@ onMounted(async () => {
|
|||||||
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
||||||
<div class="w-full max-w-7xl">
|
<div class="w-full max-w-7xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
|
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
|
||||||
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
|
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mb-6">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<span>{{ t("games.showPrices") }}</span>
|
||||||
|
<input v-model="showPrices" type="checkbox" class="toggle toggle-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="flex justify-center py-20">
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
@@ -47,9 +57,14 @@ onMounted(async () => {
|
|||||||
{{ t("games.empty") }}
|
{{ t("games.empty") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div v-else-if="showPrices" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
|
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else
|
||||||
|
class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-4 sm:gap-6">
|
||||||
|
<SteamLibraryCard v-for="game in games" :key="game.id" :game="game" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from "vue-router";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useTitle } from "@vueuse/core";
|
import { useTitle } from "@vueuse/core";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { eventGamesQuery } from "@/queries";
|
import { eventGamesQuery, eventPlanningQuery } from "@/queries";
|
||||||
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +19,8 @@ useTitle(computed(() => t("home.title")));
|
|||||||
|
|
||||||
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
|
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
|
||||||
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
|
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
|
||||||
|
const { data: planning } = useQuery(eventPlanningQuery(props.eventPath));
|
||||||
|
const hasPlanning = computed(() => !!planning.value?.length);
|
||||||
const imageErrors = ref<Set<number>>(new Set());
|
const imageErrors = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
|
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
|
||||||
@@ -86,9 +88,13 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Link to the Steam games page -->
|
<!-- Links to the planning and the Steam games page -->
|
||||||
<div v-if="!loading && hasSteamGames" class="text-center mt-12">
|
<div v-if="!loading && (hasPlanning || hasSteamGames)" class="flex flex-wrap justify-center gap-4 mt-12">
|
||||||
<RouterLink :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
|
<RouterLink v-if="hasPlanning" :to="{ name: 'planning', params: { eventPath } }" class="btn btn-outline">
|
||||||
|
<i class="mdi mdi-calendar-clock mr-2"></i>
|
||||||
|
{{ t("home.planning") }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink v-if="hasSteamGames" :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
|
||||||
<i class="mdi mdi-steam mr-2"></i>
|
<i class="mdi mdi-steam mr-2"></i>
|
||||||
{{ t("home.getTheGames") }}
|
{{ t("home.getTheGames") }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -133,20 +152,36 @@ function handleClick(event: MouseEvent) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
|
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
|
||||||
<div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }">
|
<!-- Sized to fill the width, but capped so the full height (minus the
|
||||||
|
navbar and the controls below) still fits in the viewport. -->
|
||||||
|
<div class="relative" :style="{
|
||||||
|
aspectRatio: `${gridWidth} / ${gridHeight}`,
|
||||||
|
width: `min(100%, calc((100dvh - 16rem) * ${gridWidth} / ${gridHeight}))`,
|
||||||
|
}">
|
||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Timer shares the toggle row so showing it doesn't push the page past
|
||||||
|
the viewport height the grid is sized against. -->
|
||||||
|
<div class="flex items-center gap-4 h-8">
|
||||||
<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>
|
||||||
|
<span v-if="placementEnded" class="badge badge-warning">{{ t("place.placementEnded") }}</span>
|
||||||
|
<template v-else-if="onCooldown">
|
||||||
|
<span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span>
|
||||||
|
<progress class="progress progress-primary w-48"
|
||||||
|
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
||||||
|
</template>
|
||||||
|
<span v-if="placementEndDisplay && !placementEnded" class="text-sm opacity-70">
|
||||||
|
{{ t("place.endsAt", { time: placementEndDisplay }) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-2 max-w-md">
|
<div class="flex flex-wrap justify-center gap-2 max-w-md">
|
||||||
@@ -154,13 +189,5 @@ function handleClick(event: MouseEvent) {
|
|||||||
:class="selectedColorIndex === index ? 'border-primary scale-110' : 'border-base-300'"
|
:class="selectedColorIndex === index ? 'border-primary scale-110' : 'border-base-300'"
|
||||||
:style="{ backgroundColor: color }" @click="selectedColorIndex = index" />
|
:style="{ backgroundColor: color }" @click="selectedColorIndex = index" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-2">
|
|
||||||
<span v-if="onCooldown" class="font-bold text-2xl tabular-nums">
|
|
||||||
{{ cooldownSecondsDisplay }}s
|
|
||||||
</span>
|
|
||||||
<progress v-if="onCooldown" class="progress progress-primary w-48"
|
|
||||||
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useTemplateRef } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useElementSize, useTitle } from "@vueuse/core";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { eventPlanningQuery } from "@/queries";
|
||||||
|
import type { PlanningSlotOut } from "./api/types.gen";
|
||||||
|
import PlanningCover from "./components/PlanningCover.vue";
|
||||||
|
import PlanningMultiSlot from "./components/PlanningMultiSlot.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eventPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("planning.title")));
|
||||||
|
|
||||||
|
const { data: slots, isPending: loading } = useQuery(eventPlanningQuery(props.eventPath));
|
||||||
|
|
||||||
|
const HOUR = 3600e3;
|
||||||
|
// Hour height per px of column width: a 2h slot ends up close to the 2:3 cover ratio
|
||||||
|
const HOUR_RATIO = 104 / 150;
|
||||||
|
const BREAK_PX = 34;
|
||||||
|
const DAY_PX = 32;
|
||||||
|
const AXIS_PX = 56;
|
||||||
|
// Columns shrink to fit the page, the planning only scrolls sideways below the min
|
||||||
|
const MIN_COL_PX = 80;
|
||||||
|
const MAX_COL_PX = 150;
|
||||||
|
const GAP_PX = 10;
|
||||||
|
// The multi-anims column is the big one, its covers 1.75x the size of the others
|
||||||
|
const WIDE_RATIO = 1.75;
|
||||||
|
// Room on the left for the day names, they stick out of the time axis
|
||||||
|
const DAY_LABEL_ROOM_PX = 120;
|
||||||
|
// Empty runs at least this long (nights) get squashed into a thin break
|
||||||
|
const MIN_BREAK_HOURS = 3;
|
||||||
|
|
||||||
|
const fmtTime = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||||
|
const fmtDay = new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "short" });
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
y: number;
|
||||||
|
h: number;
|
||||||
|
kind: "hour" | "idle" | "break" | "day";
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Placed {
|
||||||
|
slot: PlanningSlotOut;
|
||||||
|
top: number;
|
||||||
|
height: number;
|
||||||
|
when: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroller = useTemplateRef<HTMLElement>("scroller");
|
||||||
|
const { width: available } = useElementSize(scroller);
|
||||||
|
|
||||||
|
const layout = computed(() => {
|
||||||
|
const list = (slots.value ?? []).map((slot) => ({
|
||||||
|
slot,
|
||||||
|
b: Date.parse(slot.begin),
|
||||||
|
e: Date.parse(slot.end),
|
||||||
|
}));
|
||||||
|
if (!list.length) return null;
|
||||||
|
|
||||||
|
// Unused columns are dropped, the multi-anims ones are wider
|
||||||
|
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
|
||||||
|
const isWide = (id: number) => list.some(({ slot }) => slot.column === id && slot.is_multi_anims);
|
||||||
|
const wideCount = columnIds.filter(isWide).length;
|
||||||
|
const fit =
|
||||||
|
(available.value - DAY_LABEL_ROOM_PX - AXIS_PX - (columnIds.length - 1) * GAP_PX) /
|
||||||
|
(columnIds.length + (WIDE_RATIO - 1) * wideCount);
|
||||||
|
const colPx = available.value ? Math.min(MAX_COL_PX, Math.max(MIN_COL_PX, fit)) : MAX_COL_PX;
|
||||||
|
const hourPx = colPx * HOUR_RATIO;
|
||||||
|
|
||||||
|
// Multi-anims run the whole event, only the other slots decide what's busy
|
||||||
|
const singles = list.filter(({ slot }) => !slot.is_multi_anims);
|
||||||
|
const busy = (t: number) => singles.some(({ b, e }) => b < t + HOUR && e > t);
|
||||||
|
|
||||||
|
const start = Math.floor(Math.min(...list.map(({ b }) => b)) / HOUR) * HOUR;
|
||||||
|
const end = Math.ceil(Math.max(...list.map(({ e }) => e)) / HOUR) * HOUR;
|
||||||
|
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
let y = 0;
|
||||||
|
let lastDay = "";
|
||||||
|
for (let t = start; t < end;) {
|
||||||
|
if (busy(t)) {
|
||||||
|
// Each day gets its own header row, takes no time
|
||||||
|
const day = fmtDay.format(t);
|
||||||
|
if (day !== lastDay) {
|
||||||
|
segments.push({ from: t, to: t, y, h: DAY_PX, kind: "day", label: day });
|
||||||
|
y += DAY_PX;
|
||||||
|
lastDay = day;
|
||||||
|
}
|
||||||
|
segments.push({ from: t, to: t + HOUR, y, h: hourPx, kind: "hour" });
|
||||||
|
y += hourPx;
|
||||||
|
t += HOUR;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let u = t;
|
||||||
|
while (u < end && !busy(u)) u += HOUR;
|
||||||
|
const hours = (u - t) / HOUR;
|
||||||
|
const isBreak = hours >= MIN_BREAK_HOURS;
|
||||||
|
const h = isBreak ? BREAK_PX : hourPx * hours;
|
||||||
|
segments.push({ from: t, to: u, y, h, kind: isBreak ? "break" : "idle" });
|
||||||
|
y += h;
|
||||||
|
t = u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day headers have no duration, they never contain a time
|
||||||
|
const timed = segments.filter((s) => s.kind !== "day");
|
||||||
|
const yOf = (t: number) => {
|
||||||
|
const s = timed.find((s) => t >= s.from && t < s.to) ?? timed[timed.length - 1]!;
|
||||||
|
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
|
||||||
|
};
|
||||||
|
|
||||||
|
const days = segments
|
||||||
|
.filter((s) => s.kind === "day")
|
||||||
|
.map((s) => ({ y: s.y, label: s.label! }));
|
||||||
|
|
||||||
|
// No tick right under a day header, it would sit on its line
|
||||||
|
const dayEnds = new Set(days.map((d) => d.y + DAY_PX));
|
||||||
|
const ticks = timed
|
||||||
|
.filter((s) => s.kind !== "break")
|
||||||
|
.flatMap((s) => {
|
||||||
|
const out = [];
|
||||||
|
for (let t = s.from; t < s.to; t += HOUR) out.push({ y: yOf(t), label: fmtTime.format(t) });
|
||||||
|
return out;
|
||||||
|
})
|
||||||
|
.filter((tick) => !dayEnds.has(tick.y));
|
||||||
|
|
||||||
|
const breaks = segments
|
||||||
|
.filter((s) => s.kind === "break")
|
||||||
|
.map((s) => ({ y: s.y, h: s.h, label: `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}` }));
|
||||||
|
|
||||||
|
let left = AXIS_PX;
|
||||||
|
const columns = columnIds.map((id) => {
|
||||||
|
const placed = list
|
||||||
|
.filter(({ slot }) => slot.column === id)
|
||||||
|
.map<Placed>(({ slot, b, e }) => ({
|
||||||
|
slot,
|
||||||
|
top: yOf(b) + 3,
|
||||||
|
height: yOf(e) - yOf(b) - 6,
|
||||||
|
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
|
||||||
|
}));
|
||||||
|
const width = isWide(id) ? WIDE_RATIO * colPx : colPx;
|
||||||
|
const column = { left, width, placed };
|
||||||
|
left += width + GAP_PX;
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: y,
|
||||||
|
width: left - GAP_PX,
|
||||||
|
days,
|
||||||
|
ticks,
|
||||||
|
breaks,
|
||||||
|
columns,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
||||||
|
<!-- min-w-0: lets the planning scroll sideways instead of overflowing the page -->
|
||||||
|
<div class="w-full max-w-7xl min-w-0">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-5xl font-bold mb-4">{{ t("planning.title") }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="!layout" class="text-center text-base-content/50 py-20">
|
||||||
|
{{ t("planning.empty") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- pr/pt/pb: room for the Fils rouges glow, the scroll box would clip it -->
|
||||||
|
<div v-else ref="scroller" class="overflow-x-auto pr-10 pt-4 pb-10">
|
||||||
|
<div class="relative" :style="{
|
||||||
|
width: `${layout.width}px`,
|
||||||
|
height: `${layout.height}px`,
|
||||||
|
marginLeft: `max(${DAY_LABEL_ROOM_PX}px, (100% - ${layout.width}px) / 2)`,
|
||||||
|
}">
|
||||||
|
<!-- Time axis: a header row per day, hour ticks on the side -->
|
||||||
|
<!-- Above the cards: slots running past midnight go over the header row -->
|
||||||
|
<div v-for="day in layout.days" :key="`day-${day.y}`"
|
||||||
|
class="absolute left-0 right-0 z-20 pointer-events-none border-b border-base-content/20 flex items-end text-sm font-semibold uppercase tracking-wide"
|
||||||
|
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
|
||||||
|
<!-- Ends at the time axis and grows to the left, never over the cards -->
|
||||||
|
<span class="absolute bottom-0 whitespace-nowrap bg-base-300 rounded-t px-2 py-0.5"
|
||||||
|
:style="{ right: `calc(100% - ${AXIS_PX}px)` }">{{ day.label }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-for="tick in layout.ticks" :key="tick.y">
|
||||||
|
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
|
||||||
|
:style="{ top: `${tick.y}px`, width: `${AXIS_PX}px` }">
|
||||||
|
{{ tick.label }}
|
||||||
|
</span>
|
||||||
|
<div class="absolute right-0 border-t border-dashed border-base-content/5"
|
||||||
|
:style="{ top: `${tick.y}px`, left: `${AXIS_PX}px` }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Nights -->
|
||||||
|
<div v-for="b in layout.breaks" :key="`break-${b.y}`"
|
||||||
|
class="planning-break absolute left-0 right-0 rounded flex items-center justify-center text-xs text-base-content/40"
|
||||||
|
:style="{ top: `${b.y}px`, height: `${b.h}px` }">
|
||||||
|
{{ b.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slots -->
|
||||||
|
<div v-for="column in layout.columns" :key="column.left" class="absolute top-0 h-full"
|
||||||
|
:style="{ left: `${column.left}px`, width: `${column.width}px` }">
|
||||||
|
<template v-for="{ slot, top, height, when } in column.placed" :key="slot.id">
|
||||||
|
<!-- Multi-anims: one long shiny card, its games follow the scroll -->
|
||||||
|
<PlanningMultiSlot v-if="slot.is_multi_anims" :entry="slot" class="absolute inset-x-0"
|
||||||
|
:style="{ top: `${top}px`, height: `${height}px` }" />
|
||||||
|
|
||||||
|
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
|
||||||
|
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="group absolute inset-x-0 block rounded-md overflow-hidden bg-base-200 hover:bg-base-100
|
||||||
|
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05] hover:z-10"
|
||||||
|
:style="{ top: `${top}px`, height: `${height}px` }">
|
||||||
|
<PlanningCover :game="slot.games[0] ?? { name: slot.title }" :color="slot.color" :when="when" />
|
||||||
|
<span class="absolute top-1.5 left-1.5 text-xs px-1.5 rounded bg-black/70 text-white opacity-0
|
||||||
|
group-hover:opacity-100 transition-opacity">
|
||||||
|
{{ when }}
|
||||||
|
</span>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.planning-break {
|
||||||
|
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, 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, 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, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationError, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoError, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseError, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionError, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, 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'> & {
|
||||||
@@ -350,6 +350,26 @@ export const opusMagnumApiListResponsesNeedingValidationOptions = (options: Opti
|
|||||||
queryKey: opusMagnumApiListResponsesNeedingValidationQueryKey(options)
|
queryKey: opusMagnumApiListResponsesNeedingValidationQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const opusMagnumApiListResponseFilesQueryKey = (options: Options<OpusMagnumApiListResponseFilesData>) => createQueryKey('opusMagnumApiListResponseFiles', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Response Files
|
||||||
|
*
|
||||||
|
* Get the solution files of any player's response (admin only)
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiListResponseFilesOptions = (options: Options<OpusMagnumApiListResponseFilesData>) => queryOptions<OpusMagnumApiListResponseFilesResponse, OpusMagnumApiListResponseFilesError, OpusMagnumApiListResponseFilesResponse, ReturnType<typeof opusMagnumApiListResponseFilesQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await opusMagnumApiListResponseFiles({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: opusMagnumApiListResponseFilesQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate Submission
|
* Validate Submission
|
||||||
*
|
*
|
||||||
@@ -551,6 +571,26 @@ export const gamesApiListEventSteamGamesOptions = (options: Options<GamesApiList
|
|||||||
queryKey: gamesApiListEventSteamGamesQueryKey(options)
|
queryKey: gamesApiListEventSteamGamesQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const gamesApiListEventPlanningQueryKey = (options: Options<GamesApiListEventPlanningData>) => createQueryKey('gamesApiListEventPlanning', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Event Planning
|
||||||
|
*
|
||||||
|
* The event planning, with the covers of the games we know.
|
||||||
|
*/
|
||||||
|
export const gamesApiListEventPlanningOptions = (options: Options<GamesApiListEventPlanningData>) => queryOptions<GamesApiListEventPlanningResponse, DefaultError, GamesApiListEventPlanningResponse, ReturnType<typeof gamesApiListEventPlanningQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await gamesApiListEventPlanning({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: gamesApiListEventPlanningQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
export const submissionsApiListMyScopesQueryKey = (options: Options<SubmissionsApiListMyScopesData>) => createQueryKey('submissionsApiListMyScopes', options);
|
export const submissionsApiListMyScopesQueryKey = (options: Options<SubmissionsApiListMyScopesData>) => createQueryKey('submissionsApiListMyScopes', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -836,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 }) => {
|
||||||
@@ -857,6 +898,9 @@ export const placeApiEvolutionGifQueryKey = (options: Options<PlaceApiEvolutionG
|
|||||||
* Evolution Gif
|
* Evolution Gif
|
||||||
*
|
*
|
||||||
* Admin-only: replays the full placement history as an animated GIF.
|
* 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).
|
||||||
*/
|
*/
|
||||||
export const placeApiEvolutionGifOptions = (options: Options<PlaceApiEvolutionGifData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiEvolutionGifQueryKey>>({
|
export const placeApiEvolutionGifOptions = (options: Options<PlaceApiEvolutionGifData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiEvolutionGifQueryKey>>({
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
@@ -871,6 +915,141 @@ 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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placements Csv
|
||||||
|
*
|
||||||
|
* Admin-only: pixel placement count per player, most active first.
|
||||||
|
*/
|
||||||
|
export const placeApiPlacementsCsvOptions = (options: Options<PlaceApiPlacementsCsvData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiPlacementsCsvQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await placeApiPlacementsCsv({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: placeApiPlacementsCsvQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
export const marketApiListMarketsQueryKey = (options: Options<MarketApiListMarketsData>) => createQueryKey('marketApiListMarkets', options);
|
export const marketApiListMarketsQueryKey = (options: Options<MarketApiListMarketsData>) => createQueryKey('marketApiListMarkets', 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, 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, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, 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> & {
|
||||||
/**
|
/**
|
||||||
@@ -122,6 +122,13 @@ export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(
|
|||||||
*/
|
*/
|
||||||
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListResponsesNeedingValidationErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/needs-validation', ...options });
|
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListResponsesNeedingValidationErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/needs-validation', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Response Files
|
||||||
|
*
|
||||||
|
* Get the solution files of any player's response (admin only)
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiListResponseFiles = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponseFilesData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponseFilesResponses, OpusMagnumApiListResponseFilesErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/files', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate Submission
|
* Validate Submission
|
||||||
*
|
*
|
||||||
@@ -203,6 +210,13 @@ export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(opt
|
|||||||
*/
|
*/
|
||||||
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
|
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Event Planning
|
||||||
|
*
|
||||||
|
* The event planning, with the covers of the games we know.
|
||||||
|
*/
|
||||||
|
export const gamesApiListEventPlanning = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventPlanningData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventPlanningResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/planning', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List My Scopes
|
* List My Scopes
|
||||||
*/
|
*/
|
||||||
@@ -320,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 });
|
||||||
|
|
||||||
@@ -328,6 +343,9 @@ export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Op
|
|||||||
* Evolution Gif
|
* Evolution Gif
|
||||||
*
|
*
|
||||||
* Admin-only: replays the full placement history as an animated GIF.
|
* 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).
|
||||||
*/
|
*/
|
||||||
export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiEvolutionGifResponses, unknown, ThrowOnError>({
|
export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiEvolutionGifResponses, unknown, ThrowOnError>({
|
||||||
security: [{
|
security: [{
|
||||||
@@ -339,6 +357,111 @@ 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
|
||||||
|
*
|
||||||
|
* Admin-only: pixel placement count per player, most active first.
|
||||||
|
*/
|
||||||
|
export const placeApiPlacementsCsv = <ThrowOnError extends boolean = false>(options: Options<PlaceApiPlacementsCsvData, ThrowOnError>) => (options.client ?? client).get<PlaceApiPlacementsCsvResponses, unknown, ThrowOnError>({
|
||||||
|
security: [{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}],
|
||||||
|
url: '/api/events/{event_path}/place/placements.csv',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List Markets
|
* List Markets
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -308,19 +308,19 @@ export type PuzzleResponseOut = {
|
|||||||
*
|
*
|
||||||
* Cost value from OCR
|
* Cost value from OCR
|
||||||
*/
|
*/
|
||||||
cost: number;
|
cost?: number | null;
|
||||||
/**
|
/**
|
||||||
* Cycles
|
* Cycles
|
||||||
*
|
*
|
||||||
* Cycles value from OCR
|
* Cycles value from OCR
|
||||||
*/
|
*/
|
||||||
cycles: number;
|
cycles?: number | null;
|
||||||
/**
|
/**
|
||||||
* Area
|
* Area
|
||||||
*
|
*
|
||||||
* Area value from OCR
|
* Area value from OCR
|
||||||
*/
|
*/
|
||||||
area: number;
|
area?: number | null;
|
||||||
/**
|
/**
|
||||||
* Needs Manual Validation
|
* Needs Manual Validation
|
||||||
*
|
*
|
||||||
@@ -952,6 +952,10 @@ export type SteamGameOut = {
|
|||||||
* Header Image
|
* Header Image
|
||||||
*/
|
*/
|
||||||
header_image: string;
|
header_image: string;
|
||||||
|
/**
|
||||||
|
* Library Image
|
||||||
|
*/
|
||||||
|
library_image: string;
|
||||||
/**
|
/**
|
||||||
* Short Description
|
* Short Description
|
||||||
*/
|
*/
|
||||||
@@ -982,6 +986,66 @@ export type SteamGameOut = {
|
|||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlanningGameOut
|
||||||
|
*/
|
||||||
|
export type PlanningGameOut = {
|
||||||
|
/**
|
||||||
|
* Name
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Library Image
|
||||||
|
*/
|
||||||
|
library_image?: string;
|
||||||
|
/**
|
||||||
|
* Store Url
|
||||||
|
*/
|
||||||
|
store_url?: string;
|
||||||
|
/**
|
||||||
|
* Steam App Id
|
||||||
|
*/
|
||||||
|
steam_app_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlanningSlotOut
|
||||||
|
*/
|
||||||
|
export type PlanningSlotOut = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Title
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Begin
|
||||||
|
*/
|
||||||
|
begin: string;
|
||||||
|
/**
|
||||||
|
* End
|
||||||
|
*/
|
||||||
|
end: string;
|
||||||
|
/**
|
||||||
|
* Column
|
||||||
|
*/
|
||||||
|
column: number;
|
||||||
|
/**
|
||||||
|
* Color
|
||||||
|
*/
|
||||||
|
color: string;
|
||||||
|
/**
|
||||||
|
* Is Multi Anims
|
||||||
|
*/
|
||||||
|
is_multi_anims: boolean;
|
||||||
|
/**
|
||||||
|
* Games
|
||||||
|
*/
|
||||||
|
games: Array<PlanningGameOut>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScopeOut
|
* ScopeOut
|
||||||
*/
|
*/
|
||||||
@@ -1200,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
|
||||||
*/
|
*/
|
||||||
@@ -1725,6 +1927,46 @@ export type OpusMagnumApiListResponsesNeedingValidationResponses = {
|
|||||||
|
|
||||||
export type OpusMagnumApiListResponsesNeedingValidationResponse = OpusMagnumApiListResponsesNeedingValidationResponses[keyof OpusMagnumApiListResponsesNeedingValidationResponses];
|
export type OpusMagnumApiListResponsesNeedingValidationResponse = OpusMagnumApiListResponsesNeedingValidationResponses[keyof OpusMagnumApiListResponsesNeedingValidationResponses];
|
||||||
|
|
||||||
|
export type OpusMagnumApiListResponseFilesData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
/**
|
||||||
|
* Response Id
|
||||||
|
*/
|
||||||
|
response_id: number;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/files';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpusMagnumApiListResponseFilesErrors = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* Forbidden
|
||||||
|
*/
|
||||||
|
403: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpusMagnumApiListResponseFilesError = OpusMagnumApiListResponseFilesErrors[keyof OpusMagnumApiListResponseFilesErrors];
|
||||||
|
|
||||||
|
export type OpusMagnumApiListResponseFilesResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<SubmissionFileOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpusMagnumApiListResponseFilesResponse = OpusMagnumApiListResponseFilesResponses[keyof OpusMagnumApiListResponseFilesResponses];
|
||||||
|
|
||||||
export type OpusMagnumApiValidateSubmissionData = {
|
export type OpusMagnumApiValidateSubmissionData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
@@ -1993,6 +2235,29 @@ export type GamesApiListEventSteamGamesResponses = {
|
|||||||
|
|
||||||
export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses];
|
export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses];
|
||||||
|
|
||||||
|
export type GamesApiListEventPlanningData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/planning';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GamesApiListEventPlanningResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<PlanningSlotOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GamesApiListEventPlanningResponse = GamesApiListEventPlanningResponses[keyof GamesApiListEventPlanningResponses];
|
||||||
|
|
||||||
export type SubmissionsApiListMyScopesData = {
|
export type SubmissionsApiListMyScopesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
@@ -2600,7 +2865,12 @@ export type PlaceApiEvolutionGifData = {
|
|||||||
*/
|
*/
|
||||||
event_path: string;
|
event_path: string;
|
||||||
};
|
};
|
||||||
query?: never;
|
query?: {
|
||||||
|
/**
|
||||||
|
* Players
|
||||||
|
*/
|
||||||
|
players?: string;
|
||||||
|
};
|
||||||
url: '/api/events/{event_path}/place/evolution.gif';
|
url: '/api/events/{event_path}/place/evolution.gif';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2611,6 +2881,145 @@ 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 = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/place/placements.csv';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaceApiPlacementsCsvResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type MarketApiListMarketsData = {
|
export type MarketApiListMarketsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { PlanningGameOut } from "../api/types.gen";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
game: PlanningGameOut;
|
||||||
|
color: string;
|
||||||
|
// Slot time, shown on the colored card (the cover has its own hover badge)
|
||||||
|
when?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Not every game has a library capsule on Steam, fall back to the colored card
|
||||||
|
const coverFailed = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img v-if="game.library_image && !coverFailed" :src="game.library_image" :alt="game.name" :title="game.name"
|
||||||
|
loading="lazy" class="w-full h-full object-cover object-top" @error="coverFailed = true" />
|
||||||
|
<div v-else class="planning-fallback w-full h-full flex flex-col justify-center gap-1 p-3 border-l-4"
|
||||||
|
:style="{ '--slot-color': color }">
|
||||||
|
<span class="font-semibold leading-tight text-base-content">{{ game.name }}</span>
|
||||||
|
<span v-if="when" class="text-xs text-base-content/70">{{ when }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.planning-fallback {
|
||||||
|
border-color: var(--slot-color);
|
||||||
|
background: linear-gradient(160deg,
|
||||||
|
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
|
||||||
|
var(--color-base-200));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useTemplateRef } from "vue";
|
||||||
|
import { useElementBounding, useElementSize, useWindowSize } from "@vueuse/core";
|
||||||
|
import type { PlanningSlotOut } from "../api/types.gen";
|
||||||
|
import PlanningCover from "./PlanningCover.vue";
|
||||||
|
|
||||||
|
defineProps<{ entry: PlanningSlotOut }>();
|
||||||
|
|
||||||
|
const track = useTemplateRef<HTMLElement>("track");
|
||||||
|
const stack = useTemplateRef<HTMLElement>("stack");
|
||||||
|
|
||||||
|
const { top: trackTop, height: trackHeight } = useElementBounding(track);
|
||||||
|
const { height: stackHeight } = useElementSize(stack);
|
||||||
|
const { height: windowHeight } = useWindowSize();
|
||||||
|
|
||||||
|
// The games follow the scroll: kept in the middle of the screen, but never
|
||||||
|
// leaving the slot (stuck to its top/bottom when it's only partly visible)
|
||||||
|
const offset = computed(() => {
|
||||||
|
const wanted = windowHeight.value / 2 - stackHeight.value / 2 - trackTop.value;
|
||||||
|
const max = Math.max(0, trackHeight.value - stackHeight.value);
|
||||||
|
return Math.min(max, Math.max(0, wanted));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="planning-multi h-full rounded-md p-[2px]" :style="{ '--slot-color': entry.color }">
|
||||||
|
<div class="planning-multi-inner relative h-full rounded-[5px] p-2 flex flex-col overflow-hidden">
|
||||||
|
<div class="planning-multi-title font-black uppercase text-sm tracking-widest mb-2">
|
||||||
|
<i class="mdi mdi-star-four-points mr-1"></i>{{ entry.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="track" class="relative flex-1">
|
||||||
|
<div ref="stack" class="absolute inset-x-0 flex flex-col gap-3"
|
||||||
|
:style="{ transform: `translateY(${offset}px)` }">
|
||||||
|
<component :is="game.store_url ? 'a' : 'div'" v-for="game in entry.games" :key="game.name"
|
||||||
|
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="block aspect-[2/3] rounded overflow-hidden shadow-lg bg-base-200
|
||||||
|
transition-transform hover:scale-[1.05]">
|
||||||
|
<PlanningCover :game="game" :color="entry.color" />
|
||||||
|
</component>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Light sweeping down the whole slot -->
|
||||||
|
<div class="planning-multi-sheen pointer-events-none absolute inset-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@property --planning-angle {
|
||||||
|
syntax: "<angle>";
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotating gradient border + glow in the slot color */
|
||||||
|
.planning-multi {
|
||||||
|
background: conic-gradient(from var(--planning-angle),
|
||||||
|
var(--slot-color), #ffd76a, var(--slot-color), #ffffff, var(--slot-color));
|
||||||
|
box-shadow: 0 0 24px -4px var(--slot-color);
|
||||||
|
animation: planning-spin 6s linear infinite, planning-glow 3s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-multi-inner {
|
||||||
|
background: linear-gradient(180deg,
|
||||||
|
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
|
||||||
|
var(--color-base-200) 40%,
|
||||||
|
color-mix(in srgb, var(--slot-color) 20%, var(--color-base-200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-multi-title {
|
||||||
|
background: linear-gradient(90deg, #fff 20%, #ffd76a 40%, #fff 60%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: planning-shimmer 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-multi-sheen {
|
||||||
|
background: linear-gradient(180deg, transparent 0%, rgb(255 255 255 / 0.12) 50%, transparent 100%);
|
||||||
|
background-size: 100% 600px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
animation: planning-sheen 7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes planning-spin {
|
||||||
|
to {
|
||||||
|
--planning-angle: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes planning-glow {
|
||||||
|
to {
|
||||||
|
box-shadow: 0 0 40px 0 var(--slot-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes planning-shimmer {
|
||||||
|
from {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes planning-sheen {
|
||||||
|
from {
|
||||||
|
background-position: 0 -600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
background-position: 0 calc(100% + 600px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
||||||
|
.planning-multi,
|
||||||
|
.planning-multi-title,
|
||||||
|
.planning-multi-sheen {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import RankBadge from "./RankBadge.vue";
|
import RankBadge from "./RankBadge.vue";
|
||||||
import { animationsApiResults, polylanSubmitterApiClearCache } from "@/api";
|
import {
|
||||||
|
animationsApiResults,
|
||||||
|
opusMagnumApiListResponseFiles,
|
||||||
|
polylanSubmitterApiClearCache,
|
||||||
|
} from "@/api";
|
||||||
|
import type { SubmissionFileOut } from "@/api/types.gen";
|
||||||
import { queryClient, userQuery } from "@/queries";
|
import { queryClient, userQuery } from "@/queries";
|
||||||
import { useEventPath } from "@/composables/useEventPath";
|
import { useEventPath } from "@/composables/useEventPath";
|
||||||
|
|
||||||
@@ -61,6 +66,32 @@ const isLoading = ref(true);
|
|||||||
const resultsData = ref<ResultsData | null>(null);
|
const resultsData = ref<ResultsData | null>(null);
|
||||||
const selectedTab = ref<"overall" | "byPuzzle">("overall");
|
const selectedTab = ref<"overall" | "byPuzzle">("overall");
|
||||||
const expandedPuzzleId = ref<number | null>(null);
|
const expandedPuzzleId = ref<number | null>(null);
|
||||||
|
// Solution viewer (staff only)
|
||||||
|
const solutionModal = ref<{ username: string; puzzleTitle: string } | null>(null);
|
||||||
|
const solutionFiles = ref<SubmissionFileOut[]>([]);
|
||||||
|
const isLoadingSolution = ref(false);
|
||||||
|
|
||||||
|
const openSolution = async (responseId: number, username: string, puzzleTitle: string) => {
|
||||||
|
solutionModal.value = { username, puzzleTitle };
|
||||||
|
solutionFiles.value = [];
|
||||||
|
isLoadingSolution.value = true;
|
||||||
|
try {
|
||||||
|
const response = await opusMagnumApiListResponseFiles({
|
||||||
|
path: { event_path: eventPath, response_id: responseId },
|
||||||
|
});
|
||||||
|
solutionFiles.value = response.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching solution files:", error);
|
||||||
|
} finally {
|
||||||
|
isLoadingSolution.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSolution = () => {
|
||||||
|
solutionModal.value = null;
|
||||||
|
solutionFiles.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
const userInfo = ref({
|
const userInfo = ref({
|
||||||
username: "Player",
|
username: "Player",
|
||||||
rank: null as number | null,
|
rank: null as number | null,
|
||||||
@@ -108,6 +139,7 @@ const getPuzzleRanking = (puzzleId: number) => {
|
|||||||
return ranking.map((response) => {
|
return ranking.map((response) => {
|
||||||
const user = resultsData.value!.users.find((u) => u.id === response.user_id) as User;
|
const user = resultsData.value!.users.find((u) => u.id === response.user_id) as User;
|
||||||
return {
|
return {
|
||||||
|
id: response.id,
|
||||||
user: user,
|
user: user,
|
||||||
cost: response.final_cost,
|
cost: response.final_cost,
|
||||||
cycles: response.final_cycles,
|
cycles: response.final_cycles,
|
||||||
@@ -408,6 +440,7 @@ onMounted(() => {
|
|||||||
</th>
|
</th>
|
||||||
<th class="text-center">{{ t("components.results.totalWithCoef") }}</th>
|
<th class="text-center">{{ t("components.results.totalWithCoef") }}</th>
|
||||||
<th class="text-right">{{ t("common.points") }}</th>
|
<th class="text-right">{{ t("common.points") }}</th>
|
||||||
|
<th v-if="userInfo.isStaff" class="w-12"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -437,6 +470,12 @@ onMounted(() => {
|
|||||||
<span v-else class="text-base-content/40">—</span>
|
<span v-else class="text-base-content/40">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right font-bold text-primary text-lg">{{ response.rank_points }}</td>
|
<td class="text-right font-bold text-primary text-lg">{{ response.rank_points }}</td>
|
||||||
|
<td v-if="userInfo.isStaff">
|
||||||
|
<button @click="openSolution(response.id, response.user.username, puzzle.title)"
|
||||||
|
class="btn btn-ghost btn-xs" :title="t('components.results.viewSolution')">
|
||||||
|
<i class="mdi mdi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -450,5 +489,50 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Solution Modal (staff only) -->
|
||||||
|
<div v-if="solutionModal" class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-7xl w-full">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-bold text-lg">
|
||||||
|
{{ t("components.results.solutionTitle", solutionModal) }}
|
||||||
|
</h3>
|
||||||
|
<button @click="closeSolution" class="btn btn-sm btn-circle btn-ghost">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoadingSolution" class="flex justify-center py-8">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="solutionFiles.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-base-content/70">{{ t("components.results.noSolutionFiles") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div v-for="file in solutionFiles" :key="file.id" class="bg-base-200 rounded-lg p-4">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<video v-if="file.content_type.startsWith('video/')" :src="file.file_url ?? undefined" controls
|
||||||
|
autoplay loop class="max-h-[70vh] object-contain"></video>
|
||||||
|
<img v-else :src="file.file_url ?? undefined" :alt="file.original_filename"
|
||||||
|
class="max-h-[70vh] object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center mt-2 text-sm">
|
||||||
|
<span class="text-base-content/70">{{ file.original_filename }}</span>
|
||||||
|
<a v-if="file.file_url" :href="file.file_url" target="_blank" class="btn btn-primary btn-xs">
|
||||||
|
<i class="mdi mdi-download"></i>
|
||||||
|
{{ t("common.download") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action mt-4">
|
||||||
|
<button @click="closeSolution" class="btn btn-sm">{{ t("common.close") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" @click="closeSolution"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import type { SteamGameOut } from "../api/types.gen";
|
||||||
|
|
||||||
|
defineProps<{ game: SteamGameOut }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Not every game has a library capsule on Steam, fall back to the header + name
|
||||||
|
const capsuleFailed = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="group relative aspect-[2/3] rounded-md overflow-hidden bg-base-200 hover:bg-base-100
|
||||||
|
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05]">
|
||||||
|
<a :href="game.store_url" target="_blank" rel="noopener noreferrer" :title="game.name" class="block w-full h-full">
|
||||||
|
<img v-if="!capsuleFailed" :src="game.library_image" :alt="game.name" loading="lazy"
|
||||||
|
class="w-full h-full object-cover" @error="capsuleFailed = true" />
|
||||||
|
<div v-else class="w-full h-full flex flex-col justify-center gap-4 p-3 bg-gradient-to-b from-base-100 to-base-300">
|
||||||
|
<img :src="game.header_image" :alt="game.name" loading="lazy" class="w-full rounded-sm" />
|
||||||
|
<span class="text-center font-medium line-clamp-3">{{ game.name }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
|
||||||
|
<a :href="`steam://run/${game.steam_app_id}`" :title="t('games.play')"
|
||||||
|
class="steam-play absolute bottom-2 right-2 rounded-full w-10 h-10 flex items-center justify-center text-2xl
|
||||||
|
opacity-0 translate-y-1 transition-all group-hover:opacity-100 group-hover:translate-y-0 focus:opacity-100">
|
||||||
|
<i class="mdi mdi-play"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.steam-play {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steam-play:hover {
|
||||||
|
background: linear-gradient(to right, #6fceff 5%, #2d5fd8 95%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
"subtitle": "Choose a game and submit your best solutions",
|
"subtitle": "Choose a game and submit your best solutions",
|
||||||
"submitResults": "Submit results",
|
"submitResults": "Submit results",
|
||||||
"getTheGames": "Get the games",
|
"getTheGames": "Get the games",
|
||||||
|
"planning": "Planning",
|
||||||
"footer": "Select a game above to begin submitting"
|
"footer": "Select a game above to begin submitting"
|
||||||
},
|
},
|
||||||
"games": {
|
"games": {
|
||||||
@@ -76,7 +77,13 @@
|
|||||||
"buy": "Buy {game}",
|
"buy": "Buy {game}",
|
||||||
"free": "Free to Play",
|
"free": "Free to Play",
|
||||||
"viewOnSteam": "View on Steam",
|
"viewOnSteam": "View on Steam",
|
||||||
"play": "Play"
|
"play": "Play",
|
||||||
|
"showPrices": "Show prices"
|
||||||
|
},
|
||||||
|
"planning": {
|
||||||
|
"header": "Planning",
|
||||||
|
"title": "Planning",
|
||||||
|
"empty": "No planning for this event yet"
|
||||||
},
|
},
|
||||||
"clock": {
|
"clock": {
|
||||||
"pageTitle": "Beat the Clock",
|
"pageTitle": "Beat the Clock",
|
||||||
@@ -90,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",
|
||||||
@@ -470,7 +479,10 @@
|
|||||||
"second": "🥈 2nd Place",
|
"second": "🥈 2nd Place",
|
||||||
"third": "🥉 3rd Place"
|
"third": "🥉 3rd Place"
|
||||||
},
|
},
|
||||||
"totalWithCoef": "Total (with coef.)"
|
"totalWithCoef": "Total (with coef.)",
|
||||||
|
"viewSolution": "View solution",
|
||||||
|
"solutionTitle": "{username} — {puzzleTitle}",
|
||||||
|
"noSolutionFiles": "No file attached to this solution"
|
||||||
},
|
},
|
||||||
"topUsersLeaderboard": {
|
"topUsersLeaderboard": {
|
||||||
"title": "Top 10 Users"
|
"title": "Top 10 Users"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { persistQueryClient } from "@tanstack/query-persist-client-core";
|
|||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import {
|
import {
|
||||||
gamesApiListEventGamesOptions,
|
gamesApiListEventGamesOptions,
|
||||||
|
gamesApiListEventPlanningOptions,
|
||||||
polylanSubmitterApiGetUserInfoOptions,
|
polylanSubmitterApiGetUserInfoOptions,
|
||||||
polylanSubmitterApiGetUserInfoQueryKey,
|
polylanSubmitterApiGetUserInfoQueryKey,
|
||||||
} from "@/api/@tanstack/vue-query.gen";
|
} from "@/api/@tanstack/vue-query.gen";
|
||||||
@@ -11,11 +12,11 @@ import {
|
|||||||
// fetchQuery() through the same cache.
|
// fetchQuery() through the same cache.
|
||||||
export const queryClient = new QueryClient();
|
export const queryClient = new QueryClient();
|
||||||
|
|
||||||
// Login/logout are full-page redirects, so the user can't change within a
|
// Login/logout are full-page redirects, but roles/scopes can still change
|
||||||
// page load - only refetched once after a reload (see restoreQueryCache).
|
// server-side, so don't keep the user forever.
|
||||||
export const userQuery = () => ({
|
export const userQuery = () => ({
|
||||||
...polylanSubmitterApiGetUserInfoOptions(),
|
...polylanSubmitterApiGetUserInfoOptions(),
|
||||||
staleTime: Infinity,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Games only change when staff enable/disable one, a few minutes of
|
// Games only change when staff enable/disable one, a few minutes of
|
||||||
@@ -25,6 +26,12 @@ export const eventGamesQuery = (eventPath: string) => ({
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Imported by staff a handful of times per event.
|
||||||
|
export const eventPlanningQuery = (eventPath: string) => ({
|
||||||
|
...gamesApiListEventPlanningOptions({ path: { event_path: eventPath } }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]);
|
const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]);
|
||||||
|
|
||||||
// Restores user + games from localStorage before the app mounts, so pages
|
// Restores user + games from localStorage before the app mounts, so pages
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Home from "@/Home.vue";
|
|||||||
import Market from "@/Market.vue";
|
import Market from "@/Market.vue";
|
||||||
import Noita from "@/Noita.vue";
|
import Noita from "@/Noita.vue";
|
||||||
import OpusMagnum from "@/OpusMagnum.vue";
|
import OpusMagnum from "@/OpusMagnum.vue";
|
||||||
|
import Planning from "@/Planning.vue";
|
||||||
import Place from "@/Place.vue";
|
import Place from "@/Place.vue";
|
||||||
import PlaceEmbed from "@/PlaceEmbed.vue";
|
import PlaceEmbed from "@/PlaceEmbed.vue";
|
||||||
import Submissions from "@/Submissions.vue";
|
import Submissions from "@/Submissions.vue";
|
||||||
@@ -45,6 +46,13 @@ export const router = createRouter({
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { titleKey: "games.header", getBackRoute: eventHome },
|
meta: { titleKey: "games.header", getBackRoute: eventHome },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/:eventPath/planning",
|
||||||
|
name: "planning",
|
||||||
|
component: Planning,
|
||||||
|
props: true,
|
||||||
|
meta: { titleKey: "planning.header", getBackRoute: eventHome },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/:eventPath/market",
|
path: "/:eventPath/market",
|
||||||
name: "market",
|
name: "market",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
# Before the submissions -> opus_magnum rename, this app owned the opus magnum
|
||||||
|
# tables and its 0001_initial was already applied. The new 0001_initial has the
|
||||||
|
# same name, so on those databases Django skipped it and kept the legacy
|
||||||
|
# submissions_submission / submissions_submissionfile layout (is_validated NOT
|
||||||
|
# NULL, response_id, ...) and never created submissions_imagecrop.
|
||||||
|
LEGACY_TABLES = [
|
||||||
|
"submissions_imagecrop",
|
||||||
|
"submissions_submissionfile",
|
||||||
|
"submissions_puzzleresponse",
|
||||||
|
"submissions_submission",
|
||||||
|
"submissions_steamcollectionitem",
|
||||||
|
"submissions_steamcollection",
|
||||||
|
"submissions_steamapikey",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def repair_legacy_tables(apps, schema_editor):
|
||||||
|
connection = schema_editor.connection
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
columns = {
|
||||||
|
col.name
|
||||||
|
for col in connection.introspection.get_table_description(
|
||||||
|
cursor, "submissions_submission"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if "is_validated" not in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Data was copied to opus_magnum (0015) and 0007 wiped submissions, so
|
||||||
|
# this should be empty: refuse to drop anything otherwise.
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM submissions_submission")
|
||||||
|
if cursor.fetchone()[0]:
|
||||||
|
raise RuntimeError(
|
||||||
|
"submissions_submission has the legacy layout but is not empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_tables = set(connection.introspection.table_names())
|
||||||
|
for table in LEGACY_TABLES:
|
||||||
|
if table in existing_tables:
|
||||||
|
schema_editor.execute(f"DROP TABLE {schema_editor.quote_name(table)}")
|
||||||
|
|
||||||
|
for model_name in ["Submission", "SubmissionFile", "ImageCrop"]:
|
||||||
|
schema_editor.create_model(apps.get_model("submissions", model_name))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("submissions", "0008_remove_submission_game"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(repair_legacy_tables, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,168 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Games Planning Preview</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #1d232a; --surface: #2a323c; --text: #a6adbb; --muted: #6b7280; --line: #ffffff10;
|
||||||
|
--hour: 104px; --col: 150px; --gap: 10px; --break: 34px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||||
|
.page { max-width: 1280px; margin: 0 auto; padding: 48px 16px; }
|
||||||
|
h1 { text-align: center; font-size: 3rem; margin: 0 0 16px; color: #e5e7eb; }
|
||||||
|
.sub { text-align: center; font-size: 1.25rem; margin: 0 0 32px; }
|
||||||
|
.toolbar { display: flex; justify-content: flex-end; gap: 8px; align-items: center; margin-bottom: 24px; }
|
||||||
|
.toggle { width: 44px; height: 24px; border-radius: 999px; border: 2px solid var(--muted); position: relative; }
|
||||||
|
.toggle::after { content: ""; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.scroller { overflow-x: auto; }
|
||||||
|
.planning { display: grid; gap: var(--gap); width: max-content; margin: 0 auto; }
|
||||||
|
.axis, .column { position: relative; }
|
||||||
|
.axis { width: 56px; }
|
||||||
|
.tick { position: absolute; right: 8px; font-size: 12px; color: var(--muted); transform: translateY(-50%); }
|
||||||
|
.day { position: absolute; left: 0; right: calc(-1 * (var(--col) + var(--gap)) * 6); border-top: 1px solid #ffffff30;
|
||||||
|
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #e5e7eb; padding-top: 2px; }
|
||||||
|
.hourline { position: absolute; left: 56px; right: 0; border-top: 1px dashed var(--line); }
|
||||||
|
.break { position: absolute; left: 0; right: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; color: var(--muted); background: repeating-linear-gradient(45deg, #ffffff06 0 6px, transparent 6px 12px); border-radius: 4px; }
|
||||||
|
.column { width: var(--col); }
|
||||||
|
.card { position: absolute; left: 0; right: 0; border-radius: 6px; overflow: hidden; background: var(--surface);
|
||||||
|
box-shadow: 0 20px 25px -5px #0006; transition: all .15s; cursor: pointer; }
|
||||||
|
.card:hover { transform: translateY(-8px) scale(1.05); box-shadow: 0 25px 50px -12px #000a; z-index: 5; }
|
||||||
|
.card img.cover { width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; }
|
||||||
|
.card .time { position: absolute; top: 6px; left: 6px; font-size: 11px; padding: 1px 6px; border-radius: 4px;
|
||||||
|
background: #000a; color: #fff; opacity: 0; transition: opacity .15s; }
|
||||||
|
.card:hover .time { opacity: 1; }
|
||||||
|
.fallback { height: 100%; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 10px;
|
||||||
|
border-left: 4px solid var(--c); background: linear-gradient(160deg, color-mix(in srgb, var(--c) 35%, var(--surface)), var(--surface)); }
|
||||||
|
.fallback .title { color: #f3f4f6; font-weight: 600; line-height: 1.2; }
|
||||||
|
.fallback .when { font-size: 12px; color: #d1d5db; }
|
||||||
|
/* multi-anims: one long card, its games stacked & sticky while you scroll */
|
||||||
|
.multi { background: linear-gradient(180deg, color-mix(in srgb, var(--c) 30%, var(--surface)), var(--surface)); padding: 8px; overflow: visible; }
|
||||||
|
.multi:hover { transform: none; }
|
||||||
|
.multi .head { color: #f3f4f6; font-weight: 700; text-transform: uppercase; font-size: 13px; margin-bottom: 8px; letter-spacing: .05em; }
|
||||||
|
.multi .sub-card { position: sticky; top: 8px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; margin-bottom: 12px;
|
||||||
|
box-shadow: 0 10px 15px -3px #0008; background: var(--surface); }
|
||||||
|
.multi .sub-card .fallback { border-left: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<h1>Get the games</h1>
|
||||||
|
<p class="sub">Don't own them yet? Grab them on Steam</p>
|
||||||
|
<div class="toolbar"><span>Show prices</span><span class="toggle"></span></div>
|
||||||
|
<div class="scroller"><div class="planning" id="planning"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script>
|
||||||
|
// Preview only: in the app this comes from Game.name matching + library_image
|
||||||
|
const STEAM = {
|
||||||
|
"factorio": 427520, "fall guys": 1097150, "lethal company": 1966720, "rocket league": 252950,
|
||||||
|
"r.e.p.o.": 3241660, "tricky towers": 437920, "rounds": 1557740, "cosmoteer": 799600,
|
||||||
|
"golf it!": 571740, "towerfall: ascension": 251470, "puyo puyo — tetris": 546050,
|
||||||
|
"peak": 3527290, "straftat": 2386720, "trackmania": 2225070, "slay the spire 2": 2868840,
|
||||||
|
};
|
||||||
|
const coverOf = (name) => {
|
||||||
|
const id = STEAM[name.toLowerCase()];
|
||||||
|
return id && `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/library_600x900.jpg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOUR = 3600e3;
|
||||||
|
const css = getComputedStyle(document.documentElement);
|
||||||
|
const HOUR_PX = parseFloat(css.getPropertyValue("--hour"));
|
||||||
|
const BREAK_PX = parseFloat(css.getPropertyValue("--break"));
|
||||||
|
const TZ = "Europe/Zurich";
|
||||||
|
const fmtTime = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, hour: "2-digit", minute: "2-digit" });
|
||||||
|
const fmtDay = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "long", day: "numeric", month: "short" });
|
||||||
|
|
||||||
|
const slots = PLANNING.list.map((s) => ({ ...s, b: Date.parse(s.begin), e: Date.parse(s.end) }));
|
||||||
|
const singles = slots.filter((s) => !s.is_multi_anims);
|
||||||
|
|
||||||
|
// Hours with something going on; long empty runs (nights) get squashed into a thin break
|
||||||
|
const start = Math.floor(Math.min(...slots.map((s) => s.b)) / HOUR) * HOUR;
|
||||||
|
const end = Math.ceil(Math.max(...slots.map((s) => s.e)) / HOUR) * HOUR;
|
||||||
|
const busy = (t) => singles.some((s) => s.b < t + HOUR && s.e > t);
|
||||||
|
const segments = []; // {from, to, y, h, kind}
|
||||||
|
let y = 0;
|
||||||
|
for (let t = start; t < end;) {
|
||||||
|
if (busy(t)) { segments.push({ from: t, to: t + HOUR, y, h: HOUR_PX, kind: "hour" }); y += HOUR_PX; t += HOUR; continue; }
|
||||||
|
let u = t; while (u < end && !busy(u)) u += HOUR;
|
||||||
|
const short = (u - t) / HOUR < 3;
|
||||||
|
const h = short ? HOUR_PX * (u - t) / HOUR : BREAK_PX;
|
||||||
|
segments.push({ from: t, to: u, y, h, kind: short ? "idle" : "break" }); y += h; t = u;
|
||||||
|
}
|
||||||
|
const total = y;
|
||||||
|
const yOf = (t) => {
|
||||||
|
const s = segments.find((s) => t >= s.from && t <= s.to) || segments.at(-1);
|
||||||
|
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cols = [...new Set(slots.map((s) => s.colonneS))].sort((a, b) => a - b); // drop unused columns
|
||||||
|
const root = document.getElementById("planning");
|
||||||
|
root.style.gridTemplateColumns = `56px repeat(${cols.length}, var(--col))`;
|
||||||
|
|
||||||
|
// Time axis + day headers + hour lines
|
||||||
|
const axis = document.createElement("div");
|
||||||
|
axis.className = "axis"; axis.style.height = total + "px";
|
||||||
|
root.appendChild(axis);
|
||||||
|
let lastDay = null;
|
||||||
|
for (const s of segments) {
|
||||||
|
if (s.kind === "break") {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "break"; el.style.top = s.y + "px"; el.style.height = s.h + "px";
|
||||||
|
el.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
|
||||||
|
el.textContent = `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}`;
|
||||||
|
axis.appendChild(el);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let t = s.from; t < s.to; t += HOUR) {
|
||||||
|
const ty = yOf(t), day = fmtDay.format(t);
|
||||||
|
if (day !== lastDay) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.className = "day"; d.style.top = ty + "px"; d.textContent = day;
|
||||||
|
d.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
|
||||||
|
axis.appendChild(d); lastDay = day;
|
||||||
|
} else {
|
||||||
|
const tick = document.createElement("div");
|
||||||
|
tick.className = "tick"; tick.style.top = ty + "px"; tick.textContent = fmtTime.format(t);
|
||||||
|
axis.appendChild(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = (title, when, color) =>
|
||||||
|
`<div class="fallback" style="--c:${color}"><div class="title">${title}</div>${when ? `<div class="when">${when}</div>` : ""}</div>`;
|
||||||
|
const coverOrFallback = (name, when, color) => {
|
||||||
|
const src = coverOf(name);
|
||||||
|
if (!src) return fallback(name, when, color);
|
||||||
|
// Not every game has a library capsule, swap to the colored card if it 404s
|
||||||
|
return `<img class="cover" src="${src}" alt="${name}" title="${name}"
|
||||||
|
onerror="this.outerHTML = decodeURIComponent('${encodeURIComponent(fallback(name, when, color))}')">`;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const c of cols) {
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.className = "column"; col.style.height = total + "px";
|
||||||
|
for (const s of slots.filter((s) => s.colonneS === c)) {
|
||||||
|
const top = yOf(s.b) + 3, height = yOf(s.e) - yOf(s.b) - 6;
|
||||||
|
const when = `${fmtTime.format(s.b)} – ${fmtTime.format(s.e)}`;
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.style.top = top + "px"; card.style.height = height + "px";
|
||||||
|
if (s.is_multi_anims) {
|
||||||
|
card.className = "card multi"; card.style.setProperty("--c", s.color_code);
|
||||||
|
card.innerHTML = `<div class="head">${s.title}</div>` +
|
||||||
|
s.aandts.map((a) => `<div class="sub-card">${coverOrFallback(a.name, "", s.color_code)}</div>`).join("");
|
||||||
|
} else {
|
||||||
|
card.className = "card";
|
||||||
|
card.innerHTML = coverOrFallback(s.title, when, s.color_code) + `<span class="time">${when}</span>`;
|
||||||
|
}
|
||||||
|
col.appendChild(card);
|
||||||
|
}
|
||||||
|
root.appendChild(col);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -30,6 +30,8 @@ dependencies = [
|
|||||||
"python-dotenv>=1.2.3",
|
"python-dotenv>=1.2.3",
|
||||||
"django-command-palette>=2022.11.25",
|
"django-command-palette>=2022.11.25",
|
||||||
"django-admin-interface>=0.32.0",
|
"django-admin-interface>=0.32.0",
|
||||||
|
"uvicorn[standard]>=0.54.0",
|
||||||
|
"uvicorn-worker>=0.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -1468,6 +1468,118 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/6e/bd4f866032541728bdfaa33277856c68e608343205d8772027988ab00bab/httptools-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eacf0f45ca3ff84c01481c60c15da9ee56711f7292f66663df0f57af61e011c2", size = 119786, upload-time = "2026-10-09T19:53:47.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/f1/4a98e04117cf5d74a2079d759a68c1f29dc734636c05e34af7605e039191/httptools-0.9.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:f0ef48ce353f6b6a52232ba23d0983d4c2c84c84a778899404e34b4718509bf2", size = 115361, upload-time = "2026-10-09T19:53:49.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/16/b0400f48db7a4d4cdfa9301331d55cfd1c872c7f7b593d8be0897a517e55/httptools-0.9.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4a85401b0c3f893cf5695c1199e8679fbf673f7f78c2f6c11d6b1850f8c7e358", size = 487031, upload-time = "2026-10-09T19:53:50.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/68/a07f16edaecc4830078b4f839f5bbda3be8da961bf79f8e28aa6eb69191e/httptools-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecf7037e491c220cd73987838c1ac3958d787bb098c3be0bfaf7f04204a6162c", size = 486897, upload-time = "2026-10-09T19:53:52.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/47/06aff715edb56e0439817e52db2b50a39a8a32b6ff9d91412406ea26cf4c/httptools-0.9.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:563e4568217dc907a91843f38c737be865222c0400a38cdcd0d26ce92b3db271", size = 512626, upload-time = "2026-10-09T19:53:54.528Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/0e/6c199ce5c8f4682dd541573194adfe12935ca5ffbfd7845dd01ffbbdd283/httptools-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cbbfcd5d15056fbd1edd5e725cf3feeb47c7cbccbe205927ebab422cc229f417", size = 449273, upload-time = "2026-10-09T19:53:56.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/23/6e7cd2490b88d5567ac17fcc0e5aa2b06d4f6e2183cccbbee1b222adb0ce/httptools-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5332a020a60bbe32ede4bda1a62b3d56c4831d309cdf0932842c0fca8ad6aaa3", size = 470973, upload-time = "2026-10-09T19:53:58.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/53/a7945c1b4b4d24b3249d47ec28817ff8e313f8206c8abb6ce4e391f21d88/httptools-0.9.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:48c705bd0b1afb6253ed71eca9f9ba7ac7d47838e5fed1ef7891d67f21ecd4de", size = 495861, upload-time = "2026-10-09T19:54:00.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/f1/55b6709cc242e40aefc48eae6e9c7c908848c017c98d2661b4b33e335077/httptools-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ead1a40543a033a6732a9e1e515944979a19db3737ce77363fc0660e38554344", size = 444182, upload-time = "2026-10-09T19:54:02.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/9b/046a3dbe803a631603eea9d64b0c0fa2285553975c18c4f158b15b27e02d/httptools-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:310266a2db1377ffae3bdf6556ab4973f4f94508a8ce37b2f6bb096a89bcefa1", size = 473538, upload-time = "2026-10-09T19:54:04.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/48/c013bb37d2c21e464db23499e7369565f41299675f745104e0406d3d0ad6/httptools-0.9.0-cp310-cp310-win32.whl", hash = "sha256:ae9bb62a7902e2ab65782447cd3eeb753510feace4e3ea03937a85489b01b16b", size = 86276, upload-time = "2026-10-09T19:54:05.662Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/63/68b4ee7f944191a64360f51af9ba4cd5ed59f751f8825d877c32019582ba/httptools-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:5cc5d3a29f9ec86ce406e5ec09c241dd8dc4d30e838f74f68d728b89131a3acf", size = 92640, upload-time = "2026-10-09T19:54:07.083Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/e8/86b1e6f43d13accfe72ac95aa72840b584d9c48012cdc52cfc24daa06051/httptools-0.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:cb3e7a4fd0168e362673a980380bf4fd6ae3b1555150e60c5390b4b10d9c50c4", size = 89519, upload-time = "2026-10-09T19:54:08.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/85/1b1e9e6f2f769dc48610f5e71b9a7d50d5a9532985fbd1f1a8b579f62b0e/httptools-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fd73d0bbf700a30dd87e4412adf41cfa71542a533d6b390c7244bbb8a1152bb", size = 119077, upload-time = "2026-10-09T19:54:10.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/30/72d0caf79e54eb1356527c870daac40f8d06f86cd078fdf73c6bf3f7d100/httptools-0.9.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d2b095129b9a98eb46a271ee9631089529c4e40354576b4aa74e24de9d2bf2f7", size = 114529, upload-time = "2026-10-09T19:54:11.35Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/0b/6498fe8218db1ed5f785010c303bfef50516d0988e64988bb8f9f59d70ab/httptools-0.9.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b68fb053b37c258a473ab67f4965c3b439500dc160fe364667035a6833eaf50a", size = 499139, upload-time = "2026-10-09T19:54:13.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/a9/81795025aa1ac0ca5346917571756c3e77ae3d0aa11d70fee11b5f89f713/httptools-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2780e33a58a93f27cc3bb74a55bae6f9a8278a1dbabdff392940d30d381671", size = 498141, upload-time = "2026-10-09T19:54:14.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/e9/f9070a752f6efb42381c0c63fec08385bfbc6d63be15191c424f6d740575/httptools-0.9.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:272db0c51e8b71e953c1f2ecbe63402b819680e4564be2ef285cfd4584ee8355", size = 524457, upload-time = "2026-10-09T19:54:16.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/29/201ca4636ebe7cb2c931d6545ed4be0acd5fb90f7351ea0458b5ab7319ec/httptools-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22ab1b10b06d357f01092e60f5e6856a0d479ed79b0ec2166a339ea26c699be2", size = 455418, upload-time = "2026-10-09T19:54:18.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/97/2cc1ad7a28243e35002dcd9c4dd98074bc47c9a0a72be12de01fff10e2a5/httptools-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8a59c749a73fbdbc8e63b895a3079825fa085d752e75bc0a500042cb8a801e48", size = 483407, upload-time = "2026-10-09T19:54:20.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/98/c7ca6a34d92010561eb5af95bf0d2b667ce4ea3e83ff7fda68da52e037bf/httptools-0.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6ac1414556b910a879c108d79736f77e797871f9919ed0d2c3cf8cf3ecca986", size = 506748, upload-time = "2026-10-09T19:54:21.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/62/6aec88e4d1da59005184f1038f5abfaad6143499fbf4baa46c2fed8e5b59/httptools-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:13873eb8aef5972fcfee614f63d47064312ad4efbfe65ade15b8a3b77f8c8659", size = 450253, upload-time = "2026-10-09T19:54:23.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/06/4be91efa577ccae9a16694a413bb8a7c30cb0ec2dc972627b64e108517b8/httptools-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5042aa1c7e2b1a24c17dab31d8770b63a5101c9abc25f832c6aef6b201e1ca4f", size = 485588, upload-time = "2026-10-09T19:54:25.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/eb/3224a5e3145b784e7344a370a8cc9a0d448035a913ddece6e4c35067315f/httptools-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a4d1ecad62e83cc65b411ea0125972cf3af98821e8117129947fd1e3a113f8d2", size = 85785, upload-time = "2026-10-09T19:54:28.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/41/214e2da998e6348eb68fd0883ffa9774c83ab7a5da12d97595aafb07d0ba/httptools-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c4fa57d3c31889722f64bfa785545a5e603a893b6f29ac1a41bfa830abeaefd5", size = 92421, upload-time = "2026-10-09T19:54:29.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/af/d8fc6b8581045899a780018842a3db0365c4b8ca46519a528e9b8bc6095e/httptools-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:ecfeee649184ffd800955068be9a6b579a0f33fc3c98535d685d5779cb59347f", size = 89093, upload-time = "2026-10-09T19:54:31.269Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx"
|
name = "httpx"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -2392,6 +2504,8 @@ dependencies = [
|
|||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sentry-sdk", extra = ["django"] },
|
{ name = "sentry-sdk", extra = ["django"] },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "uvicorn-worker" },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2439,6 +2553,8 @@ requires-dist = [
|
|||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sentry-sdk", extras = ["django"] },
|
{ name = "sentry-sdk", extras = ["django"] },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
|
||||||
|
{ name = "uvicorn-worker", specifier = ">=0.4.0" },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3519,6 +3635,100 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "websockets", version = "17.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn-worker"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/aa/a67389d92dc118bb6b48cb57b08bf6f24925a07e05de196e4b998c339017/uvloop-0.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce17bc317d089f361b33521654c13e30eacfd3d2034fd34e613ca9c51c969686", size = 1420655, upload-time = "2026-10-01T03:15:21.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/70/749d8bad691e6036f83d7c7e3cb34306261e01de847ce4ce46eb7aec5240/uvloop-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53c2c5d7e2024e46776c2d90e6c637d01102126b61aaf5faa5edaf05f8b5722a", size = 780765, upload-time = "2026-10-01T03:15:22.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/44/a4b7bea44d55c882e23fc858eebed9e157486650cdbecdb951577e89362f/uvloop-0.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42feced24b9b44b856c633eafb5cc5dec354972da55ce77598db6844c054bc7c", size = 3795900, upload-time = "2026-10-01T03:15:25.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/4a/488d9ee6eb87899273d84ebeaf7023c551ff8f8d44f7e7c0f78d06b6da25/uvloop-0.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bf08e4b6362dd1c08623bbfa2d061e8bac0f1da8fc2007062cfe1dc360a49fa", size = 3850999, upload-time = "2026-10-01T03:15:27.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/51/6146339b0a4e0f880ed1abd98517b21a6021ac0988cbc83c7339d7ee346f/uvloop-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4bb7f5d0b62b5afaaaea2b7b60d508921c24b0fe39c22c1438bec1811ffe10ec", size = 3655020, upload-time = "2026-10-01T03:15:28.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/76/c2576407efee20fdfbf08ad35122ec9b2eb439a9090016e7f025c41259ab/uvloop-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0305871ac712f54b62af73f943dbf21ae3ce80a44bc0f0151424484affa85645", size = 3758530, upload-time = "2026-10-01T03:15:30.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/b1/948067eab45d5307f04b34e50eb7bd1f7352aee866fa5f0706b061ddacf0/uvloop-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24c58ae4a83e93a04c504bcc678125e36a0bfc44af928ad69444880c60f187a5", size = 1415276, upload-time = "2026-10-01T03:15:32.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/6f/ee3ee84c5d27f2f0a47ae8b67a6adeacf9841b193c0e07412a1403586ce2/uvloop-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0efdd55bddbd36bb2fcb842d64c0d5f6407c6958c68088cc25df8c09edc5b5fd", size = 779533, upload-time = "2026-10-01T03:15:34.062Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/0d/b5f69dae3736d96a8753c6ecd32d676ecd212be7ba3252e9c379ad9cc05c/uvloop-0.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fcd721113260ffb5e38bf14a8725b17d431f34209f7d1c7005b667946e630b3", size = 3896377, upload-time = "2026-10-01T03:15:35.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/fd/8cbf6124607863399008ae4b0d2bb50c22ed83526deec28dca08d635eb6d/uvloop-0.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab17b3a8aa754be0de0e397f7b95f13b14e56f077a4c6ae295e3d4afd199b325", size = 3956355, upload-time = "2026-10-01T03:15:37.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/7a/b73007866e7198519067a1f1afc343b4973ae924d2b7afcea67c44320a98/uvloop-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:80cac5cb90ed7b9b72a217a1d6982b15b829cdbd0ee6bc19b93e3a9e47fb0ac9", size = 3755618, upload-time = "2026-10-01T03:15:39.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/28/e50816f1ce38b97b28d62bc4adf7c82c33b7c68fa902e41a39adc8a3d189/uvloop-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93087a845cdfb35753e539354ac9551bdd2ff528c202a98df0ae46e852bcf021", size = 3863192, upload-time = "2026-10-01T03:15:40.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "21.3.3"
|
version = "21.3.3"
|
||||||
@@ -3535,6 +3745,49 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/eb/289fec71eba51d6c3ef3a28c81f7213f4e860084ffae5abc866933c564f0/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:45726c6b5a8d67c8087eb2933c4398948546d04206ef40f629dc4881d65d23d1", size = 396932, upload-time = "2026-09-21T09:08:50.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/cc/a3c3f557992a20a5bb24607f0da44d39cf6d6ba3f8f6ed7cb31f6c73371a/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6398d250994baff581147ccba846223076fe6492ca93c0d1b3588fcf5355eb5c", size = 394852, upload-time = "2026-09-21T09:08:51.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/65/d45763a5e1108a65af85a217af2930ebf1081c73fd8169d3f75e586992bb/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:591de392458bca26f6130fea4e667c06b657e873ad334ea06fad5a79f5086cef", size = 451789, upload-time = "2026-09-21T09:08:53.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/96/165c6e540bf765ca1115062017adf861d0816525f02464ba71313fe7cf92/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f", size = 456919, upload-time = "2026-09-21T09:08:54.372Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wcwidth"
|
name = "wcwidth"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3553,6 +3806,297 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "16.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version < '3.11'",
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "17.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||||
|
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
|
||||||
|
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
|
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
|
||||||
|
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
|
||||||
|
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
|
"python_full_version == '3.11.*' and sys_platform == 'win32'",
|
||||||
|
"python_full_version == '3.11.*' and sys_platform == 'emscripten'",
|
||||||
|
"python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz", hash = "sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329", size = 188878, upload-time = "2026-10-19T16:58:05.813Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/b8/282f114a67886fccde02a77ff296501ad7fea25f4d696b2b1b337cbd27da/websockets-17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45cc1ccb12ced6d242335159e8221733c8696da5ad0b6ceef6debb0f5ce89110", size = 218223, upload-time = "2026-10-19T16:54:25.998Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/ba/8043c303aa60af053e092c1d667165f46d6729217ce2af795a23a3cd700a/websockets-17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46ae05aa1aecd47d4d7de0f05ce2d1d2eb418f8de614964d754cc3b9a409c400", size = 215908, upload-time = "2026-10-19T16:54:27.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/3d/5d0eaa9b29db6c999a2cf749605141796cff21a5e235b2488bd39dcdcac9/websockets-17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43f270a5f21e12db08c73d83849a877c02ab7b7d0fb6e2718b5101d9c810212f", size = 216181, upload-time = "2026-10-19T16:54:29.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/fb/33080a7b2d8427b343056eab32ba78235a4bfbbdc79b5593688c7b7c62ef/websockets-17.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d875224bac1a05a20acade268a7a0e3498e7f8cf890acb7c2b23bacd2bd3659", size = 225246, upload-time = "2026-10-19T16:54:30.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/8c/d4220455cee00adb553c602ba4f1f6a47cd882d86dd9e5124679c0c95c75/websockets-17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ad409b7ea102339041281117cf2dadfe71edbb653dc48037cc55d516fb7f448", size = 225517, upload-time = "2026-10-19T16:54:32.182Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/a3/0ff8e4f0195884ae446844185e81bf7619673d816424b8a58bb5812dd4b8/websockets-17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d43c311f3519fb1057b6114dc24e9060bc3fb6aedf4aa11160484154b66b63", size = 226751, upload-time = "2026-10-19T16:54:33.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/cf/82c88e3fbb6a75a62b2997932664bccfe68256661c69144bd48020160cf1/websockets-17.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9eacb382f400bb5865e7e6d24bbe709a84510291e707f169dd4a24ee4a5ac6a", size = 230063, upload-time = "2026-10-19T16:54:34.855Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/e4/9131d3b73504902a83d10b1e1244319377ca72755351998ac9c408ba6ddb/websockets-17.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f09f145b89ea292cf6c25fd7c8c0d90207fd6a9e54c7828499669996a1e5bdcc", size = 227310, upload-time = "2026-10-19T16:54:36.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/b2/2208506afb45d181b0d6ccf377d9d79985fbcbf871af02ef4f7f3d405f5b/websockets-17.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:49fb5b4960ebe1bc557b435da8687deef1302a08f030a33c5606fa6fb11b773b", size = 226087, upload-time = "2026-10-19T16:54:37.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/53/da73e3809f1d4e8c3346ca89a4bed7995de5708f2cb14c9359172a85f5a5/websockets-17.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:13cffaedde6e06183860fc92d88fe1d190abda73837fe4c14f5d4803c472f5dd", size = 223360, upload-time = "2026-10-19T16:54:38.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/7b/eaf828fd414401672e615570b9f2bfd84342b49c86ffb2ae735e4aada32f/websockets-17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ef2950896673a9809e8e5ab857525924fbcacfc98b858b0509ccf50d341f80f4", size = 226240, upload-time = "2026-10-19T16:54:40.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/bd/4b6aa6e761c8d89aa6defdd83e40a6b2d9aa21bd01520c8e8470fe8d8681/websockets-17.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2d08ca4bf5a9cdb6fd81b86b51bd5cfb10527d3a1199888f2b02cc6c6a3d5f80", size = 224833, upload-time = "2026-10-19T16:54:41.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a8/0a6aea08cad7db2c1aa53dbd3ece3ebc09e420e8301cb02f2b812a728a5b/websockets-17.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e0c0ed37472c4729f3001066510a656486f08f149dda1ebfd3dfe4450c9756e", size = 225599, upload-time = "2026-10-19T16:54:43.54Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/82/3998ea56beb079f43fc39dd5935ef8dc150f915bf2de8bd6ddcd50c0fae6/websockets-17.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62059d739af7fc035ed6e63c5390118a60a5e9aa5a5541cd0486a83e741842c7", size = 226643, upload-time = "2026-10-19T16:54:44.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/a5/6568add8d0a2f37c5780c6de62b5b8ee191ca9d2d0b8ff2123b8277c41b7/websockets-17.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc6f87d03e3691154673aaa8f08991723d6e3394c83ef2875c7a9049eac9595c", size = 224223, upload-time = "2026-10-19T16:54:46.429Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/79/b11bc05b0f64d8b14901443a4b01ea80d039cc68b260250b4cb488070aca/websockets-17.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3d78d93846eddc990551c1c31914d90d71957ca3af468969826f7ed0d7d1d51c", size = 225255, upload-time = "2026-10-19T16:54:47.82Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/f9/9a8902074db7044b64a05feff349e4f44144a961310d3419bab612f00dd1/websockets-17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2a377c7132b1e69be7a50d7434d7f26f69e93c70166ebb6d8f8cabf802ac4506", size = 225494, upload-time = "2026-10-19T16:54:49.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/ba/7b873060ce6764207a24c10e81294739abffe2a90dc67c7f7db5042c596c/websockets-17.3-cp311-cp311-win32.whl", hash = "sha256:29b01520c39ef2f23c88b002f11e6ef4165675f6c92f5f3578fd587d69b5a5fc", size = 218720, upload-time = "2026-10-19T16:54:50.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/11/7469f3f28513ec0d43f10b1c88bf970b0b6f0aa8190c42731954297e824c/websockets-17.3-cp311-cp311-win_amd64.whl", hash = "sha256:d338813a440bf6d2f0df55140d3f3080284410b1ebfe9df50e3847bcdb1a17bb", size = 219017, upload-time = "2026-10-19T16:54:51.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/fd/4724610baabbba94a44c5d1cf1b1a33ff77031e94a51addb0387c7f56b10/websockets-17.3-cp311-cp311-win_arm64.whl", hash = "sha256:6a8248b1ba8861e48e4c7ced3895a1264d2a3b617de72ec94172ce6e64bf16c7", size = 218950, upload-time = "2026-10-19T16:54:53.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/29/902ffe19c0d35603102f31490dea342f083bc77d8f8bcdc7d8b16f5810f9/websockets-17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:41665c7ea01d3ae497a0c61fd22d3424230d18782a5fceb79f04db9ccb2042c9", size = 218250, upload-time = "2026-10-19T16:54:54.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/66/f56a2637b1aac263205b0e3cd7b2ff2e2f8b404b8118c991f4d122fd381f/websockets-17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8f7469408740cf06a79b01605f2ae0b2acc8839c03fcbbf05da49baee12d3302", size = 215927, upload-time = "2026-10-19T16:54:56.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/7d/a27f25451c4cc62141992b51005688481210f3d3e63de3dc66f9e882ba59/websockets-17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d68da281ac208b6236ffa8beef23fe7c7c956d8bf09b421d447232fd94441b35", size = 216196, upload-time = "2026-10-19T16:54:57.538Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/77/40bbc1a5c016cf9f598dea21d1250a93245c995eafaec6401d0e4eb4e647/websockets-17.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078d1d41f45ee34eed5a4f3bbe7c588caecbdd6e12dfca73e8c1b0e803773793", size = 225516, upload-time = "2026-10-19T16:54:59.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/9c/91889c8c62fb7d16cd58e3074473a259303d6229bf0ce496aec87e66f3dc/websockets-17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7f391838e8adcf1f2fe71561d9e5a9f74ea7ffa5052eb2db329aac677383136", size = 225795, upload-time = "2026-10-19T16:55:00.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/29/2e5e2158e9920f806cd45f3cbc81cf5f3eef2b9e5a74b9ef049e2a3578e3/websockets-17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15a165673bb3fb57facbf040edf5c0556d20ccd2e0ccc5f23ebb69fb715fdc1", size = 227034, upload-time = "2026-10-19T16:55:02.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/af/fbab5c4098b26026b877bfc139b1ea0d7c6eaf7a86b84544933b4c445f82/websockets-17.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28d1935c16782a1e3650c9baa79069b016fff739e292e4cb14bbb830d6930491", size = 229863, upload-time = "2026-10-19T16:55:03.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/72/d6e460a991607794072fe3fdfcb3221a0342af61586f5bd7ea40bddcfd21/websockets-17.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c534c9524a2acd0b97b7022074bec3784ac273c683cf9535087e437672ea8531", size = 227669, upload-time = "2026-10-19T16:55:04.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/8f/1c7da1cfb632e141e019cf2e0bbbd4e6dc8c198c8cc29544e4acf1a89f56/websockets-17.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b459d29c8b82a420a98fa0ce8e9cf787ce0062d98cc90604ef9574fb3cc26ff8", size = 226429, upload-time = "2026-10-19T16:55:06.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/63/c48ef8bbfbd982f2cfae1cfcf9e63f5bfbb8468fd37ca700507efc0b0007/websockets-17.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53209dffd91b0aefed9ea630fa6ad5c2944d6210ba3a8a3a8664dc1575289b14", size = 223653, upload-time = "2026-10-19T16:55:07.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/37/7bedaad48ae8e89eaa4f3942c11be0c41de77ce2118a475f3ba05e9b5a0f/websockets-17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9662a9fe457820c60365b3209e03cbd8e4a817195d55de65a80fa680b4bea136", size = 226465, upload-time = "2026-10-19T16:55:08.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/cf/ab9a8a0de4769ca1dce392021b6a922e21b4d3687fe9e1ab4db585a088f8/websockets-17.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5670a762c7ae388bf8aa04199fd436180a4d28318dca9c1eae7b38604676dbfd", size = 225071, upload-time = "2026-10-19T16:55:10.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/96/64d8460cec95f873c61c6d13a09b435915150640525ae56d5871400aec09/websockets-17.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ed59cb4ee258674a6ed9b8515e42079db02cb3be94f133fdd3f200183a40c49d", size = 225841, upload-time = "2026-10-19T16:55:11.519Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/98/2b83211ee3087db4ae66533782664c0e283cf4a08cbb14036018d0803e09/websockets-17.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f78bb2e82fe88912c9d7b25c98e57a9b08655579af89f164fd4c0657a075d62", size = 226945, upload-time = "2026-10-19T16:55:12.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/fe/6b2a37d422d7f4dfc0c92c95a14649d9bd662243ed8b0774523ed2703604/websockets-17.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:71250e010eede33c1d638e2c7a98288520d67f782fd5eceecaeca3129233a09a", size = 224502, upload-time = "2026-10-19T16:55:14.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c5/364015c9311c89ea6085d54c70d654c5d27ba05014d44ef9fd9daa09462b/websockets-17.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:729989759feb81ff6493879a0d60a0bd7d2eee41001bbec0381a080318439382", size = 225483, upload-time = "2026-10-19T16:55:15.899Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/b3/75d0cae8398816ed34a16a78252bfc8651f2f883cc5a568e1198395f15df/websockets-17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90d8c4fa5ba293cf92de351fbc292cdaadb1a1d58d031a1a97ff9b8fb6250ec7", size = 225761, upload-time = "2026-10-19T16:55:17.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/af/9a7c4b2e87f47a2a6a066acdc2157e9b7cb6222a224b95a432f54ae786ce/websockets-17.3-cp312-cp312-win32.whl", hash = "sha256:0824e6ecee838cc28e6481062574c7fde49f9a2cedfacd36f0e91cb4b37fd6fe", size = 218722, upload-time = "2026-10-19T16:55:18.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/e8/e52953a8280a1e51cb9307218443f68c8f4dc4b9952db6021d5c12d03c0f/websockets-17.3-cp312-cp312-win_amd64.whl", hash = "sha256:6644eecd99c4e8cdf72e646e17b391bb70b054f44becde37dcdc48cbec7e1527", size = 219024, upload-time = "2026-10-19T16:55:19.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/a4/005eb7c1f5c1a249c1231a6ed83a54ada0b6b8ffab33a608ca20e3034ea0/websockets-17.3-cp312-cp312-win_arm64.whl", hash = "sha256:4a9133dbaedc6f1af358127275676171d9a1bb3e61f47ee62b2ff9acb78af3d0", size = 218950, upload-time = "2026-10-19T16:55:22.161Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/1d/c1f4a1c45c5ca2c35ae9fd7cac0e491f8a014c9a86933a5da7b04ff825cf/websockets-17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:52a2ca6f401fb49a8d5d9e0e48ade5f58f1bdda33132907b0e4e3b702628fa4e", size = 218243, upload-time = "2026-10-19T16:55:23.851Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/e8/f1b47d0ced4964ae748931a54347fd27b06b62e8ea7837a9de3aee0c5999/websockets-17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80578e91764bfca297ddca322c00a3136d993b829e9839e7fa378a19c1fcc25d", size = 215927, upload-time = "2026-10-19T16:55:25.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/df/ab0ed0445d8d81a133c8fed760f3b4f743a2f563804fdbecb2c32fe05d94/websockets-17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe7a168d805c8a7bf1c2de55b7ab2bc958586bfd88d0e87235a92cac66b590a7", size = 216185, upload-time = "2026-10-19T16:55:26.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/51/b30fd6c5a33dac76a78083be125e3f9804f3d735da494ff03a46834f31f4/websockets-17.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:54fcd24944e6d127d198c28dc363967b7131ff0c97833962c2ef2048018de02d", size = 225574, upload-time = "2026-10-19T16:55:28.097Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/8a/8ede659919c0805d1fcc79c2ed16588d3419f1687c5117ba46f0217750ec/websockets-17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de2c12555bf05546f3ee75733f2c81de70aab7f530e86d0d2162b0ddcb3c39ae", size = 225854, upload-time = "2026-10-19T16:55:29.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/6a/e75d10e3896edc61320062890f71cdc0767dc4a54e2a26a943b703042d13/websockets-17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c95d25b5382ee0e6191ecafdeabefdc1f7bff8dfee29239b9b79e760ef3b04bd", size = 227091, upload-time = "2026-10-19T16:55:31.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/d3/658f1e153566e5edae02812122d9f020c30785d8b53c4bda61b8e976f77f/websockets-17.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8f244dfee05ea29b8064701880865eb1684f3a704a5665bd10b64694444dbbd4", size = 228526, upload-time = "2026-10-19T16:55:32.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/00/002f95a02d721d8c6ec2676c5aee904a823838a9c205fc0b31eedd882a7b/websockets-17.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e99440ef446c7833690c1754e23b0188d440f3cb89812b0764c697a7880cb33", size = 227718, upload-time = "2026-10-19T16:55:34.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/9c/d36ec9499a62ec4e2a4eb570f93c6fcedd80ad5685e0f9d6866e8cfbbb84/websockets-17.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:26838e2d95d7004c52e8c7b6b51676c872093a3b55a497824f899116a15a8a55", size = 226472, upload-time = "2026-10-19T16:55:35.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/5d/3a6d74e409f24ae4e8661a9d74d6645f71b3e91425a527c0e3f2e78bb15b/websockets-17.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:508775efa11d26aec186d10154359f52276e2baf9ca201b52356330883276dfd", size = 223710, upload-time = "2026-10-19T16:55:36.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/90/e08eb5d69b73cf5babf80922a8c964019b48339ce5c694a5d0c0f083c5a8/websockets-17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2789d203babb6ceb2770aadc2394c1f99263ec37fba57a93064f9a8ad899e8ff", size = 226532, upload-time = "2026-10-19T16:55:38.191Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/45/b1916efa1dad5af33beafe5c536810e1c80856b7e913f671c35031c57745/websockets-17.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cfef7f7aa434a1e632e0ebda14b39f4f1d7975e0fce5c77ea7b43156fabf9bd8", size = 225129, upload-time = "2026-10-19T16:55:39.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/c1/d427abb3f11e92f102f70f8e73addb767fc62c614565c0ee3f5657fa3551/websockets-17.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:39f532fa0d9026de4b9315a175998646112776baad54d55348435c11b05d034e", size = 225900, upload-time = "2026-10-19T16:55:41.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/2b/462fab8e4b09eaa4eac2f40fcff8ee30f851aa40fb518efb0177262f6590/websockets-17.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:85d6daa3a7ca404570ae130babbeb347ef9d3faca5300fb13cfa4bb48c3d30d1", size = 227007, upload-time = "2026-10-19T16:55:42.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/36/7404a4376021a0cc7ae9fcbdb1196c0c8aa48df4fd370c185f4f5b677bff/websockets-17.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ce39283ef08b0f28478e920d4d89a1fc169176e568e8a6a821332673145522eb", size = 224565, upload-time = "2026-10-19T16:55:43.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/5f/b8ada5787d11e6edbf58e2d680235fb3b2fb3298991cc310566f4d6c2fbf/websockets-17.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23046098ebf844753cfd211033624a8247520e5bf2194c24cb1a8c222917acff", size = 225516, upload-time = "2026-10-19T16:55:45.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/1c/0533f760237ac6604efd0bf98c18ea4c1381f2cadd8c8d0b05faecb07a80/websockets-17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:724224d4c457207271239114bf43afafc3eca4a1b4661ccdd428d11869a03bbd", size = 225797, upload-time = "2026-10-19T16:55:46.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/b4/0e580e8f96197130ebdb32b727e9c891594c930be30d8a9de9aa0bbb7e2c/websockets-17.3-cp313-cp313-win32.whl", hash = "sha256:5af7070d80d278832cbca9c3c3dd44b30a26dcfa3ae1e0f6f19ff34edc27b689", size = 218712, upload-time = "2026-10-19T16:55:48.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/6c/db5c907d1cbea84444bc759ff63db97fc586988d11a34d5565e8b50080fa/websockets-17.3-cp313-cp313-win_amd64.whl", hash = "sha256:66027e401c70d0cf55021b91b8a7ec2cd5810e20602f945b4fe6ae8b37ed232b", size = 219026, upload-time = "2026-10-19T16:55:50.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/d3/30f04bec5b9cf0c00c98c038494b210ece923a0b05b6df38a162b759e5ed/websockets-17.3-cp313-cp313-win_arm64.whl", hash = "sha256:6b14914e67044724c31a9360c7b89e9d070117c630746eefdc1620d967545acd", size = 218958, upload-time = "2026-10-19T16:55:52.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/ad/f65e365bbf38d61164b0c2f64776fc7714bfa59ac51e492b038a35f4ab1b/websockets-17.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:bc8fd230ef013951900c1fda100eebe4a4b1b407dde2a44186995042dda2c628", size = 218288, upload-time = "2026-10-19T16:55:53.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/1c/33271eba53b4927a1b91d99d838a6d7b888e6f708425262c911450d73696/websockets-17.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cdb8e4b0f6a41b857c74c03116e8ffbef59ef8e533e934b532843178990d01d5", size = 215977, upload-time = "2026-10-19T16:55:55.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/f3/6d982c60f6c7c95e5faed53d39263df273ecfda904c7d6920c37eee58dd1/websockets-17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:018fab26140ce221c3853454a608e4e7004f4f10641cb97819dcd5b917bb2e99", size = 216193, upload-time = "2026-10-19T16:55:56.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/c6/4ff6b8d2a4b32719d4658bbd791ee9d19939a47a69b961c961fd9dc3a64c/websockets-17.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd0f3037d501178a761fd2814370aca547f79e99a200a61a1f6e7b79ec3d78b9", size = 225573, upload-time = "2026-10-19T16:55:58.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/8f/46e4030947f5590cb61832901f44a3edb07ef8d1e27f6c06b42c5f1c4d33/websockets-17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8dbb3862aa649de95660b6337d97101977f160b5b354d07b82fe309874d2c8bc", size = 225927, upload-time = "2026-10-19T16:55:59.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/1b/3db30fdf4fbefa4ac463060d3f87e2d444f960aa1234028468c09b88bffc/websockets-17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af37a1e6b6bd506b597d5fb4cf111fb527d1b2223608a5d441d4738827cfd166", size = 227169, upload-time = "2026-10-19T16:56:01.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/ed/849799cfc0e3e359b75ca1d50605376da52dc9ef0b2a53931e3f5991f96e/websockets-17.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:740bba4223fa5216d4d6c5dcd29e697f37229b8a61b69e1a50527b0ea993ab2c", size = 228542, upload-time = "2026-10-19T16:56:02.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/53/dcc397e20ce792fffd9a8f79b6333275748ee7a23f807e576166a0b16edd/websockets-17.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4170556762c6fd2913d99dfdd8dbb4c927584899df7facb1034627e355263c45", size = 227772, upload-time = "2026-10-19T16:56:03.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/36/e5935782f71e9e22247b2a2e562da05e18d8945c49f2692ca1bdaceec178/websockets-17.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d355b22270be8062360b9df2ee0b10277590789a1059257916fb9e256a0cff80", size = 226518, upload-time = "2026-10-19T16:56:05.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/76/bd5978a1e403fc646230cf35cf7c06b40b3a232d440881b9e89ede229b18/websockets-17.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4aa85b34c21cf97b3470793d0bf0763d32bfd1e67b83067a0a3ba8dc3474dacf", size = 223766, upload-time = "2026-10-19T16:56:06.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/87/c66eb39bd851d5c14d9eb75aa64d03cfcf2ded77927ca067b5917291d19d/websockets-17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0e2e7c2c1a4f25db7b6596738d810c10732e77f852247300a1bc49e364d6dc9", size = 226644, upload-time = "2026-10-19T16:56:08.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/ae/b9a31d5497fa0b73629a097b173a26123da2ed656b562114644bc623414b/websockets-17.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7fa69212ea60ea24e2d8aff23fe102b1f87f1b27a320f92d2e7b3468997a496a", size = 225107, upload-time = "2026-10-19T16:56:09.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/0c/b773a102f84b60b2052b2cfde773d2f3cd3e8eaa93a145f54450460549cf/websockets-17.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:80a6e37e235780f189558ab804f3cbc01c18f5c578995739b49613d544d34ebf", size = 225891, upload-time = "2026-10-19T16:56:10.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/fa/e7e538575dd4912dea9ea4c9ae86519aa1420969d9cb5cf02fd10fd20ba7/websockets-17.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:628efe13630ac3397e7d1278b6be6f22f37a86d638a324334b4cd43c1daf9928", size = 227064, upload-time = "2026-10-19T16:56:12.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/87/bf2b928c156deefd0c50285a48fc985e2ca85d284e7d9981cc5b79b0e696/websockets-17.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:443fa28652682d2a3ec3eca49af1b87cb2523561257683e62e03a98967b22138", size = 224618, upload-time = "2026-10-19T16:56:13.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/9a/d59817ba3535f787c1cf8990a01b3d2896a7d9d8f3b5d55bed22fce595a6/websockets-17.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6705af9d1005127302c04d9f1533ba56e289336c465923ef878430e99295a0b9", size = 225571, upload-time = "2026-10-19T16:56:15.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/96/8210379dac889e16c75099aefffa200dc55dc8cb030010f309a7eb9c75c1/websockets-17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9bde5d82d7ac4859e0cb3a35e148f537a8d4e56aa43f42582b5f263982f095ae", size = 225869, upload-time = "2026-10-19T16:56:16.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/0b/d4741c977fec9e6482beffece572d544d42d230f56fc3ef504f96521063a/websockets-17.3-cp314-cp314-win32.whl", hash = "sha256:be7be57097a65a8fab3d0f0a10d12931deb626a7d8082eb732b1ae41f3a202e3", size = 218232, upload-time = "2026-10-19T16:56:18.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/69/2f9ce899776d0c522f7f44080cad4228a1c14ec13b0125a2efc12ebe41c9/websockets-17.3-cp314-cp314-win_amd64.whl", hash = "sha256:2b06213c8e1ce93b18d0476d7a5e647436c8f23d099888c9065e930e80f4bed5", size = 218582, upload-time = "2026-10-19T16:56:19.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/e9/11b885666d72add674440a3435c059bea325616959cec8be42605948714c/websockets-17.3-cp314-cp314-win_arm64.whl", hash = "sha256:6b7b540bb321f7f0a7b8f5d107f941bb361e3a154c0b594dae0394997b91b743", size = 218465, upload-time = "2026-10-19T16:56:21.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/0e/0c0f40ab8eefe9d5a6c78b4e267d8e37d70d3db8dd195574e0ab867e4f81/websockets-17.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c9d06edfd974e918b40e0662a7e76dac13125529ee5ec9fbe42dc0d6faa209c3", size = 218614, upload-time = "2026-10-19T16:56:22.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/93/d22b3d63aa439bc01a9477880070d05e0b257b0548294786a33be3e842ec/websockets-17.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a0bcf66289b9d017c175562c35c5304b5cc3ed56aded994ae30b0a9654ba405", size = 216151, upload-time = "2026-10-19T16:56:23.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/92/f60a9672cdeb8cb4f7156e6e83c76d59fdc586737a97ac77129e9fc188a2/websockets-17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f3ed747b7651668131029c720fbc32c7ee2d129bdad200e6c8f4037b20f7913a", size = 216354, upload-time = "2026-10-19T16:56:25.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/da/2be8c5f3c6612d4523f95b074e2352bc11bd090cf121d32bc11af1d52e70/websockets-17.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5868a02c4f6d698ddd19b4c9742748c03dec045289915806845189d96bc3d314", size = 225937, upload-time = "2026-10-19T16:56:26.726Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/1c/cb84aec871840acd66dfa84520bf7b8e48ce70bdb3f4baa4efd097fbe6b1/websockets-17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa87ea2ad64e59fe5622c1dc5716106b6d6e7e4b26b848cb5f5710a75c292ef5", size = 226215, upload-time = "2026-10-19T16:56:28.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/7b/cfb65557accec2eda2c80288e13eca6f1c797c579375906eb73f4053cfd1/websockets-17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65e937576141ac50fac32fa9736dbb3e0e4a7769f91be004011a09216c6ab05f", size = 227389, upload-time = "2026-10-19T16:56:29.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/a3/7e08ec4b23e376ed228831aa44b2f2a3f2e7025cc66dda4b2e53dcc2619e/websockets-17.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:35381b575f70a08d4241e73c0cde4672bb9b2a9aae8767ca6b7fb61305a052c8", size = 229534, upload-time = "2026-10-19T16:56:31.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/7d/8a813e3e53956f876cfaad4f6358625bbd94168aa8046c44e2f3561f12af/websockets-17.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5142798871a248b422f353e0684d51dfa119de3403a6fa0904b7cb806d9790ce", size = 227959, upload-time = "2026-10-19T16:56:32.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/23/5f3dab6ab44f2f30042db83a658a57129d5e3c0425c798b3c3e727bc060a/websockets-17.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e164c4a6868d11988c7f2386e3278ccff21c665cd7d08279b62709dd37a3167c", size = 226691, upload-time = "2026-10-19T16:56:34.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/49/99a3fb5a2e94d677fbd0d014a6f9a56a3d2b8e8007b7175727bc4fc4ae94/websockets-17.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10b24e836ee0233ca078e1244f6ba40343a22127a9a736f46a7e7b18de2cc711", size = 224225, upload-time = "2026-10-19T16:56:35.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/e5/f503a4d757858d083c90ea85dd1fd636ae9eb63c9552c75a4c38514ee7ed/websockets-17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56a76556ab04ab54a675acce2cac8b4e74ea2cec558a7c9ed37e0367b04b5d50", size = 226984, upload-time = "2026-10-19T16:56:36.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/a6/8586844ad4b21988837624e1f4b331bc71919c85a6eb1ee3c8bd29893f27/websockets-17.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:33221e49be79d7bfe6d12bc52ef9458b256ab74fa8ac5660acfa3065de1c7870", size = 225436, upload-time = "2026-10-19T16:56:38.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/b9/f419506494d6ffd205a9c3258f56ff5aeae6976de26361057de74f4ab659/websockets-17.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2fa79b9a48ae59744316c71a34cb9f76f30c93e117338522c5135cb7eac10351", size = 226325, upload-time = "2026-10-19T16:56:39.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/c5/55a58faa67149e84cb98b33f9d8fda95242677ea07b78f3ecaf2e77877d6/websockets-17.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f87f02afe05c1c246012e3de100a77cfbabd17db709fe93cf074271ab239b312", size = 227342, upload-time = "2026-10-19T16:56:41.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/e0/fbe561dff24b6ef1b2034877f37b56eb9b81b3c103e2a8b03819bf2b3b6d/websockets-17.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8135a549e27e9941a70e2d2156676ef2ffeff4b06e9d564dbaaae963482afdef", size = 225036, upload-time = "2026-10-19T16:56:42.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/37/d80753064f8370a24d3bec74807c3928d7b0ebef5381956e2a0108b365c2/websockets-17.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ace8dd1efa5f44c482d091aa38322e20e3ffe5adcdba6b67ad9f4e7da58f3f68", size = 225807, upload-time = "2026-10-19T16:56:43.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/9d/a11d3168522a2959c4a5661728744d62b9eee344abdba5f9fbc1d359742f/websockets-17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2129c9a5fc05a230ce088745a790af073e582b584badc7b05db067ee326f56d7", size = 226241, upload-time = "2026-10-19T16:56:45.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/28/16107b3b83fd2b5024bfecf8d000140f555204a0191202f9c4c981137810/websockets-17.3-cp314-cp314t-win32.whl", hash = "sha256:af7c474f0d8f4d2a196370e85033873c7b063a0b5da9a4f143aa023e3bd25407", size = 218365, upload-time = "2026-10-19T16:56:46.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/dd/8b00e1dd552788d8c7762d7ef61de8f2f238d7357b1a02d4c4650b112196/websockets-17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c63ad657695e8f1a48624754cb6b464118ac50d4c1d477c26461c98390f0a62", size = 218706, upload-time = "2026-10-19T16:56:47.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/eb/5d90d57a5cbe614b2f13433aaf1c0b068c804589379061ba06812a185a89/websockets-17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:3c4f631dc8d00e470cde3ec0a35d4b58a817bea592411956f1cf4099dbb306b3", size = 218622, upload-time = "2026-10-19T16:56:49.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/41/1efe5ecae1f32495dc75641704f959bd2d8e56cdc825f3acc5e010a51960/websockets-17.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5", size = 218211, upload-time = "2026-10-19T16:56:50.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/ec/4edb653533241203db3f11522e122338d4fda50a41ce2a542029086f8eda/websockets-17.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32029441af953e980b9940f042b337fe4ad9a97fa8b17a4ed878ec67b5b89ef4", size = 215944, upload-time = "2026-10-19T16:56:51.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/b9/ec450b9650925dd5cf27c1f36d0464786fe3ac702ed656879c4c0dd5c4df/websockets-17.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff", size = 216164, upload-time = "2026-10-19T16:56:53.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/1e/63f7fbff9f0540a0331f1a62424c9ff24faa6ba8aec264f55e9e31017eea/websockets-17.3-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bdc658c9474864893e0b6b94f1390bf6a912952df1870dd93bb7d1a902b5628", size = 225608, upload-time = "2026-10-19T16:56:54.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/1b/f6679ad61cb8c110033a1b30d561d5c1052983e202157251ff1778f27932/websockets-17.3-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1", size = 226042, upload-time = "2026-10-19T16:56:55.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/c6/ed993aaef8046cbbb939ae367ee3dd5383dbd055f0500a5b8aed101168b6/websockets-17.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc", size = 227203, upload-time = "2026-10-19T16:56:57.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/96/7057dc71affe6c8252df8648f5a225642228a6fdd8d046a6b9aa1fb0e9eb/websockets-17.3-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a004c1fe5a72c496f28c6c1d8c2dc5cfe3278d99478e18f8b838c70a912b366f", size = 228900, upload-time = "2026-10-19T16:56:58.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/e3/8b3268bb7e72b465e55172b86d55f58aa277fb6d9bac92aee615345874df/websockets-17.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35fe35d20baa661cef4a12ee7bc42eaf2376d43ef9816ab29dd47ca957f7a73a", size = 227684, upload-time = "2026-10-19T16:56:59.975Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/15/fa31c0a12240b1225c3712ff080d2f7ede4f695ca90e59fdb58e8affabba/websockets-17.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8b3bede7d583bc9f47d8693a29984882e59077db1a5238ad977fc70ae8169741", size = 226580, upload-time = "2026-10-19T16:57:01.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/b5/75d911c206a3035e2ecfad107878e62c06e574b2437fa2cfb4ecef436fa3/websockets-17.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dafbb58a149d1ebd5767b3f064f911c5ee0897608005e51c3904b0ada2a8d99c", size = 223967, upload-time = "2026-10-19T16:57:02.839Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/f8/f98aa36f611acd5770819f6690125c20d55f221fd6db8d00d284b5f78e89/websockets-17.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80326bc571164bbbb6bead2e35d0c943b23b24fde629032197c58151529d24d8", size = 226689, upload-time = "2026-10-19T16:57:04.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/36/d52f5dff7597de6fbfc26c3d126abd668137915719aebf97abcc3be77eca/websockets-17.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:7d53fc4b90ef6e11de639dc18744d9be8cbd70ccb5ce80027aaffe7b1ab0215a", size = 225353, upload-time = "2026-10-19T16:57:05.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/10/dcd70ad9f9cd20e8af43284ac250af610e8bf9022da25f5b409153d033b4/websockets-17.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:fdd00937f15f5f93c8e53d8060a8c4c010431ab595a9b5b50ddd569a04603944", size = 225879, upload-time = "2026-10-19T16:57:07.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ab/f26c6bfa99d38ca3d1bf23145306d97c5d0c75e133a8d837373e4b3d4629/websockets-17.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1dae5cd6c92d4440b15f20aa2987baa73c47ea14baa0fec8536a542e82e0ab33", size = 227031, upload-time = "2026-10-19T16:57:08.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/3f/c8fd74cdb526cc65295b7468f2f84ba16678646d8174288822f99f510274/websockets-17.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b225c656efa2dedecc81651d2290170af25887b7de4f38c5efc7c5ae2b9442ab", size = 224842, upload-time = "2026-10-19T16:57:10.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/c5/b3a07ce1c54fc0f5441642f5352662b90d7e658c966730eaf6d78a5f20bc/websockets-17.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:bd645b5d2f810a181c44f4a71135e7c365b44f4be9ed3d1892635be13a857553", size = 225744, upload-time = "2026-10-19T16:57:11.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/37/8321a8a6ab0215d4df0238e49ace5f539364cf337bb122b85c788e8c93ec/websockets-17.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d2605a3b2af8d66f8b75312d223642c065ec773446c04566d9030ad5e2db13c9", size = 226006, upload-time = "2026-10-19T16:57:13.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/04/8c457a7e6a260b673bf4e6f2f422eb4af5f11c48505748359706f24f1bef/websockets-17.3-cp315-cp315-win32.whl", hash = "sha256:82e60c00cfb8493a5ba27cdf9b3695fdd153f666f73e11f18968f78ead1d8705", size = 218196, upload-time = "2026-10-19T16:57:14.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/7c/c2df129d9e344b99bb388bcac54fbbdfeec29e6c716c12dc9a3398757940/websockets-17.3-cp315-cp315-win_amd64.whl", hash = "sha256:4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c", size = 218490, upload-time = "2026-10-19T16:57:16.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/4f/721b778c85559b2949e46246838e31a346dd815b3cdd8460fe517235675a/websockets-17.3-cp315-cp315-win_arm64.whl", hash = "sha256:55494475206113dbaf731dc8c5ffd5e1094175b753b7bbaad09fe3d7df61165c", size = 218403, upload-time = "2026-10-19T16:57:17.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/28/241d9ebb4dad79346b5a6abf9f14d7685b12ca358d250eea4920cdb1caf3/websockets-17.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:23cb6f6d64e3a389bb8ba150f947f9a6c8c0aca383e814ca3c5cffab738d5646", size = 218523, upload-time = "2026-10-19T16:57:18.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/bb/559fe539992102c66cbca38b488af6c7b184ab7736d819afc0f72268a2b4/websockets-17.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f27e7e8a85512b6a4b490d5756edde8e6772883a5800e1476172e44eda3f7d7c", size = 216103, upload-time = "2026-10-19T16:57:20.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/ee/e8ade4f13a3d0da68636fe44bb8ac1a3e36fc57070512788320dead42067/websockets-17.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6342f79d6f50e680c8aa59e0615d745e867e51ffd59c77093be60b7c433067af", size = 216316, upload-time = "2026-10-19T16:57:21.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/06/4812b2a7a5e6571d9be425e76cf54ee69aaa15720caa02307785aa5b19a7/websockets-17.3-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fc23e72bd7f8a8e540fed2244524d0a9aeb6ae3325f3d56f04a7a3debe5b04c2", size = 226467, upload-time = "2026-10-19T16:57:23.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/e8/adfc05d7f9b6b0cce6cc554426c50b7058609119b6b84a39171fe5ed4e50/websockets-17.3-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:722ab747ff5dc28070b1a8c020dba6466f13f2ab56bc251ad301fefa2454aa1e", size = 226736, upload-time = "2026-10-19T16:57:24.419Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/b2/d5b93dd933552554507edae9e3a4890269a8ea8b46019450ea9c8a30e7cc/websockets-17.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d20d6ea5aabbeba2e054d33e405192ef75dc12947bbd00ec97d23b14969d325", size = 228065, upload-time = "2026-10-19T16:57:25.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/37/642f0f1cf1c4814147a4f0586f796a0b38e42b69e5a296bfc270fb660051/websockets-17.3-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:90cc9973c51811918247a78d9ab2f54871296e4e37e69a98aba4d3c9acedbf62", size = 229692, upload-time = "2026-10-19T16:57:27.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/33/eac81cf62ac394b61db6a27ccbe8fb9ae382016ef73ede1edf9242a6a43e/websockets-17.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63b87c93e28957e6dc2bb92d31727a863c9bf4a4daa080f0995fdcb0242c9166", size = 228503, upload-time = "2026-10-19T16:57:29.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/84/e057788a9bd056f4f7d3a06b59efaaf724bbc6f2d1e1d316f8a93c951abc/websockets-17.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f7431e71072962d3ed1768038200aa4f1e76d9c2d7bedd554d57a55faaf6644", size = 227379, upload-time = "2026-10-19T16:57:30.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/a7/cca1f9f45b893bb5b7545f2b234f910e07fcd2f8065724ac1a62a0961be2/websockets-17.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:534ed583fd4323de45d196535a444cebd1af2105e6e4e0cdcbf8cea266a8747a", size = 224719, upload-time = "2026-10-19T16:57:31.895Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/a0/5d07451027150c802ce4c9999e86994a3f0de5f4dbe3d1cce46995999269/websockets-17.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8879b6a4040ee925c638548238ab7fe612cf5fa8752fe847346da0da8be81c3f", size = 227564, upload-time = "2026-10-19T16:57:33.236Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/90/835f37e13fac0c615a700caae0d39deff1de2d2b9b341836a786f9b359e0/websockets-17.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58e5d1e935a90878a935c7d2737a25c71cf4f1c13b6e6bef454ab722c4f338bb", size = 226218, upload-time = "2026-10-19T16:57:34.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/8a/718639939b947fe715501e8073350fd3f8a42f8b5eba3c0c2389c5836513/websockets-17.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:0a657b51e4a5f4ac577c11e706ff9a884ea5aaf3e2666bae283a251c63e66acb", size = 226712, upload-time = "2026-10-19T16:57:36.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/45/6bef8572b9b0dee51369b69776af1e4c7cc3a807f199dc3c165bbcf1199a/websockets-17.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:411aa0fb234f42a1444dea1e177890ddfc577b36ffdfafec25cb0d2c818e1af8", size = 227819, upload-time = "2026-10-19T16:57:37.586Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/3f/3b726d0b46a132e641d2d8c0243ea8a7d39464dbaf28c7a206e51fbae681/websockets-17.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:623d4b44f64efdf235b1e8dce266057073d93cde6197e7d7684f20c297f67f30", size = 225573, upload-time = "2026-10-19T16:57:39.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/96/cd972a12e1412d9b42c42ef42efbd81aa396ca9408d982d524012f011a8d/websockets-17.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5d0c871efc696ef5eb8514df64b86ab28736db753ebb42a2101f3b562f4849c0", size = 226430, upload-time = "2026-10-19T16:57:40.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/2d/e11524771588188a39f6da234779d4942496b96c5a10b92f3df30db48608/websockets-17.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1db2604e6aedb7a2cb2f6e0575d6c9a852ccac8239703a930ac83b63309177a5", size = 226728, upload-time = "2026-10-19T16:57:42.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9b/63722aed973f53ea579e1ce0c4563a2b8b536629e556feff26e4c6a2a60d/websockets-17.3-cp315-cp315t-win32.whl", hash = "sha256:07246d4f0293ef0b87eff3d7dd88c1f03dd9f678fa39f6de87b2af8504e7e0de", size = 218325, upload-time = "2026-10-19T16:57:43.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/07/5e0c92e225546f1789c957c0383852797be7c899b07207e9b6eb4a93473f/websockets-17.3-cp315-cp315t-win_amd64.whl", hash = "sha256:856bbb24f2ee6436af7609267e73309e4bb0a0f9982f79f1c2e01019279c6671", size = 218641, upload-time = "2026-10-19T16:57:44.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/e4/339663de65c44ab3eb5209890e86eed26e1a131d6afab3df6f1c3e5b4d8c/websockets-17.3-cp315-cp315t-win_arm64.whl", hash = "sha256:a22c4956a4d858200dba83f0691bb692d77b0eeddfca80157820d20b85dea4f1", size = 218564, upload-time = "2026-10-19T16:57:46.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/f1/b462b1518779adf0b6fce305beee07357a79cf0724bbe61113ed6521882e/websockets-17.3-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:34c78052277053bcb09810ac8e9ba4029f9e3e277833fc18ec1c928a6246ac91", size = 215839, upload-time = "2026-10-19T16:57:47.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/59/7dcea408892967516cc00d491ee7bba48577059c6eb0811f436b720bcd98/websockets-17.3-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:ef07e0b6b879f699a16fc7a513b974246fdeaef44908a021130cf4afe6a1c89c", size = 216114, upload-time = "2026-10-19T16:57:49.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/b7/2d447e461f1160dc8dcdd8b143e2adba54d402e5f828eeb1f8469970c937/websockets-17.3-pp311-pypy311_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99c5637b74e2805ee4de9e2a43c8b45f678e50abb1c2da9880f0372f6498ba2f", size = 217065, upload-time = "2026-10-19T16:57:50.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/51/35aa5edd12b23e0001b85517ad447342c07a29fcee62009b0859825b77a7/websockets-17.3-pp311-pypy311_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c460aeb12c2bd1c204944d9e9199595bf9e505eb542018d304bf9e63cb60cad", size = 216952, upload-time = "2026-10-19T16:57:51.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/db/537306b92236d761b99dc56a7316c0ad46f2854cd0756585b3a9d3fe5639/websockets-17.3-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1c174b366565e101f903f9d1cc96d5508f805759dd37d012743f1c73ec0d7b", size = 217844, upload-time = "2026-10-19T16:57:53.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/c2/494bed87536f3822872e57b07ac9afa744af88ff64dda664b9caa32b1a72/websockets-17.3-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:c4b3f2c7d84f303da873b634d0beef83b5e1137294c3de4e23102841dac0cf4a", size = 306274, upload-time = "2026-10-19T16:57:54.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/1a/5582103e0362f86c4725051ded89e585ceaf750f26beace530b7e42c81c0/websockets-17.3-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:0e65260a1b1d7b81651f47343d836de70b88b02358ae3658efa6382104ec37b1", size = 215872, upload-time = "2026-10-19T16:57:55.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/a7/f69d65ed3ff9ae73d428a918300c23a992e4d50683090b8dc33cc3341d5a/websockets-17.3-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:94c02b0702656f083c098bdbd93bce22a7aa7a2b34ba56d2472efb67dab7cf58", size = 216136, upload-time = "2026-10-19T16:57:57.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/76/c64fda4ac18a3aeb32e66acec481011a766add480b6362fad80e2c3c1963/websockets-17.3-pp312-pypy312_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ade76f8c84ade325bbed96806357fffd353fcc29a5166d6dc6d39381f6d9f76c", size = 217080, upload-time = "2026-10-19T16:57:58.593Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/6d/085a3f0e6152bbe79917973f3d33cd008437de6c04675e2f15253b921f21/websockets-17.3-pp312-pypy312_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a215749c9a88ef2842e880215193e56ae60b856773c99702b078236bc6d4adb", size = 216966, upload-time = "2026-10-19T16:57:59.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/b8/7eb904583ea2090b329f50487e6f8dc58eb558712b9f3c16f289f0be668e/websockets-17.3-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7b5d23ef144b32645d97bd99f382c4394c443395f45fe1576786838d26e308", size = 217828, upload-time = "2026-10-19T16:58:01.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/36/f54b4a1cd9456ca0a65a76d77d181621433793aca633dfeed214748ff4f0/websockets-17.3-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:13eace1b315345ebac5b27821f5dcce24e5cf9976ab18f79e00a59bdd67e5d19", size = 306301, upload-time = "2026-10-19T16:58:03.161Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.8"
|
version = "3.1.8"
|
||||||
|
|||||||
Reference in New Issue
Block a user