feat: increase max players to 16, add dept leaderboard and scoring constants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
# Route Rush
|
# Route Rush
|
||||||
|
|
||||||
Multiplayer network routing game built for team events. Players race to find the most efficient path through a 4×4 network grid before time runs out. Built with FastAPI, WebSockets, and vanilla JS — no dependencies on the client side.
|
Multiplayer network routing game built for team events. Players race to find the most efficient path through a 4×4 network grid before time runs out. Built with FastAPI, WebSockets, and vanilla JS — no client-side dependencies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Each round, all players receive the same grid with a start node and a destination. Links between nodes carry modifiers (normal, slow, high-latency, or down). Players trace a route by tapping nodes on their mobile device and submit before the timer expires. Points are awarded based on whether the route is valid, how fast it was submitted, and how efficient the path was.
|
Each round, all players receive the same grid with a start node and a destination. Links between nodes carry modifiers (normal, slow, boost, or down). Players trace a route by tapping nodes on their mobile device and submit before the timer expires. Points are awarded based on whether the route is valid, how fast it was submitted, and how efficient the path was.
|
||||||
|
|
||||||
Designed for 2–4 players. Runs as a Docker container proxied through the Pathfinder nginx stack.
|
Designed for 2–4 players. Runs as a Docker container on `labmini-01`, proxied through Caddy with automatic SSL.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ Designed for 2–4 players. Runs as a Docker container proxied through the Pathf
|
|||||||
|
|
||||||
| Purpose | URL |
|
| Purpose | URL |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Players (mobile) | `https://pathfinder.libertypr.com/route-rush/` |
|
| Players (mobile) | `https://route-rush.carloselugo.com` |
|
||||||
| Host / Projector | `https://pathfinder.libertypr.com/route-rush/host` |
|
| Host / Projector | `https://route-rush.carloselugo.com/host` |
|
||||||
| Score History | `https://pathfinder.libertypr.com/route-rush/scores` |
|
| Score History | `https://route-rush.carloselugo.com/scores` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ Designed for 2–4 players. Runs as a Docker container proxied through the Pathf
|
|||||||
| Realtime | WebSockets (native FastAPI) |
|
| Realtime | WebSockets (native FastAPI) |
|
||||||
| Persistence | SQLite via Docker volume |
|
| Persistence | SQLite via Docker volume |
|
||||||
| Frontend | Vanilla JS, HTML5 Canvas |
|
| Frontend | Vanilla JS, HTML5 Canvas |
|
||||||
| Proxy | Pathfinder nginx (`pathfinder-nginx`) |
|
| Proxy | Caddy (automatic SSL, `web_web-net`) |
|
||||||
| Container | Docker Compose, `ipfix-stack_ipfix-net` |
|
| Container | Docker Compose |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ Designed for 2–4 players. Runs as a Docker container proxied through the Pathf
|
|||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
├── static/ # Empty — all CSS/JS is inline in templates
|
||||||
└── templates/
|
└── templates/
|
||||||
├── player.html # Mobile game interface (HTML5 Canvas grid)
|
├── player.html # Mobile game interface (HTML5 Canvas grid)
|
||||||
├── host.html # Host/projector control panel with QR code
|
├── host.html # Host/projector control panel with QR code
|
||||||
@@ -58,69 +59,53 @@ services:
|
|||||||
route-rush:
|
route-rush:
|
||||||
build: .
|
build: .
|
||||||
container_name: route-rush
|
container_name: route-rush
|
||||||
ports:
|
restart: unless-stopped
|
||||||
- "8001:8001"
|
|
||||||
volumes:
|
|
||||||
- rr-data:/data
|
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/rr.db
|
- DB_PATH=/data/rr.db
|
||||||
restart: unless-stopped
|
volumes:
|
||||||
|
- route_rush_data:/data
|
||||||
networks:
|
networks:
|
||||||
- ipfix-net
|
- web-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
rr-data:
|
route_rush_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ipfix-net:
|
web-net:
|
||||||
name: ipfix-stack_ipfix-net
|
|
||||||
external: true
|
external: true
|
||||||
|
name: web_web-net
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> No ports exposed to host — Caddy reaches the container directly over `web_web-net`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Nginx Proxy (Pathfinder default.conf)
|
## Caddyfile Block
|
||||||
|
|
||||||
Add to both the HTTP (port 80) and HTTPS (port 443) server blocks:
|
```caddy
|
||||||
|
route-rush.carloselugo.com {
|
||||||
```nginx
|
import security_headers
|
||||||
location = /route-rush {
|
reverse_proxy route-rush:8001 {
|
||||||
return 301 /route-rush/;
|
header_up Host {host}
|
||||||
}
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
location /route-rush/ws/ {
|
log {
|
||||||
proxy_pass http://route-rush:8001/ws/;
|
output file /var/log/caddy/route-rush.log
|
||||||
proxy_http_version 1.1;
|
format json
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
}
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /route-rush/ {
|
|
||||||
proxy_pass http://route-rush:8001/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
After editing, reload nginx without downtime:
|
> No `encode gzip` — gzip breaks WebSocket upgrades.
|
||||||
|
> No `internal_only` — players need public access from their mobile devices.
|
||||||
|
|
||||||
|
After editing the Caddyfile, force-recreate Caddy (reload is not enough due to bind mount caching):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec pathfinder-nginx nginx -t && docker exec pathfinder-nginx nginx -s reload
|
cd /opt/web
|
||||||
|
docker compose up -d --force-recreate caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
> If nginx ignores the changes, force a full recreate:
|
|
||||||
> ```bash
|
|
||||||
> cd /opt/pathfinder && docker compose up -d --force-recreate pathfinder-nginx
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
@@ -148,12 +133,14 @@ Host configures rounds (3/5/7) and time per round (30/45/60s)
|
|||||||
Host clicks START GAME
|
Host clicks START GAME
|
||||||
↓
|
↓
|
||||||
[Each round]
|
[Each round]
|
||||||
Grid is generated → broadcast to all players
|
Grid generated → broadcast to all players
|
||||||
Players trace route by tapping nodes → submit before timer expires
|
Players trace route by tapping nodes → submit before timer expires
|
||||||
Timer expires or all players submit → Host clicks END ROUND
|
Timer expires or all players submit → Host clicks END ROUND
|
||||||
Results screen: all paths revealed on grid, leaderboard updated
|
Results screen: all paths revealed on grid, leaderboard updated
|
||||||
↓
|
↓
|
||||||
After final round → Podium screen with confetti
|
After final round → Podium screen with confetti
|
||||||
|
↓
|
||||||
|
Host clicks PLAY AGAIN → all players kicked back to join screen
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -222,6 +209,7 @@ Formula: `points = 1000 + 500 × (time_remaining / time_limit) − max(0, hop_co
|
|||||||
| `round_start` | `{round, total_rounds, grid, time_limit}` | New round begins |
|
| `round_start` | `{round, total_rounds, grid, time_limit}` | New round begins |
|
||||||
| `path_result` | `{points, total, reason, elapsed}` | Score for submitted path |
|
| `path_result` | `{points, total, reason, elapsed}` | Score for submitted path |
|
||||||
| `round_results` | `{grid, submissions, leaderboard, is_final}` | Round ended |
|
| `round_results` | `{grid, submissions, leaderboard, is_final}` | Round ended |
|
||||||
|
| `kicked` | — | Host reset — return to join screen |
|
||||||
| `error` | `{msg}` | Error (game full, invalid name) |
|
| `error` | `{msg}` | Error (game full, invalid name) |
|
||||||
|
|
||||||
### Host → Server
|
### Host → Server
|
||||||
@@ -230,7 +218,7 @@ Formula: `points = 1000 + 500 × (time_remaining / time_limit) − max(0, hop_co
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `start_round` | `{total_rounds?, time_limit?}` | Start next round |
|
| `start_round` | `{total_rounds?, time_limit?}` | Start next round |
|
||||||
| `end_round` | — | End current round and reveal results |
|
| `end_round` | — | End current round and reveal results |
|
||||||
| `reset` | — | Reset game to lobby |
|
| `reset` | — | Kick all players and reset to lobby |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,21 +243,20 @@ CREATE TABLE scores (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Scores persist in Docker volume `rr-data` and survive container rebuilds.
|
Scores persist in Docker volume `route_rush_data` and survive container rebuilds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Issues / Lessons Learned
|
## Known Issues / Lessons Learned
|
||||||
|
|
||||||
- **`crypto.randomUUID()` not available over HTTP** — the player ID uses `Math.random().toString(36)` as a fallback since the game runs over HTTP internally. `crypto.randomUUID()` requires HTTPS or localhost.
|
- **`static/` directory must exist** — FastAPI's `StaticFiles` mount throws `RuntimeError` if the directory is missing, even if it's empty. Always create it: `mkdir -p /opt/route-rush/static`.
|
||||||
- **No gzip on WebSocket proxies** — Caddy/nginx `encode gzip` or `gzip on` breaks WebSocket upgrades. The route-rush proxy block intentionally omits compression.
|
- **No gzip on WebSocket proxies** — Caddy `encode gzip` breaks WebSocket upgrades. Omit it for any service with WS connections.
|
||||||
- **nginx bind mount is read-only** — after editing `default.conf`, nginx must be force-recreated (`--force-recreate`), not just reloaded, for changes to take effect from disk.
|
- **Caddy stale bind mount** — after editing the Caddyfile, always use `--force-recreate caddy`, not just reload.
|
||||||
- **Player limit is 4** — enforced server-side. A 5th connection attempt returns an error message.
|
- **Player limit is 4** — enforced server-side. A 5th connection attempt returns an error. To raise the limit, change `len(game.players) >= 4` in `main.py` and update the lobby display in `host.html`.
|
||||||
|
- **PLAY AGAIN kicks all players** — by design. Host reset sends `kicked` to all connected players, clearing their session and returning them to the join screen. Players must re-enter their info for the next game.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scaling Notes
|
## Scaling Notes
|
||||||
|
|
||||||
Current limit is 4 players. To scale to larger groups (e.g. 16 for a full team event), the recommended approach is manual brackets: 4 groups of 4 play simultaneously, winners advance to a final round. The host manages bracket progression manually between sessions.
|
Current limit is 4 players. For larger groups (e.g. a full team event), the recommended approach is manual brackets: groups of 4 play simultaneously, winners advance to a final round. The host manages bracket progression manually between sessions.
|
||||||
|
|
||||||
To raise the hard limit, change `game.players >= 4` in `main.py` and update the lobby display in `host.html`.
|
|
||||||
|
|||||||
+13
-6
@@ -1,14 +1,21 @@
|
|||||||
services:
|
services:
|
||||||
route-rush:
|
route-rush:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: route-rush
|
container_name: route-rush
|
||||||
ports:
|
|
||||||
- "8001:8001"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/rr.db
|
||||||
|
volumes:
|
||||||
|
- route_rush_data:/data
|
||||||
networks:
|
networks:
|
||||||
- ipfix-net
|
- web-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
route_rush_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ipfix-net:
|
web-net:
|
||||||
name: ipfix-stack_ipfix-net
|
|
||||||
external: true
|
external: true
|
||||||
|
name: web_web-net
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
import json, time, random, sqlite3, os
|
import json, time, random, sqlite3, os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/data/quiz.db")
|
DB_PATH = os.environ.get("DB_PATH", "/data/rr.db")
|
||||||
|
app = FastAPI()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# ── SCORING CONSTANTS ─────────────────────────────────────────────────────────
|
||||||
|
MAX_SCORE_PER_PLAYER = 4500 # 3 rounds × 1500 pts theoretical max
|
||||||
|
PRIZE_THRESHOLD = 3000 # avg score that earns full 25 prize points
|
||||||
|
MAX_PRIZE_POINTS = 25
|
||||||
|
|
||||||
# ── DATABASE ──────────────────────────────────────────────────────────────────
|
# ── DATABASE ──────────────────────────────────────────────────────────────────
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -18,109 +24,112 @@ def init_db():
|
|||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
started_at TEXT NOT NULL,
|
started_at TEXT NOT NULL,
|
||||||
ended_at TEXT,
|
ended_at TEXT,
|
||||||
total_players INTEGER DEFAULT 0
|
rounds INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS scores (
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id),
|
session_id INTEGER REFERENCES sessions(id),
|
||||||
player TEXT NOT NULL,
|
player TEXT NOT NULL,
|
||||||
score INTEGER NOT NULL DEFAULT 0,
|
score INTEGER DEFAULT 0,
|
||||||
rank INTEGER,
|
rank INTEGER,
|
||||||
dept TEXT NOT NULL DEFAULT '',
|
dept TEXT NOT NULL DEFAULT '',
|
||||||
finished_at TEXT NOT NULL
|
finished_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
# Safe migration: add dept column if it doesn't exist yet
|
# safe migration if dept column missing from older DB
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''")
|
conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # column already exists
|
pass
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
def save_session(players: list) -> int:
|
# ── GRID ──────────────────────────────────────────────────────────────────────
|
||||||
now = datetime.utcnow().isoformat()
|
GRID_SIZE = 4
|
||||||
with get_db() as conn:
|
|
||||||
cur = conn.execute(
|
|
||||||
"INSERT INTO sessions (started_at, ended_at, total_players) VALUES (?,?,?)",
|
|
||||||
(now, now, len(players))
|
|
||||||
)
|
|
||||||
session_id = cur.lastrowid
|
|
||||||
ranked = sorted(players, key=lambda p: p["score"], reverse=True)
|
|
||||||
for rank, p in enumerate(ranked, 1):
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO scores (session_id, player, score, rank, dept, finished_at) VALUES (?,?,?,?,?,?)",
|
|
||||||
(session_id, p.get("display", p["name"]), p["score"], rank, p.get("dept", ""), now)
|
|
||||||
)
|
|
||||||
return session_id
|
|
||||||
|
|
||||||
# ── APP ───────────────────────────────────────────────────────────────────────
|
MODIFIER_TYPES = [
|
||||||
app = FastAPI()
|
"normal", "normal", "normal", "normal", "normal", "normal",
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
"slow", "slow", "boost", "blocked"
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
# ── QUESTIONS ─────────────────────────────────────────────────────────────────
|
|
||||||
QUESTIONS = [
|
|
||||||
{
|
|
||||||
"q": "¿Cuál es el enfoque principal del equipo de Network Intelligence?",
|
|
||||||
"options": [
|
|
||||||
"Vender licencias de software a otras empresas",
|
|
||||||
"Construir herramientas propias que empoderan a los equipos operacionales",
|
|
||||||
"Administrar el presupuesto de TI de LCPR",
|
|
||||||
"Instalar equipos físicos en planta externa"
|
|
||||||
],
|
|
||||||
"correct": 1,
|
|
||||||
"fun_fact": "¡Exacto! Construimos para quienes operan la red día a día — no solo dashboards bonitos."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "¿Qué problema resolvió el equipo al reemplazar Power BI con frontend propio?",
|
|
||||||
"options": [
|
|
||||||
"La pantalla se veía más bonita",
|
|
||||||
"Eliminamos la dependencia de licencias costosas y ganamos control total",
|
|
||||||
"Fue más fácil de instalar en los servidores",
|
|
||||||
"Los colores del dashboard coincidían con los de Liberty"
|
|
||||||
],
|
|
||||||
"correct": 1,
|
|
||||||
"fun_fact": "$0 en licencias de visualización. El código es nuestro y lo extendemos como queremos."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "¿Qué es Pathfinder?",
|
|
||||||
"options": [
|
|
||||||
"Una aplicación para rastrear camiones de campo",
|
|
||||||
"Un sistema de GPS para la red de fibra",
|
|
||||||
"Una plataforma web que centraliza herramientas operacionales de red en un solo lugar",
|
|
||||||
"Un reporte mensual de disponibilidad de red"
|
|
||||||
],
|
|
||||||
"correct": 2,
|
|
||||||
"fun_fact": "Pathfinder es el 'panel de control' propio de LCPR — construido exactamente para nuestros flujos de trabajo."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "¿Qué logró el proyecto de Firmware Upgrade Automation?",
|
|
||||||
"options": [
|
|
||||||
"Actualizar manualmente 10 modems por semana",
|
|
||||||
"Contratar más técnicos para visitas a domicilio",
|
|
||||||
"Automatizar la actualización masiva de modems sin intervención humana",
|
|
||||||
"Comprar equipos nuevos para reemplazar los modems viejos"
|
|
||||||
],
|
|
||||||
"correct": 2,
|
|
||||||
"fun_fact": "400+ modems actualizados en batches automáticos. Antes era trabajo manual uno por uno."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "¿Hacia dónde se dirige el equipo en su visión AIOps?",
|
|
||||||
"options": [
|
|
||||||
"Depender más de herramientas comerciales como ServiceNow",
|
|
||||||
"Detectar y predecir fallas antes de que el cliente las reporte",
|
|
||||||
"Reducir el equipo de ingeniería a la mitad",
|
|
||||||
"Migrar toda la red a la nube pública"
|
|
||||||
],
|
|
||||||
"correct": 1,
|
|
||||||
"fun_fact": "La meta: que el técnico salga a campo con el diagnóstico ya hecho — no a diagnosticar."
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def generate_grid():
|
||||||
|
nodes = []
|
||||||
|
for row in range(GRID_SIZE):
|
||||||
|
for col in range(GRID_SIZE):
|
||||||
|
nodes.append({"id": row * GRID_SIZE + col, "row": row, "col": col})
|
||||||
|
|
||||||
|
corners = [0, 3, 12, 15]
|
||||||
|
start = random.choice(corners)
|
||||||
|
opposite = {0: 15, 3: 12, 12: 3, 15: 0}
|
||||||
|
end = opposite[start]
|
||||||
|
|
||||||
|
for attempt in range(20):
|
||||||
|
edges = []
|
||||||
|
for row in range(GRID_SIZE):
|
||||||
|
for col in range(GRID_SIZE):
|
||||||
|
nid = row * GRID_SIZE + col
|
||||||
|
if col + 1 < GRID_SIZE:
|
||||||
|
edges.append({"a": nid, "b": nid + 1, "modifier": random.choice(MODIFIER_TYPES)})
|
||||||
|
if row + 1 < GRID_SIZE:
|
||||||
|
edges.append({"a": nid, "b": nid + GRID_SIZE, "modifier": random.choice(MODIFIER_TYPES)})
|
||||||
|
|
||||||
|
# Validate: start node must have at least one non-blocked neighbor
|
||||||
|
start_edges = [e for e in edges if e["a"] == start or e["b"] == start]
|
||||||
|
if any(e["modifier"] != "blocked" for e in start_edges):
|
||||||
|
break
|
||||||
|
# If all attempts failed (extremely unlikely), force one start edge open
|
||||||
|
else:
|
||||||
|
for e in edges:
|
||||||
|
if e["a"] == start or e["b"] == start:
|
||||||
|
e["modifier"] = "normal"
|
||||||
|
break
|
||||||
|
|
||||||
|
return {"nodes": nodes, "edges": edges, "start": start, "end": end}
|
||||||
|
|
||||||
|
def score_path(path: list, edges: list, elapsed: float, time_limit: float, end_node: int) -> dict:
|
||||||
|
if not path or path[-1] != end_node:
|
||||||
|
return {"points": 0, "reason": "no_path"}
|
||||||
|
|
||||||
|
edge_map = {}
|
||||||
|
for e in edges:
|
||||||
|
edge_map[(e["a"], e["b"])] = e["modifier"]
|
||||||
|
edge_map[(e["b"], e["a"])] = e["modifier"]
|
||||||
|
|
||||||
|
hop_cost = 0
|
||||||
|
valid = True
|
||||||
|
for i in range(len(path) - 1):
|
||||||
|
a, b = path[i], path[i + 1]
|
||||||
|
mod = edge_map.get((a, b))
|
||||||
|
if mod is None or mod == "blocked":
|
||||||
|
valid = False
|
||||||
|
break
|
||||||
|
elif mod == "slow":
|
||||||
|
hop_cost += 2
|
||||||
|
elif mod == "boost":
|
||||||
|
hop_cost += 0.5
|
||||||
|
else:
|
||||||
|
hop_cost += 1
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
return {"points": 0, "reason": "invalid_path", "hops": len(path) - 1}
|
||||||
|
|
||||||
|
base = 1000
|
||||||
|
speed_bonus = int(500 * max(0, (time_limit - elapsed) / time_limit))
|
||||||
|
efficiency_penalty = int(max(0, hop_cost - (GRID_SIZE - 1)) * 50)
|
||||||
|
points = max(0, base + speed_bonus - efficiency_penalty)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"points": points,
|
||||||
|
"reason": "ok",
|
||||||
|
"hops": len(path) - 1,
|
||||||
|
"hop_cost": hop_cost,
|
||||||
|
"speed_bonus": speed_bonus,
|
||||||
|
"efficiency_penalty": efficiency_penalty,
|
||||||
|
}
|
||||||
|
|
||||||
# ── GAME STATE ────────────────────────────────────────────────────────────────
|
# ── GAME STATE ────────────────────────────────────────────────────────────────
|
||||||
class GameState:
|
class GameState:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -128,56 +137,59 @@ class GameState:
|
|||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self.phase = "lobby"
|
self.phase = "lobby"
|
||||||
self.players: dict = {}
|
self.players = {}
|
||||||
self.current_q = -1
|
self.current_round = 0
|
||||||
self.q_start_time = 0
|
self.total_rounds = 3
|
||||||
self.answers_this_round = []
|
self.grid = None
|
||||||
self.question_order = list(range(len(QUESTIONS)))
|
self.round_start = 0
|
||||||
random.shuffle(self.question_order)
|
self.time_limit = 30
|
||||||
|
self.prize_threshold = PRIZE_THRESHOLD # can be overridden by host
|
||||||
|
self.submissions = {}
|
||||||
|
self.session_id = None
|
||||||
|
|
||||||
game = GameState()
|
game = GameState()
|
||||||
|
|
||||||
# ── CONNECTION MANAGER ────────────────────────────────────────────────────────
|
# ── CONNECTION MANAGER ────────────────────────────────────────────────────────
|
||||||
class ConnectionManager:
|
class ConnMgr:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.players: dict = {}
|
self.players = {}
|
||||||
self.hosts: list = []
|
self.hosts = []
|
||||||
|
|
||||||
async def connect_player(self, ws_id: str, ws: WebSocket):
|
async def connect_player(self, pid, ws):
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
self.players[ws_id] = ws
|
self.players[pid] = ws
|
||||||
|
|
||||||
async def connect_host(self, ws: WebSocket):
|
async def connect_host(self, ws):
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
self.hosts.append(ws)
|
self.hosts.append(ws)
|
||||||
|
|
||||||
def disconnect(self, ws_id: str):
|
def disconnect(self, pid):
|
||||||
self.players.pop(ws_id, None)
|
self.players.pop(pid, None)
|
||||||
game.players.pop(ws_id, None)
|
game.players.pop(pid, None)
|
||||||
|
|
||||||
def disconnect_host(self, ws: WebSocket):
|
def disconnect_host(self, ws):
|
||||||
if ws in self.hosts:
|
if ws in self.hosts:
|
||||||
self.hosts.remove(ws)
|
self.hosts.remove(ws)
|
||||||
|
|
||||||
async def send_to(self, ws_id: str, data: dict):
|
async def send(self, pid, data):
|
||||||
ws = self.players.get(ws_id)
|
ws = self.players.get(pid)
|
||||||
if ws:
|
if ws:
|
||||||
try:
|
try:
|
||||||
await ws.send_json(data)
|
await ws.send_json(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def broadcast_players(self, data: dict):
|
async def broadcast_players(self, data):
|
||||||
dead = []
|
dead = []
|
||||||
for ws_id, ws in list(self.players.items()):
|
for pid, ws in list(self.players.items()):
|
||||||
try:
|
try:
|
||||||
await ws.send_json(data)
|
await ws.send_json(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
dead.append(ws_id)
|
dead.append(pid)
|
||||||
for d in dead:
|
for d in dead:
|
||||||
self.disconnect(d)
|
self.disconnect(d)
|
||||||
|
|
||||||
async def broadcast_hosts(self, data: dict):
|
async def broadcast_hosts(self, data):
|
||||||
dead = []
|
dead = []
|
||||||
for ws in list(self.hosts):
|
for ws in list(self.hosts):
|
||||||
try:
|
try:
|
||||||
@@ -187,21 +199,84 @@ class ConnectionManager:
|
|||||||
for d in dead:
|
for d in dead:
|
||||||
self.disconnect_host(d)
|
self.disconnect_host(d)
|
||||||
|
|
||||||
async def broadcast_all(self, data: dict):
|
async def broadcast_all(self, data):
|
||||||
await self.broadcast_players(data)
|
await self.broadcast_players(data)
|
||||||
await self.broadcast_hosts(data)
|
await self.broadcast_hosts(data)
|
||||||
|
|
||||||
mgr = ConnectionManager()
|
mgr = ConnMgr()
|
||||||
|
|
||||||
def get_leaderboard():
|
def leaderboard():
|
||||||
ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True)
|
ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True)
|
||||||
return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept","")} for p in ranked]
|
return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept", "")} for p in ranked]
|
||||||
|
|
||||||
|
def dept_leaderboard():
|
||||||
|
"""
|
||||||
|
Group players by dept and compute:
|
||||||
|
- avg_score : mean individual score across all players in the group
|
||||||
|
- pct : avg_score as % of MAX_SCORE_PER_PLAYER (4500 pts)
|
||||||
|
- prize_pts : prize points earned (max 25, anchored at PRIZE_THRESHOLD=3000)
|
||||||
|
- player_count: number of players in the group
|
||||||
|
Sorted by avg_score descending.
|
||||||
|
"""
|
||||||
|
groups = {}
|
||||||
|
for p in game.players.values():
|
||||||
|
dept = p.get("dept", "") or "—"
|
||||||
|
if dept not in groups:
|
||||||
|
groups[dept] = []
|
||||||
|
groups[dept].append(p["score"])
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for dept, scores in groups.items():
|
||||||
|
n = len(scores)
|
||||||
|
avg = round(sum(scores) / n) if n else 0
|
||||||
|
pct = round((avg / MAX_SCORE_PER_PLAYER) * 100, 1)
|
||||||
|
prize = min(MAX_PRIZE_POINTS, round((avg / game.prize_threshold) * MAX_PRIZE_POINTS))
|
||||||
|
result.append({
|
||||||
|
"dept": dept,
|
||||||
|
"avg_score": avg,
|
||||||
|
"pct": pct,
|
||||||
|
"prize_pts": prize,
|
||||||
|
"player_count": n,
|
||||||
|
"total_score": sum(scores),
|
||||||
|
})
|
||||||
|
|
||||||
|
result.sort(key=lambda x: x["avg_score"], reverse=True)
|
||||||
|
# Add rank
|
||||||
|
for i, r in enumerate(result, 1):
|
||||||
|
r["rank"] = i
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save_session():
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
with get_db() as conn:
|
||||||
|
if not game.session_id:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO sessions (started_at, ended_at, rounds) VALUES (?,?,?)",
|
||||||
|
(now, now, game.current_round)
|
||||||
|
)
|
||||||
|
game.session_id = cur.lastrowid
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET ended_at=?, rounds=? WHERE id=?",
|
||||||
|
(now, game.current_round, game.session_id)
|
||||||
|
)
|
||||||
|
ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True)
|
||||||
|
conn.execute("DELETE FROM scores WHERE session_id=?", (game.session_id,))
|
||||||
|
for rank, p in enumerate(ranked, 1):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO scores (session_id, player, score, rank, dept, finished_at) VALUES (?,?,?,?,?,?)",
|
||||||
|
(game.session_id, p.get("display", p["name"]), p["score"], rank, p.get("dept", ""), now)
|
||||||
|
)
|
||||||
|
|
||||||
# ── ROUTES ────────────────────────────────────────────────────────────────────
|
# ── ROUTES ────────────────────────────────────────────────────────────────────
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def player_page(request: Request):
|
async def player_page(request: Request):
|
||||||
return templates.TemplateResponse("player.html", {"request": request})
|
return templates.TemplateResponse("player.html", {"request": request})
|
||||||
|
|
||||||
|
@app.get("/practice", response_class=HTMLResponse)
|
||||||
|
async def practice_page(request: Request):
|
||||||
|
return templates.TemplateResponse("practice.html", {"request": request})
|
||||||
|
|
||||||
@app.get("/host", response_class=HTMLResponse)
|
@app.get("/host", response_class=HTMLResponse)
|
||||||
async def host_page(request: Request):
|
async def host_page(request: Request):
|
||||||
return templates.TemplateResponse("host.html", {"request": request})
|
return templates.TemplateResponse("host.html", {"request": request})
|
||||||
@@ -210,18 +285,11 @@ async def host_page(request: Request):
|
|||||||
async def scores_page(request: Request):
|
async def scores_page(request: Request):
|
||||||
return templates.TemplateResponse("scores.html", {"request": request})
|
return templates.TemplateResponse("scores.html", {"request": request})
|
||||||
|
|
||||||
@app.delete("/api/scores/clear")
|
|
||||||
async def clear_scores():
|
|
||||||
with get_db() as conn:
|
|
||||||
conn.execute("DELETE FROM scores")
|
|
||||||
conn.execute("DELETE FROM sessions")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.get("/api/scores")
|
@app.get("/api/scores")
|
||||||
async def api_scores():
|
async def api_scores():
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
sessions = conn.execute(
|
sessions = conn.execute(
|
||||||
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT 50"
|
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT 20"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
result = []
|
result = []
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
@@ -232,84 +300,94 @@ async def api_scores():
|
|||||||
result.append({
|
result.append({
|
||||||
"id": s["id"],
|
"id": s["id"],
|
||||||
"started_at": s["started_at"],
|
"started_at": s["started_at"],
|
||||||
"ended_at": s["ended_at"],
|
"rounds": s["rounds"],
|
||||||
"total_players": s["total_players"],
|
"players": [
|
||||||
"players": [{"player": p["player"], "score": p["score"], "rank": p["rank"], "dept": p["dept"] if "dept" in p.keys() else ""} for p in players]
|
{
|
||||||
|
"player": p["player"],
|
||||||
|
"score": p["score"],
|
||||||
|
"rank": p["rank"],
|
||||||
|
"dept": p["dept"] if "dept" in p.keys() else "",
|
||||||
|
}
|
||||||
|
for p in players
|
||||||
|
],
|
||||||
})
|
})
|
||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
@app.delete("/api/scores/clear")
|
||||||
|
async def clear_scores():
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("DELETE FROM scores")
|
||||||
|
conn.execute("DELETE FROM sessions")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
# ── PLAYER WEBSOCKET ──────────────────────────────────────────────────────────
|
# ── PLAYER WEBSOCKET ──────────────────────────────────────────────────────────
|
||||||
@app.websocket("/ws/player/{ws_id}")
|
@app.websocket("/ws/player/{pid}")
|
||||||
async def player_ws(ws: WebSocket, ws_id: str):
|
async def player_ws(ws: WebSocket, pid: str):
|
||||||
await mgr.connect_player(ws_id, ws)
|
await mgr.connect_player(pid, ws)
|
||||||
try:
|
try:
|
||||||
await mgr.send_to(ws_id, {"type": "phase", "phase": game.phase})
|
await mgr.send(pid, {"type": "phase", "phase": game.phase})
|
||||||
async for raw in ws.iter_text():
|
async for raw in ws.iter_text():
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
|
|
||||||
if msg["type"] == "join":
|
if msg["type"] == "join":
|
||||||
name = msg["name"].strip()[:40]
|
name = msg["name"].strip()[:30]
|
||||||
dept = msg.get("dept", "").strip()[:40]
|
dept = msg.get("dept", "").strip()[:40]
|
||||||
display = msg.get("display", name).strip()[:40]
|
display = msg.get("display", name).strip()[:30]
|
||||||
if not name:
|
if not name or len(game.players) >= 4:
|
||||||
|
await mgr.send(pid, {"type": "error", "msg": "Game full or invalid name"})
|
||||||
continue
|
continue
|
||||||
game.players[ws_id] = {"name": name, "score": 0, "answered": False, "dept": dept, "display": display}
|
game.players[pid] = {
|
||||||
await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept})
|
"name": name,
|
||||||
|
"display": display,
|
||||||
|
"dept": dept,
|
||||||
|
"score": 0,
|
||||||
|
"submitted": False,
|
||||||
|
}
|
||||||
|
await mgr.send(pid, {"type": "joined", "name": name, "display": display, "dept": dept})
|
||||||
await mgr.broadcast_hosts({
|
await mgr.broadcast_hosts({
|
||||||
"type": "lobby_update",
|
"type": "lobby_update",
|
||||||
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
|
"players": [p.get("display", p["name"]) for p in game.players.values()],
|
||||||
"count": len(game.players)
|
"count": len(game.players),
|
||||||
})
|
})
|
||||||
|
|
||||||
elif msg["type"] == "answer":
|
elif msg["type"] == "submit_path":
|
||||||
if game.phase != "question":
|
if game.phase != "playing":
|
||||||
continue
|
continue
|
||||||
player = game.players.get(ws_id)
|
player = game.players.get(pid)
|
||||||
if not player or player["answered"]:
|
if not player or player["submitted"]:
|
||||||
continue
|
continue
|
||||||
elapsed = time.time() - game.q_start_time
|
elapsed = time.time() - game.round_start
|
||||||
TIME_LIMIT = 15
|
path = msg.get("path", [])
|
||||||
if elapsed > TIME_LIMIT:
|
result = score_path(path, game.grid["edges"], elapsed, game.time_limit, game.grid["end"])
|
||||||
continue
|
player["score"] += result["points"]
|
||||||
|
player["submitted"] = True
|
||||||
player["answered"] = True
|
game.submissions[pid] = {
|
||||||
chosen = msg["choice"]
|
"name": player.get("display", player["name"]),
|
||||||
q_idx = game.question_order[game.current_q]
|
"path": path,
|
||||||
correct = QUESTIONS[q_idx]["correct"]
|
"points": result["points"],
|
||||||
is_correct = chosen == correct
|
"elapsed": round(elapsed, 2),
|
||||||
|
"reason": result["reason"],
|
||||||
points = 0
|
}
|
||||||
if is_correct:
|
await mgr.send(pid, {
|
||||||
speed_bonus = max(0, (TIME_LIMIT - elapsed) / TIME_LIMIT)
|
"type": "path_result",
|
||||||
points = int(200 + 800 * speed_bonus)
|
"points": result["points"],
|
||||||
player["score"] += points
|
|
||||||
|
|
||||||
game.answers_this_round.append({
|
|
||||||
"name": player["name"], "correct": is_correct, "elapsed": round(elapsed, 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
await mgr.send_to(ws_id, {
|
|
||||||
"type": "answer_result",
|
|
||||||
"correct": is_correct,
|
|
||||||
"points": points,
|
|
||||||
"total": player["score"],
|
"total": player["score"],
|
||||||
"correct_idx": correct
|
"reason": result["reason"],
|
||||||
|
"elapsed": round(elapsed, 2),
|
||||||
})
|
})
|
||||||
|
|
||||||
answered = sum(1 for p in game.players.values() if p["answered"])
|
|
||||||
await mgr.broadcast_hosts({
|
await mgr.broadcast_hosts({
|
||||||
"type": "answer_update",
|
"type": "submission_update",
|
||||||
"answered": answered,
|
"submitted": sum(1 for p in game.players.values() if p["submitted"]),
|
||||||
"total": len(game.players),
|
"total": len(game.players),
|
||||||
"details": game.answers_this_round
|
"submissions": list(game.submissions.values()),
|
||||||
})
|
})
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
mgr.disconnect(ws_id)
|
mgr.disconnect(pid)
|
||||||
await mgr.broadcast_hosts({
|
await mgr.broadcast_hosts({
|
||||||
"type": "lobby_update",
|
"type": "lobby_update",
|
||||||
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
|
"players": [p.get("display", p["name"]) for p in game.players.values()],
|
||||||
"count": len(game.players)
|
"count": len(game.players),
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── HOST WEBSOCKET ────────────────────────────────────────────────────────────
|
# ── HOST WEBSOCKET ────────────────────────────────────────────────────────────
|
||||||
@@ -319,63 +397,56 @@ async def host_ws(ws: WebSocket):
|
|||||||
try:
|
try:
|
||||||
await ws.send_json({
|
await ws.send_json({
|
||||||
"type": "lobby_update",
|
"type": "lobby_update",
|
||||||
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
|
"players": [p.get("display", p["name"]) for p in game.players.values()],
|
||||||
"count": len(game.players)
|
"count": len(game.players),
|
||||||
})
|
})
|
||||||
|
|
||||||
async for raw in ws.iter_text():
|
async for raw in ws.iter_text():
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
|
|
||||||
if msg["type"] == "start_game":
|
if msg["type"] == "start_round":
|
||||||
existing_players = dict(game.players) # preserve joined players
|
# accept config params from host on first round
|
||||||
game.reset()
|
if "total_rounds" in msg:
|
||||||
game.players = existing_players # restore them
|
game.total_rounds = int(msg["total_rounds"])
|
||||||
# Reset scores and state for a fresh game
|
if "time_limit" in msg:
|
||||||
|
game.time_limit = int(msg["time_limit"])
|
||||||
|
if "prize_threshold" in msg:
|
||||||
|
pt = int(msg["prize_threshold"])
|
||||||
|
game.prize_threshold = max(500, min(4500, pt)) # clamp to valid range
|
||||||
|
game.grid = generate_grid()
|
||||||
|
game.phase = "playing"
|
||||||
|
game.round_start = time.time()
|
||||||
|
game.current_round += 1
|
||||||
|
game.submissions = {}
|
||||||
for p in game.players.values():
|
for p in game.players.values():
|
||||||
p["score"] = 0
|
p["submitted"] = False
|
||||||
p["answered"] = False
|
|
||||||
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
|
|
||||||
|
|
||||||
elif msg["type"] == "next_question":
|
|
||||||
game.current_q += 1
|
|
||||||
if game.current_q >= len(QUESTIONS):
|
|
||||||
lb = get_leaderboard()
|
|
||||||
save_session(list(game.players.values())) # ← persist to SQLite
|
|
||||||
game.phase = "podium"
|
|
||||||
await mgr.broadcast_all({"type": "podium", "leaderboard": lb})
|
|
||||||
continue
|
|
||||||
|
|
||||||
game.phase = "question"
|
|
||||||
game.q_start_time = time.time()
|
|
||||||
game.answers_this_round = []
|
|
||||||
for p in game.players.values():
|
|
||||||
p["answered"] = False
|
|
||||||
|
|
||||||
q_idx = game.question_order[game.current_q]
|
|
||||||
q = QUESTIONS[q_idx]
|
|
||||||
await mgr.broadcast_all({
|
await mgr.broadcast_all({
|
||||||
"type": "question",
|
"type": "round_start",
|
||||||
"number": game.current_q + 1,
|
"round": game.current_round,
|
||||||
"total": len(QUESTIONS),
|
"total_rounds": game.total_rounds,
|
||||||
"q": q["q"],
|
"grid": game.grid,
|
||||||
"options": q["options"],
|
"time_limit": game.time_limit,
|
||||||
"time_limit": 15,
|
|
||||||
"correct_idx_hint": -1 # not revealed until show_results
|
|
||||||
})
|
})
|
||||||
|
|
||||||
elif msg["type"] == "show_results":
|
elif msg["type"] == "end_round":
|
||||||
game.phase = "results"
|
game.phase = "results"
|
||||||
q_idx = game.question_order[game.current_q]
|
lb = leaderboard()
|
||||||
q = QUESTIONS[q_idx]
|
dept_lb = dept_leaderboard()
|
||||||
|
save_session()
|
||||||
await mgr.broadcast_all({
|
await mgr.broadcast_all({
|
||||||
"type": "results",
|
"type": "round_results",
|
||||||
"correct_idx": q["correct"],
|
"round": game.current_round,
|
||||||
"fun_fact": q["fun_fact"],
|
"grid": game.grid,
|
||||||
"leaderboard": get_leaderboard()
|
"submissions": list(game.submissions.values()),
|
||||||
|
"leaderboard": lb,
|
||||||
|
"dept_leaderboard": dept_lb,
|
||||||
|
"is_final": game.current_round >= game.total_rounds,
|
||||||
|
"max_score": MAX_SCORE_PER_PLAYER,
|
||||||
|
"prize_threshold": PRIZE_THRESHOLD,
|
||||||
|
"max_prize_pts": MAX_PRIZE_POINTS,
|
||||||
})
|
})
|
||||||
|
|
||||||
elif msg["type"] == "reset":
|
elif msg["type"] == "reset":
|
||||||
# Kick all players back to join screen, then wipe state
|
|
||||||
await mgr.broadcast_players({"type": "kicked"})
|
await mgr.broadcast_players({"type": "kicked"})
|
||||||
game.reset()
|
game.reset()
|
||||||
await mgr.broadcast_hosts({"type": "lobby_update", "players": [], "count": 0})
|
await mgr.broadcast_hosts({"type": "lobby_update", "players": [], "count": 0})
|
||||||
|
|||||||
+172
-74
@@ -10,17 +10,17 @@
|
|||||||
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box;}
|
*{margin:0;padding:0;box-sizing:border-box;}
|
||||||
:root{
|
:root{
|
||||||
--bg:#070b14;
|
--bg:#ffffff;
|
||||||
--surface:#0d1220;
|
--surface:#f0f4f8;
|
||||||
--surface2:#131928;
|
--surface2:#e2e8f0;
|
||||||
--accent:#00ffe0;
|
--accent:#0077cc;
|
||||||
--accent2:#ff3e6c;
|
--accent2:#cc0033;
|
||||||
--warn:#ffb800;
|
--warn:#b35c00;
|
||||||
--slow:#ff6b00;
|
--slow:#cc4400;
|
||||||
--boost:#00ff88;
|
--boost:#00994d;
|
||||||
--blocked:#ff1744;
|
--blocked:#ff1744;
|
||||||
--text:#ddeeff;
|
--text:#0a0f1e;
|
||||||
--muted:#3a4a66;
|
--muted:#4a5568;
|
||||||
--mono:'Share Tech Mono',monospace;
|
--mono:'Share Tech Mono',monospace;
|
||||||
--sans:'Exo 2',sans-serif;
|
--sans:'Exo 2',sans-serif;
|
||||||
}
|
}
|
||||||
@@ -31,11 +31,52 @@ html,body{width:100%;height:100vh;background:var(--bg);color:var(--text);
|
|||||||
body::before{
|
body::before{
|
||||||
content:'';position:fixed;inset:0;
|
content:'';position:fixed;inset:0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(0,255,224,0.025) 1px,transparent 1px),
|
linear-gradient(rgba(0,119,204,0.08) 1px,transparent 1px),
|
||||||
linear-gradient(90deg,rgba(0,255,224,0.025) 1px,transparent 1px);
|
linear-gradient(90deg,rgba(0,119,204,0.08) 1px,transparent 1px);
|
||||||
background-size:40px 40px;pointer-events:none;
|
background-size:40px 40px;pointer-events:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PASSWORD GATE ── */
|
||||||
|
#auth-gate{
|
||||||
|
position:fixed;inset:0;z-index:9999;
|
||||||
|
background:var(--bg);
|
||||||
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
gap:1.5rem;
|
||||||
|
}
|
||||||
|
#auth-gate .logo{
|
||||||
|
font-family:var(--sans);font-weight:900;font-size:3rem;letter-spacing:-0.02em;
|
||||||
|
color:var(--accent);text-shadow:0 0 30px rgba(0,119,204,0.4);line-height:1;
|
||||||
|
}
|
||||||
|
#auth-gate .logo span{color:var(--accent2);}
|
||||||
|
#auth-gate .gate-label{
|
||||||
|
font-family:var(--mono);font-size:.7rem;letter-spacing:.15em;color:var(--muted);
|
||||||
|
}
|
||||||
|
#auth-gate .gate-field{
|
||||||
|
display:flex;flex-direction:column;align-items:center;gap:.75rem;width:100%;max-width:300px;
|
||||||
|
}
|
||||||
|
#auth-pass{
|
||||||
|
width:100%;padding:.9rem 1rem;text-align:center;
|
||||||
|
background:var(--surface);border:1px solid rgba(0,119,204,0.4);border-radius:3px;
|
||||||
|
color:var(--text);font-family:var(--mono);font-size:1.1rem;letter-spacing:.15em;
|
||||||
|
outline:none;transition:border-color .2s;
|
||||||
|
}
|
||||||
|
#auth-pass:focus{border-color:var(--accent);}
|
||||||
|
#auth-pass.shake{animation:shake .3s ease both;}
|
||||||
|
#auth-btn{
|
||||||
|
width:100%;padding:.85rem;
|
||||||
|
background:var(--accent);border:none;border-radius:3px;
|
||||||
|
color:#fff;font-family:var(--mono);font-size:.75rem;letter-spacing:.15em;
|
||||||
|
cursor:pointer;transition:opacity .2s;
|
||||||
|
}
|
||||||
|
#auth-btn:hover{opacity:.9;}
|
||||||
|
#auth-btn:active{transform:scale(.97);}
|
||||||
|
#auth-err{
|
||||||
|
font-family:var(--mono);font-size:.65rem;color:var(--accent2);
|
||||||
|
letter-spacing:.1em;opacity:0;transition:opacity .2s;
|
||||||
|
}
|
||||||
|
#auth-err.visible{opacity:1;}
|
||||||
|
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
|
||||||
|
|
||||||
.screen{display:none;height:100vh;}
|
.screen{display:none;height:100vh;}
|
||||||
.screen.active{display:flex;}
|
.screen.active{display:flex;}
|
||||||
|
|
||||||
@@ -43,14 +84,14 @@ body::before{
|
|||||||
#lobby-screen{flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center;}
|
#lobby-screen{flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center;}
|
||||||
|
|
||||||
.logo{font-family:var(--sans);font-weight:900;font-size:4rem;letter-spacing:-0.02em;line-height:1;
|
.logo{font-family:var(--sans);font-weight:900;font-size:4rem;letter-spacing:-0.02em;line-height:1;
|
||||||
color:var(--accent);text-shadow:0 0 40px rgba(0,255,224,.4);margin-bottom:.25rem;}
|
color:var(--accent);text-shadow:0 0 40px rgba(0,119,204,0.6);margin-bottom:.25rem;}
|
||||||
.logo span{color:var(--accent2);}
|
.logo span{color:var(--accent2);}
|
||||||
.tagline{font-family:var(--mono);font-size:.85rem;color:var(--text);letter-spacing:.15em;margin-bottom:2.5rem;}
|
.tagline{font-family:var(--mono);font-size:.85rem;color:var(--text);letter-spacing:.15em;margin-bottom:2.5rem;}
|
||||||
|
|
||||||
.lobby-row{display:flex;gap:2rem;align-items:flex-start;justify-content:center;width:100%;max-width:800px;margin-bottom:2rem;}
|
.lobby-row{display:flex;gap:2rem;align-items:flex-start;justify-content:center;width:100%;max-width:800px;margin-bottom:2rem;}
|
||||||
|
|
||||||
/* QR */
|
/* QR */
|
||||||
.qr-box{background:var(--surface);border:1px solid rgba(0,255,224,.1);border-radius:4px;
|
.qr-box{background:var(--surface);border:1px solid rgba(0,119,204,0.25);border-radius:4px;
|
||||||
padding:1.25rem;text-align:center;min-width:200px;}
|
padding:1.25rem;text-align:center;min-width:200px;}
|
||||||
.box-label{font-family:var(--mono);font-size:.75rem;color:var(--text);letter-spacing:.15em;margin-bottom:.75rem;}
|
.box-label{font-family:var(--mono);font-size:.75rem;color:var(--text);letter-spacing:.15em;margin-bottom:.75rem;}
|
||||||
#qrcode{display:flex;justify-content:center;margin-bottom:.75rem;}
|
#qrcode{display:flex;justify-content:center;margin-bottom:.75rem;}
|
||||||
@@ -58,21 +99,21 @@ body::before{
|
|||||||
.qr-url{font-family:var(--mono);font-size:.85rem;color:var(--accent);}
|
.qr-url{font-family:var(--mono);font-size:.85rem;color:var(--accent);}
|
||||||
|
|
||||||
/* Players box */
|
/* Players box */
|
||||||
.players-box{flex:1;background:var(--surface);border:1px solid rgba(0,255,224,.1);
|
.players-box{flex:1;background:var(--surface);border:1px solid rgba(0,119,204,0.25);
|
||||||
border-radius:4px;padding:1.25rem;}
|
border-radius:4px;padding:1.25rem;}
|
||||||
.player-count{font-family:var(--sans);font-weight:900;font-size:4.5rem;
|
.player-count{font-family:var(--sans);font-weight:900;font-size:4.5rem;
|
||||||
letter-spacing:-0.04em;color:var(--accent);line-height:1;margin-bottom:.15rem;}
|
letter-spacing:-0.04em;color:var(--accent);line-height:1;margin-bottom:.15rem;}
|
||||||
.player-count-sub{font-family:var(--mono);font-size:.8rem;color:var(--text);margin-bottom:1rem;}
|
.player-count-sub{font-family:var(--mono);font-size:.8rem;color:var(--text);margin-bottom:1rem;}
|
||||||
.p-chips{display:flex;flex-wrap:wrap;gap:.4rem;max-height:100px;overflow-y:auto;}
|
.p-chips{display:flex;flex-wrap:wrap;gap:.4rem;max-height:100px;overflow-y:auto;}
|
||||||
.p-chip{padding:.4rem .9rem;border-radius:2px;
|
.p-chip{padding:.4rem .9rem;border-radius:2px;
|
||||||
background:var(--surface2);border:1px solid rgba(0,255,224,.25);
|
background:var(--surface2);border:1px solid rgba(0,119,204,0.4);
|
||||||
font-family:var(--mono);font-size:.85rem;color:var(--accent);}
|
font-family:var(--mono);font-size:.85rem;color:var(--accent);}
|
||||||
|
|
||||||
/* rounds config */
|
/* rounds config */
|
||||||
.rounds-cfg{display:flex;align-items:center;gap:.75rem;margin-top:1.5rem;
|
.rounds-cfg{display:flex;align-items:center;gap:.75rem;margin-top:1.5rem;
|
||||||
font-family:var(--mono);font-size:.8rem;color:var(--text);}
|
font-family:var(--mono);font-size:.8rem;color:var(--text);}
|
||||||
.rounds-cfg select{
|
.rounds-cfg select{
|
||||||
background:var(--surface2);border:1px solid rgba(0,255,224,.25);
|
background:var(--surface2);border:1px solid rgba(0,119,204,0.4);
|
||||||
color:var(--accent);font-family:var(--mono);font-size:.8rem;
|
color:var(--accent);font-family:var(--mono);font-size:.8rem;
|
||||||
padding:.4rem .7rem;border-radius:2px;outline:none;cursor:pointer;
|
padding:.4rem .7rem;border-radius:2px;outline:none;cursor:pointer;
|
||||||
}
|
}
|
||||||
@@ -93,7 +134,7 @@ body::before{
|
|||||||
|
|
||||||
.game-left{
|
.game-left{
|
||||||
flex:1;display:flex;flex-direction:column;
|
flex:1;display:flex;flex-direction:column;
|
||||||
padding:1.25rem;border-right:1px solid rgba(0,255,224,.08);
|
padding:1.25rem;border-right:1px solid rgba(0,119,204,0.15);
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,19 +148,17 @@ body::before{
|
|||||||
.objective-bar{
|
.objective-bar{
|
||||||
display:flex;align-items:center;gap:.75rem;
|
display:flex;align-items:center;gap:.75rem;
|
||||||
padding:.75rem 1.25rem;
|
padding:.75rem 1.25rem;
|
||||||
background:var(--surface);border:1px solid rgba(0,255,224,.2);border-radius:3px;
|
background:var(--surface);border:1px solid rgba(0,119,204,0.35);border-radius:3px;
|
||||||
margin-bottom:1rem;font-family:var(--mono);font-size:.85rem;color:var(--text);
|
margin-bottom:1rem;font-family:var(--mono);font-size:.85rem;color:var(--text);
|
||||||
}
|
}
|
||||||
.obj-node{display:inline-flex;align-items:center;justify-content:center;
|
.obj-node{display:inline-flex;align-items:center;justify-content:center;
|
||||||
width:40px;height:40px;border-radius:50%;font-weight:800;font-size:1.1rem;}
|
width:40px;height:40px;border-radius:50%;font-weight:800;font-size:1.1rem;}
|
||||||
.obj-start{background:rgba(0,255,224,.15);border:2px solid var(--accent);color:var(--accent);}
|
.obj-start{background:rgba(0,119,204,0.3);border:2px solid var(--accent);color:var(--accent);}
|
||||||
.obj-end{background:rgba(255,62,108,.15);border:2px solid var(--accent2);color:var(--accent2);}
|
.obj-end{background:rgba(255,62,108,.15);border:2px solid var(--accent2);color:var(--accent2);}
|
||||||
|
|
||||||
/* host grid */
|
|
||||||
.grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
|
.grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
|
||||||
#host-canvas{display:block;border-radius:4px;}
|
#host-canvas{display:block;border-radius:4px;}
|
||||||
|
|
||||||
/* legend */
|
|
||||||
.legend{display:flex;gap:1.25rem;margin-top:.75rem;flex-wrap:wrap;}
|
.legend{display:flex;gap:1.25rem;margin-top:.75rem;flex-wrap:wrap;}
|
||||||
.legend-item{display:flex;align-items:center;gap:.4rem;
|
.legend-item{display:flex;align-items:center;gap:.4rem;
|
||||||
font-family:var(--mono);font-size:.75rem;color:var(--text);}
|
font-family:var(--mono);font-size:.75rem;color:var(--text);}
|
||||||
@@ -130,8 +169,7 @@ body::before{
|
|||||||
padding:1.25rem;gap:1rem;overflow-y:auto;
|
padding:1.25rem;gap:1rem;overflow-y:auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* submission panel */
|
.panel{background:var(--surface);border:1px solid rgba(0,119,204,0.3);
|
||||||
.panel{background:var(--surface);border:1px solid rgba(0,255,224,.15);
|
|
||||||
border-radius:4px;padding:1rem;}
|
border-radius:4px;padding:1rem;}
|
||||||
.panel-title{font-family:var(--mono);font-size:.7rem;color:var(--text);
|
.panel-title{font-family:var(--mono);font-size:.7rem;color:var(--text);
|
||||||
letter-spacing:.12em;margin-bottom:.75rem;}
|
letter-spacing:.12em;margin-bottom:.75rem;}
|
||||||
@@ -141,21 +179,19 @@ body::before{
|
|||||||
.ans-bar-wrap{height:4px;background:var(--surface2);border-radius:2px;}
|
.ans-bar-wrap{height:4px;background:var(--surface2);border-radius:2px;}
|
||||||
.ans-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .3s;}
|
.ans-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .3s;}
|
||||||
|
|
||||||
/* submission list */
|
|
||||||
.sub-list{display:flex;flex-direction:column;gap:.4rem;margin-top:.75rem;}
|
.sub-list{display:flex;flex-direction:column;gap:.4rem;margin-top:.75rem;}
|
||||||
.sub-item{display:flex;align-items:center;gap:.5rem;
|
.sub-item{display:flex;align-items:center;gap:.5rem;
|
||||||
padding:.6rem .75rem;border-radius:2px;
|
padding:.6rem .75rem;border-radius:2px;
|
||||||
background:var(--surface2);border:1px solid rgba(0,255,224,.1);
|
background:var(--surface2);border:1px solid rgba(0,119,204,0.25);
|
||||||
font-family:var(--mono);font-size:.8rem;}
|
font-family:var(--mono);font-size:.8rem;}
|
||||||
.sub-name{flex:1;color:var(--text);}
|
.sub-name{flex:1;color:var(--text);}
|
||||||
.sub-pts{color:var(--accent);font-size:.8rem;}
|
.sub-pts{color:var(--accent);font-size:.8rem;}
|
||||||
.sub-pts.fail{color:var(--accent2);}
|
.sub-pts.fail{color:var(--accent2);}
|
||||||
.sub-time{color:var(--muted);font-size:.7rem;}
|
.sub-time{color:var(--muted);font-size:.7rem;}
|
||||||
|
|
||||||
/* leaderboard */
|
|
||||||
.lb-item{display:flex;align-items:center;gap:.5rem;
|
.lb-item{display:flex;align-items:center;gap:.5rem;
|
||||||
padding:.55rem .75rem;margin-bottom:.35rem;border-radius:2px;
|
padding:.55rem .75rem;margin-bottom:.35rem;border-radius:2px;
|
||||||
background:var(--surface2);border:1px solid rgba(0,255,224,.1);}
|
background:var(--surface2);border:1px solid rgba(0,119,204,0.25);}
|
||||||
.lb-rank{font-family:var(--mono);font-size:.7rem;color:var(--text);min-width:22px;}
|
.lb-rank{font-family:var(--mono);font-size:.7rem;color:var(--text);min-width:22px;}
|
||||||
.lb-name{flex:1;font-size:1rem;font-weight:700;}
|
.lb-name{flex:1;font-size:1rem;font-weight:700;}
|
||||||
.lb-score{font-family:var(--mono);font-size:.7rem;color:var(--accent);}
|
.lb-score{font-family:var(--mono);font-size:.7rem;color:var(--accent);}
|
||||||
@@ -167,7 +203,7 @@ body::before{
|
|||||||
|
|
||||||
.results-top{
|
.results-top{
|
||||||
display:flex;justify-content:space-between;align-items:center;
|
display:flex;justify-content:space-between;align-items:center;
|
||||||
padding:1rem 1.5rem;border-bottom:1px solid rgba(0,255,224,.08);
|
padding:1rem 1.5rem;border-bottom:1px solid rgba(0,119,204,0.15);
|
||||||
}
|
}
|
||||||
.results-title{font-family:var(--sans);font-weight:900;font-size:2rem;
|
.results-title{font-family:var(--sans);font-weight:900;font-size:2rem;
|
||||||
letter-spacing:-0.02em;color:var(--accent);}
|
letter-spacing:-0.02em;color:var(--accent);}
|
||||||
@@ -175,19 +211,17 @@ body::before{
|
|||||||
|
|
||||||
.results-body{display:flex;flex:1;gap:0;overflow:hidden;}
|
.results-body{display:flex;flex:1;gap:0;overflow:hidden;}
|
||||||
|
|
||||||
/* results grid */
|
|
||||||
.results-left{flex:1;display:flex;flex-direction:column;
|
.results-left{flex:1;display:flex;flex-direction:column;
|
||||||
padding:1.25rem;border-right:1px solid rgba(0,255,224,.08);}
|
padding:1.25rem;border-right:1px solid rgba(0,119,204,0.15);}
|
||||||
.results-grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
|
.results-grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
|
||||||
#results-canvas{display:block;border-radius:4px;}
|
#results-canvas{display:block;border-radius:4px;}
|
||||||
|
|
||||||
/* results right */
|
|
||||||
.results-right{width:300px;padding:1.25rem;overflow-y:auto;display:flex;flex-direction:column;gap:1rem;}
|
.results-right{width:300px;padding:1.25rem;overflow-y:auto;display:flex;flex-direction:column;gap:1rem;}
|
||||||
|
|
||||||
.path-cards{display:flex;flex-direction:column;gap:.5rem;}
|
.path-cards{display:flex;flex-direction:column;gap:.5rem;}
|
||||||
.path-card{
|
.path-card{
|
||||||
padding:.7rem .9rem;border-radius:3px;
|
padding:.7rem .9rem;border-radius:3px;
|
||||||
background:var(--surface);border:1px solid rgba(0,255,224,.08);
|
background:var(--surface);border:1px solid rgba(0,119,204,0.15);
|
||||||
}
|
}
|
||||||
.path-card-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:.3rem;}
|
.path-card-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:.3rem;}
|
||||||
.path-player{font-weight:700;font-size:1rem;}
|
.path-player{font-weight:700;font-size:1rem;}
|
||||||
@@ -199,7 +233,7 @@ body::before{
|
|||||||
.path-node{
|
.path-node{
|
||||||
display:inline-flex;align-items:center;justify-content:center;
|
display:inline-flex;align-items:center;justify-content:center;
|
||||||
width:20px;height:20px;border-radius:50%;
|
width:20px;height:20px;border-radius:50%;
|
||||||
background:var(--surface2);border:1px solid rgba(0,255,224,.15);
|
background:var(--surface2);border:1px solid rgba(0,119,204,0.3);
|
||||||
font-family:var(--mono);font-size:.5rem;color:var(--accent);
|
font-family:var(--mono);font-size:.5rem;color:var(--accent);
|
||||||
}
|
}
|
||||||
.path-arrow{font-size:.5rem;color:var(--muted);align-self:center;}
|
.path-arrow{font-size:.5rem;color:var(--muted);align-self:center;}
|
||||||
@@ -229,7 +263,7 @@ body::before{
|
|||||||
.rest-list{display:flex;flex-direction:column;gap:.4rem;max-width:420px;width:100%;}
|
.rest-list{display:flex;flex-direction:column;gap:.4rem;max-width:420px;width:100%;}
|
||||||
.rest-item{display:flex;align-items:center;gap:.75rem;
|
.rest-item{display:flex;align-items:center;gap:.75rem;
|
||||||
padding:.65rem 1rem;border-radius:3px;
|
padding:.65rem 1rem;border-radius:3px;
|
||||||
background:var(--surface);border:1px solid rgba(0,255,224,.12);font-size:1rem;}
|
background:var(--surface);border:1px solid rgba(0,119,204,0.25);font-size:1rem;}
|
||||||
.rest-rank{font-family:var(--mono);font-size:.7rem;color:var(--text);min-width:24px;}
|
.rest-rank{font-family:var(--mono);font-size:.7rem;color:var(--text);min-width:24px;}
|
||||||
.rest-name{flex:1;font-weight:700;}
|
.rest-name{flex:1;font-weight:700;}
|
||||||
.rest-score{font-family:var(--mono);font-size:.7rem;color:var(--accent);}
|
.rest-score{font-family:var(--mono);font-size:.7rem;color:var(--accent);}
|
||||||
@@ -247,8 +281,20 @@ body::before{
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- ── AUTH GATE ── -->
|
||||||
|
<div id="auth-gate">
|
||||||
|
<div class="logo">ROUTE<span>RUSH</span></div>
|
||||||
|
<div class="gate-label">// HOST ACCESS REQUIRED</div>
|
||||||
|
<div class="gate-field">
|
||||||
|
<input type="password" id="auth-pass" placeholder="PASSWORD" autocomplete="off" autocorrect="off"
|
||||||
|
onkeydown="if(event.key==='Enter')checkAuth()">
|
||||||
|
<button id="auth-btn" onclick="checkAuth()">▶ ENTER HOST PANEL</button>
|
||||||
|
<div id="auth-err">INCORRECT PASSWORD</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- LOBBY -->
|
<!-- LOBBY -->
|
||||||
<div class="screen active" id="lobby-screen">
|
<div class="screen" id="lobby-screen">
|
||||||
<div class="logo">ROUTE<span>RUSH</span></div>
|
<div class="logo">ROUTE<span>RUSH</span></div>
|
||||||
<div class="tagline">// HOST CONTROL PANEL</div>
|
<div class="tagline">// HOST CONTROL PANEL</div>
|
||||||
<div class="lobby-row">
|
<div class="lobby-row">
|
||||||
@@ -260,7 +306,7 @@ body::before{
|
|||||||
<div class="players-box">
|
<div class="players-box">
|
||||||
<div class="box-label">PLAYERS CONNECTED</div>
|
<div class="box-label">PLAYERS CONNECTED</div>
|
||||||
<div class="player-count" id="player-count">0</div>
|
<div class="player-count" id="player-count">0</div>
|
||||||
<div class="player-count-sub">/ 4 MAX</div>
|
<div class="player-count-sub">/ 16 MAX</div>
|
||||||
<div class="p-chips" id="p-chips"></div>
|
<div class="p-chips" id="p-chips"></div>
|
||||||
<div class="rounds-cfg">
|
<div class="rounds-cfg">
|
||||||
<span>ROUNDS:</span>
|
<span>ROUNDS:</span>
|
||||||
@@ -275,11 +321,18 @@ body::before{
|
|||||||
<option value="45">45s</option>
|
<option value="45">45s</option>
|
||||||
<option value="60">60s</option>
|
<option value="60">60s</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span>PRIZE AVG:</span>
|
||||||
|
<input id="prize-threshold" type="number" value="3000" min="500" max="4500" step="100"
|
||||||
|
style="width:72px;background:var(--surface2);border:1px solid rgba(0,119,204,0.4);
|
||||||
|
color:var(--accent);font-family:var(--mono);font-size:.8rem;
|
||||||
|
padding:.4rem .5rem;border-radius:2px;outline:none;text-align:center;">
|
||||||
|
<span style="font-size:.65rem;color:var(--muted)">= 25 PTS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="hbtn hbtn-primary" id="start-btn" disabled onclick="startRound()">▶ START GAME</button>
|
<button class="hbtn hbtn-primary" id="start-btn" disabled onclick="startRound()">▶ START GAME</button>
|
||||||
|
<button class="hbtn hbtn-danger" onclick="clearLobby()">✕ CLEAR LOBBY</button>
|
||||||
<button class="hbtn hbtn-danger" onclick="clearScores()">🗑 CLEAR SCORES</button>
|
<button class="hbtn hbtn-danger" onclick="clearScores()">🗑 CLEAR SCORES</button>
|
||||||
<button class="hbtn hbtn-secondary" onclick="location.href='scores'">📊 HISTORY</button>
|
<button class="hbtn hbtn-secondary" onclick="location.href='scores'">📊 HISTORY</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,24 +435,50 @@ body::before{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ── AUTH GATE ─────────────────────────────────────────────────────────────────
|
||||||
|
const HOST_PASS = 'ni2026';
|
||||||
|
|
||||||
|
function checkAuth() {
|
||||||
|
const val = document.getElementById('auth-pass').value;
|
||||||
|
if (val === HOST_PASS) {
|
||||||
|
document.getElementById('auth-gate').style.display = 'none';
|
||||||
|
initHost();
|
||||||
|
} else {
|
||||||
|
const inp = document.getElementById('auth-pass');
|
||||||
|
const err = document.getElementById('auth-err');
|
||||||
|
inp.classList.remove('shake');
|
||||||
|
void inp.offsetWidth; // reflow to restart animation
|
||||||
|
inp.classList.add('shake');
|
||||||
|
err.classList.add('visible');
|
||||||
|
inp.value = '';
|
||||||
|
inp.focus();
|
||||||
|
setTimeout(() => err.classList.remove('visible'), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||||
let ws, timerInterval;
|
let ws, timerInterval;
|
||||||
let currentGrid = null, currentRound = 0, currentTotal = 0, totalPlayers = 0;
|
let currentGrid = null, currentRound = 0, currentTotal = 0, totalPlayers = 0;
|
||||||
let lastSubmissions = [], lastLeaderboard = [];
|
let lastSubmissions = [], lastLeaderboard = [];
|
||||||
|
|
||||||
// ── QR ────────────────────────────────────────────────────────────────────────
|
// ── INIT (called after auth) ──────────────────────────────────────────────────
|
||||||
const baseUrl = `${location.protocol}//${location.hostname}${location.port?':'+location.port:''}${location.pathname.replace(/\/host\/?$/,'')}`;
|
function initHost() {
|
||||||
document.getElementById('join-url').textContent = baseUrl;
|
// QR Code
|
||||||
new QRCode(document.getElementById('qrcode'), {
|
const baseUrl = `${location.protocol}//${location.hostname}${location.port?':'+location.port:''}${location.pathname.replace(/\/host\/?$/,'')}`;
|
||||||
text: baseUrl,
|
document.getElementById('join-url').textContent = baseUrl;
|
||||||
width: 200, height: 200,
|
new QRCode(document.getElementById('qrcode'), {
|
||||||
colorDark: '#00ffe0', colorLight: '#0d1220',
|
text: baseUrl,
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
width: 200, height: 200,
|
||||||
});
|
colorDark: '#0077cc', colorLight: '#ffffff',
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
});
|
||||||
|
connectWS();
|
||||||
|
show('lobby-screen');
|
||||||
|
}
|
||||||
|
|
||||||
// ── CANVAS ────────────────────────────────────────────────────────────────────
|
// ── CANVAS ────────────────────────────────────────────────────────────────────
|
||||||
const GRID = 4;
|
const GRID = 4;
|
||||||
const MOD_COLOR = {normal:'#00ffe0', slow:'#ff6b00', boost:'#00ff88', blocked:'#ff1744'};
|
const MOD_COLOR = {normal:'#0077cc', slow:'#ff6b00', boost:'#00ff88', blocked:'#ff1744'};
|
||||||
|
|
||||||
function setupCanvas(canvasEl, wrapEl) {
|
function setupCanvas(canvasEl, wrapEl) {
|
||||||
const size = Math.min(wrapEl.clientWidth, wrapEl.clientHeight, 480);
|
const size = Math.min(wrapEl.clientWidth, wrapEl.clientHeight, 480);
|
||||||
@@ -413,7 +492,7 @@ function drawGridOnCanvas(canvasEl, grid, highlightPaths=[]) {
|
|||||||
const size = canvasEl.width;
|
const size = canvasEl.width;
|
||||||
const ctx = canvasEl.getContext('2d');
|
const ctx = canvasEl.getContext('2d');
|
||||||
const CELL = size / GRID;
|
const CELL = size / GRID;
|
||||||
const NODE_R = CELL * 0.2;
|
const NODE_R = CELL * 0.14;
|
||||||
const OX = CELL / 2, OY = CELL / 2;
|
const OX = CELL / 2, OY = CELL / 2;
|
||||||
|
|
||||||
function nodePos(id) {
|
function nodePos(id) {
|
||||||
@@ -422,41 +501,61 @@ function drawGridOnCanvas(canvasEl, grid, highlightPaths=[]) {
|
|||||||
|
|
||||||
ctx.clearRect(0, 0, size, size);
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
// edges
|
|
||||||
grid.edges.forEach(e => {
|
grid.edges.forEach(e => {
|
||||||
const a = nodePos(e.a), b = nodePos(e.b);
|
const a = nodePos(e.a), b = nodePos(e.b);
|
||||||
const color = MOD_COLOR[e.modifier] || '#00ffe0';
|
const color = MOD_COLOR[e.modifier] || '#0077cc';
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(a.x, a.y);
|
ctx.moveTo(a.x, a.y);
|
||||||
ctx.lineTo(b.x, b.y);
|
ctx.lineTo(b.x, b.y);
|
||||||
if (e.modifier === 'blocked') {
|
if (e.modifier === 'blocked') {
|
||||||
ctx.setLineDash([4,4]);
|
ctx.setLineDash([6,5]);
|
||||||
ctx.strokeStyle = 'rgba(255,23,68,0.25)';
|
ctx.strokeStyle = 'rgba(255,23,68,0.55)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 2.5;
|
||||||
|
} else if (e.modifier !== 'normal') {
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.strokeStyle = `${color}bb`;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
} else {
|
} else {
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.strokeStyle = `${color}2a`;
|
ctx.strokeStyle = `${color}66`;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 2;
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
// modifier label
|
|
||||||
if (e.modifier !== 'normal') {
|
if (e.modifier !== 'normal') {
|
||||||
const mx = (a.x+b.x)/2, my = (a.y+b.y)/2;
|
const mx = (a.x+b.x)/2, my = (a.y+b.y)/2;
|
||||||
const label = e.modifier==='slow'?'2×':e.modifier==='boost'?'½×':'✕';
|
const label = e.modifier==='slow'?'x2':e.modifier==='boost'?'x½':'✕';
|
||||||
ctx.fillStyle='rgba(7,11,20,0.9)';
|
const fontSize = Math.max(14, CELL * 0.22);
|
||||||
ctx.fillRect(mx-12,my-9,24,18);
|
ctx.font = `bold ${fontSize}px 'Share Tech Mono',monospace`;
|
||||||
ctx.fillStyle=color;
|
const tw = ctx.measureText(label).width;
|
||||||
ctx.font=`bold ${Math.max(12,CELL*.16)}px 'Share Tech Mono',monospace`;
|
const padX = 8, padY = 5;
|
||||||
ctx.textAlign='center';ctx.textBaseline='middle';
|
const bw = tw + padX*2, bh = fontSize + padY*2;
|
||||||
ctx.fillText(label,mx,my);
|
const bx = mx - bw/2, by = my - bh/2;
|
||||||
|
const r = 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bx+r,by); ctx.lineTo(bx+bw-r,by);
|
||||||
|
ctx.quadraticCurveTo(bx+bw,by,bx+bw,by+r);
|
||||||
|
ctx.lineTo(bx+bw,by+bh-r);
|
||||||
|
ctx.quadraticCurveTo(bx+bw,by+bh,bx+bw-r,by+bh);
|
||||||
|
ctx.lineTo(bx+r,by+bh);
|
||||||
|
ctx.quadraticCurveTo(bx,by+bh,bx,by+bh-r);
|
||||||
|
ctx.lineTo(bx,by+r);
|
||||||
|
ctx.quadraticCurveTo(bx,by,bx+r,by);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.textAlign='center'; ctx.textBaseline='middle';
|
||||||
|
ctx.fillText(label, mx, my);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// highlighted paths (player routes)
|
const pathColors = ['#0077cc','#cc0033','#ffb800','#cc44ff'];
|
||||||
const pathColors = ['#00ffe0','#ff3e6c','#ffb800','#cc44ff'];
|
|
||||||
highlightPaths.forEach((p, pi) => {
|
highlightPaths.forEach((p, pi) => {
|
||||||
if (!p || p.length < 2) return;
|
if (!p || p.length < 2) return;
|
||||||
const color = pathColors[pi % pathColors.length];
|
const color = pathColors[pi % pathColors.length];
|
||||||
@@ -478,7 +577,6 @@ function drawGridOnCanvas(canvasEl, grid, highlightPaths=[]) {
|
|||||||
ctx.lineJoin = 'miter';
|
ctx.lineJoin = 'miter';
|
||||||
});
|
});
|
||||||
|
|
||||||
// nodes
|
|
||||||
grid.nodes.forEach(n => {
|
grid.nodes.forEach(n => {
|
||||||
const {x,y} = nodePos(n.id);
|
const {x,y} = nodePos(n.id);
|
||||||
const isStart = n.id === grid.start;
|
const isStart = n.id === grid.start;
|
||||||
@@ -597,7 +695,8 @@ function startTimer(secs) {
|
|||||||
function startRound() {
|
function startRound() {
|
||||||
const rounds = parseInt(document.getElementById('rounds-select').value);
|
const rounds = parseInt(document.getElementById('rounds-select').value);
|
||||||
const timeLimit = parseInt(document.getElementById('time-select').value);
|
const timeLimit = parseInt(document.getElementById('time-select').value);
|
||||||
ws.send(JSON.stringify({type: 'start_round', total_rounds: rounds, time_limit: timeLimit}));
|
const prizeThreshold = parseInt(document.getElementById('prize-threshold').value) || 3000;
|
||||||
|
ws.send(JSON.stringify({type: 'start_round', total_rounds: rounds, time_limit: timeLimit, prize_threshold: prizeThreshold}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function endRound() {
|
function endRound() {
|
||||||
@@ -613,6 +712,10 @@ function nextRound() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearLobby() {
|
||||||
|
ws.send(JSON.stringify({type: "reset"}));
|
||||||
|
}
|
||||||
|
|
||||||
function resetGame() {
|
function resetGame() {
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
ws.send(JSON.stringify({type: 'reset'}));
|
ws.send(JSON.stringify({type: 'reset'}));
|
||||||
@@ -631,15 +734,13 @@ function showResults(msg) {
|
|||||||
document.getElementById('res-round-label').textContent = `ROUND ${msg.round} / ${currentTotal}`;
|
document.getElementById('res-round-label').textContent = `ROUND ${msg.round} / ${currentTotal}`;
|
||||||
document.getElementById('next-btn').textContent = msg.is_final ? 'PODIUM →' : 'NEXT ROUND →';
|
document.getElementById('next-btn').textContent = msg.is_final ? 'PODIUM →' : 'NEXT ROUND →';
|
||||||
|
|
||||||
// Draw grid with all paths
|
|
||||||
const rc = document.getElementById('results-canvas');
|
const rc = document.getElementById('results-canvas');
|
||||||
const wrap = document.querySelector('.results-grid-wrap');
|
const wrap = document.querySelector('.results-grid-wrap');
|
||||||
setupCanvas(rc, wrap);
|
setupCanvas(rc, wrap);
|
||||||
const paths = msg.submissions.map(s => s.path);
|
const paths = msg.submissions.map(s => s.path);
|
||||||
drawGridOnCanvas(rc, msg.grid, paths);
|
drawGridOnCanvas(rc, msg.grid, paths);
|
||||||
|
|
||||||
// Path cards
|
const pathColors = ['#0077cc','#cc0033','#ffb800','#cc44ff'];
|
||||||
const pathColors = ['#00ffe0','#ff3e6c','#ffb800','#cc44ff'];
|
|
||||||
document.getElementById('path-cards').innerHTML = msg.submissions.map((s, i) => {
|
document.getElementById('path-cards').innerHTML = msg.submissions.map((s, i) => {
|
||||||
const color = pathColors[i % pathColors.length];
|
const color = pathColors[i % pathColors.length];
|
||||||
const nodes = (s.path||[]).map(n =>
|
const nodes = (s.path||[]).map(n =>
|
||||||
@@ -655,7 +756,6 @@ function showResults(msg) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Leaderboard
|
|
||||||
const medals = ['🥇','🥈','🥉'];
|
const medals = ['🥇','🥈','🥉'];
|
||||||
document.getElementById('res-lb').innerHTML = msg.leaderboard.map((p,i) =>
|
document.getElementById('res-lb').innerHTML = msg.leaderboard.map((p,i) =>
|
||||||
`<div class="lb-item">
|
`<div class="lb-item">
|
||||||
@@ -693,7 +793,7 @@ function showPodium(lb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confetti() {
|
function confetti() {
|
||||||
const colors = ['#00ffe0','#ff3e6c','#ffb800','#00ff88','#cc44ff'];
|
const colors = ['#0077cc','#cc0033','#ffb800','#00ff88','#cc44ff'];
|
||||||
for (let i = 0; i < 70; i++) {
|
for (let i = 0; i < 70; i++) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
@@ -704,8 +804,6 @@ function confetti() {
|
|||||||
}, i * 45);
|
}, i * 45);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connectWS();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+762
-342
File diff suppressed because it is too large
Load Diff
+253
-74
@@ -9,15 +9,15 @@
|
|||||||
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box;}
|
*{margin:0;padding:0;box-sizing:border-box;}
|
||||||
:root{
|
:root{
|
||||||
--bg:#070b14;
|
--bg:#ffffff;
|
||||||
--surface:#0d1220;
|
--surface:#f0f4f8;
|
||||||
--surface2:#131928;
|
--surface2:#e2e8f0;
|
||||||
--accent:#00ffe0;
|
--accent:#0077cc;
|
||||||
--accent2:#ff3e6c;
|
--accent2:#cc0033;
|
||||||
--warn:#ffb800;
|
--warn:#ffb800;
|
||||||
--boost:#00ff88;
|
--boost:#00ff88;
|
||||||
--text:#ddeeff;
|
--text:#0a0f1e;
|
||||||
--muted:#3a4a66;
|
--muted:#4a5568;
|
||||||
--mono:'Share Tech Mono',monospace;
|
--mono:'Share Tech Mono',monospace;
|
||||||
--sans:'Exo 2',sans-serif;
|
--sans:'Exo 2',sans-serif;
|
||||||
}
|
}
|
||||||
@@ -27,20 +27,20 @@ html,body{min-height:100vh;background:var(--bg);color:var(--text);font-family:va
|
|||||||
body::before{
|
body::before{
|
||||||
content:'';position:fixed;inset:0;
|
content:'';position:fixed;inset:0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(0,255,224,0.025) 1px,transparent 1px),
|
linear-gradient(rgba(0,119,204,0.06) 1px,transparent 1px),
|
||||||
linear-gradient(90deg,rgba(0,255,224,0.025) 1px,transparent 1px);
|
linear-gradient(90deg,rgba(0,119,204,0.06) 1px,transparent 1px);
|
||||||
background-size:40px 40px;pointer-events:none;z-index:0;
|
background-size:40px 40px;pointer-events:none;z-index:0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header{
|
header{
|
||||||
position:relative;z-index:1;
|
position:relative;z-index:1;
|
||||||
padding:1.75rem 2rem 1.25rem;
|
padding:1.75rem 2rem 1.25rem;
|
||||||
border-bottom:1px solid rgba(0,255,224,0.08);
|
border-bottom:1px solid rgba(0,119,204,0.12);
|
||||||
display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:1rem;
|
display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:1rem;
|
||||||
}
|
}
|
||||||
.header-left h1{
|
.header-left h1{
|
||||||
font-family:var(--sans);font-weight:900;font-size:2rem;letter-spacing:-0.03em;
|
font-family:var(--sans);font-weight:900;font-size:2rem;letter-spacing:-0.03em;
|
||||||
color:var(--accent);text-shadow:0 0 20px rgba(0,255,224,.3);line-height:1;
|
color:var(--accent);text-shadow:0 0 20px rgba(0,119,204,0.4);line-height:1;
|
||||||
}
|
}
|
||||||
.header-left h1 span{color:var(--accent2);}
|
.header-left h1 span{color:var(--accent2);}
|
||||||
.header-left p{font-family:var(--mono);font-size:.55rem;color:var(--muted);letter-spacing:.12em;margin-top:.3rem;}
|
.header-left p{font-family:var(--mono);font-size:.55rem;color:var(--muted);letter-spacing:.12em;margin-top:.3rem;}
|
||||||
@@ -48,60 +48,108 @@ header{
|
|||||||
.nav-links a{
|
.nav-links a{
|
||||||
font-family:var(--mono);font-size:.6rem;color:var(--muted);
|
font-family:var(--mono);font-size:.6rem;color:var(--muted);
|
||||||
text-decoration:none;padding:.35rem .8rem;
|
text-decoration:none;padding:.35rem .8rem;
|
||||||
border:1px solid rgba(0,255,224,.1);border-radius:2px;
|
border:1px solid rgba(0,119,204,0.15);border-radius:2px;
|
||||||
transition:all .15s;letter-spacing:.08em;
|
transition:all .15s;letter-spacing:.08em;
|
||||||
}
|
}
|
||||||
.nav-links a:hover{color:var(--accent);border-color:rgba(0,255,224,.3);}
|
.nav-links a:hover{color:var(--accent);border-color:rgba(0,119,204,0.4);}
|
||||||
|
|
||||||
main{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:2rem;}
|
main{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:2rem;}
|
||||||
|
|
||||||
/* loading / empty */
|
#loading{text-align:center;color:var(--muted);padding:4rem;font-family:var(--mono);font-size:.75rem;letter-spacing:.1em;}
|
||||||
#loading{text-align:center;color:var(--muted);padding:4rem;font-family:var(--mono);font-size:.75rem;
|
|
||||||
letter-spacing:.1em;}
|
|
||||||
#loading::after{content:'...';animation:dots 1.2s step-end infinite;}
|
#loading::after{content:'...';animation:dots 1.2s step-end infinite;}
|
||||||
@keyframes dots{0%{content:''}33%{content:'.'}66%{content:'..'}100%{content:'...'}}
|
@keyframes dots{0%{content:''}33%{content:'.'}66%{content:'..'}100%{content:'...'}}
|
||||||
#empty{display:none;text-align:center;padding:4rem;font-family:var(--mono);font-size:.7rem;color:var(--muted);}
|
#empty{display:none;text-align:center;padding:4rem;font-family:var(--mono);font-size:.7rem;color:var(--muted);}
|
||||||
#empty .icon{font-size:3rem;margin-bottom:1rem;display:block;}
|
#empty .icon{font-size:3rem;margin-bottom:1rem;display:block;}
|
||||||
|
|
||||||
#content{display:none;}
|
#content{display:none;}
|
||||||
|
|
||||||
/* section */
|
|
||||||
.section{margin-bottom:2.5rem;}
|
.section{margin-bottom:2.5rem;}
|
||||||
.section-title{
|
.section-title{display:flex;align-items:center;gap:.75rem;margin-bottom:1.25rem;}
|
||||||
display:flex;align-items:center;gap:.75rem;margin-bottom:1.25rem;
|
.section-title h2{font-family:var(--sans);font-weight:900;font-size:1.1rem;letter-spacing:-0.01em;}
|
||||||
}
|
|
||||||
.section-title h2{
|
|
||||||
font-family:var(--sans);font-weight:900;font-size:1.1rem;letter-spacing:-0.01em;
|
|
||||||
}
|
|
||||||
.section-title .pill{
|
.section-title .pill{
|
||||||
font-family:var(--mono);font-size:.45rem;letter-spacing:.15em;
|
font-family:var(--mono);font-size:.45rem;letter-spacing:.15em;
|
||||||
padding:.2rem .5rem;border-radius:2px;
|
padding:.2rem .5rem;border-radius:2px;
|
||||||
background:rgba(0,255,224,.06);border:1px solid rgba(0,255,224,.15);color:var(--accent);
|
background:rgba(0,119,204,0.1);border:1px solid rgba(0,119,204,0.3);color:var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* top cards */
|
|
||||||
.top-row{display:flex;gap:.75rem;flex-wrap:wrap;}
|
.top-row{display:flex;gap:.75rem;flex-wrap:wrap;}
|
||||||
.top-card{
|
.top-card{
|
||||||
flex:1;min-width:140px;
|
flex:1;min-width:140px;
|
||||||
background:var(--surface);border:1px solid rgba(0,255,224,.08);border-radius:4px;
|
background:var(--surface);border:1px solid rgba(0,119,204,0.15);border-radius:4px;
|
||||||
padding:.9rem 1.1rem;display:flex;align-items:center;gap:.75rem;
|
padding:.9rem 1.1rem;display:flex;align-items:center;gap:.75rem;
|
||||||
transition:border-color .2s;
|
transition:border-color .2s;
|
||||||
}
|
}
|
||||||
.top-card:hover{border-color:rgba(0,255,224,.2);}
|
.top-card:hover{border-color:rgba(0,119,204,0.35);}
|
||||||
.top-card.gold{border-color:rgba(255,184,0,.3);background:rgba(255,184,0,.04);}
|
.top-card.gold{border-color:rgba(255,184,0,.35);background:rgba(255,184,0,.04);}
|
||||||
.top-card.silver{border-color:rgba(180,180,180,.2);}
|
.top-card.silver{border-color:rgba(180,180,180,.25);}
|
||||||
.top-card.bronze{border-color:rgba(160,90,30,.2);}
|
.top-card.bronze{border-color:rgba(160,90,30,.25);}
|
||||||
.top-medal{font-size:1.6rem;}
|
.top-medal{font-size:1.6rem;}
|
||||||
.top-info .top-name{font-weight:700;font-size:.9rem;margin-bottom:.15rem;}
|
.top-info .top-name{font-weight:700;font-size:.9rem;margin-bottom:.15rem;}
|
||||||
.top-info .top-score{
|
.top-info .top-score{font-family:var(--sans);font-weight:900;font-size:1.2rem;letter-spacing:-0.02em;color:var(--accent);line-height:1;}
|
||||||
font-family:var(--sans);font-weight:900;font-size:1.2rem;
|
|
||||||
letter-spacing:-0.02em;color:var(--accent);line-height:1;
|
|
||||||
}
|
|
||||||
.top-info .top-meta{font-family:var(--mono);font-size:.45rem;color:var(--muted);margin-top:.15rem;}
|
.top-info .top-meta{font-family:var(--mono);font-size:.45rem;color:var(--muted);margin-top:.15rem;}
|
||||||
|
|
||||||
/* sessions */
|
.tabs{display:flex;gap:.5rem;margin-bottom:1.75rem;flex-wrap:wrap;border-bottom:1px solid rgba(0,119,204,0.12);padding-bottom:1rem;}
|
||||||
|
.tab-btn{
|
||||||
|
padding:.45rem 1.1rem;border-radius:2px;font-family:var(--mono);font-size:.62rem;
|
||||||
|
letter-spacing:.1em;border:1px solid rgba(0,119,204,0.2);
|
||||||
|
background:transparent;color:var(--muted);cursor:pointer;transition:all .15s;
|
||||||
|
}
|
||||||
|
.tab-btn.active{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:700;}
|
||||||
|
.tab-btn:hover:not(.active){border-color:var(--accent);color:var(--accent);}
|
||||||
|
.tab-panel{display:none;}
|
||||||
|
.tab-panel.active{display:block;}
|
||||||
|
|
||||||
|
.table-wrap{background:var(--surface);border:1px solid rgba(0,119,204,0.15);border-radius:4px;overflow:hidden;}
|
||||||
|
table{width:100%;border-collapse:collapse;}
|
||||||
|
thead th{
|
||||||
|
text-align:left;font-family:var(--mono);font-size:.5rem;letter-spacing:.12em;
|
||||||
|
color:var(--muted);padding:.65rem 1.1rem;
|
||||||
|
border-bottom:1px solid rgba(0,119,204,0.1);
|
||||||
|
}
|
||||||
|
tbody td{padding:.6rem 1.1rem;font-size:.85rem;border-bottom:1px solid rgba(0,0,0,0.04);}
|
||||||
|
tbody tr:last-child td{border:none;}
|
||||||
|
tbody tr:hover{background:var(--surface2);}
|
||||||
|
.td-rank{font-family:var(--mono);font-weight:700;color:var(--muted);font-size:.8rem;}
|
||||||
|
.td-score{font-family:var(--sans);font-weight:800;color:var(--accent);font-size:.95rem;}
|
||||||
|
.td-medal{font-size:1rem;min-width:28px;}
|
||||||
|
.td-dept{font-family:var(--mono);font-size:.7rem;color:var(--muted);}
|
||||||
|
|
||||||
|
/* ── GROUP SCORE TABLE ── */
|
||||||
|
.group-score-intro{
|
||||||
|
font-family:var(--mono);font-size:.6rem;color:var(--muted);
|
||||||
|
margin-bottom:1.25rem;line-height:1.6;letter-spacing:.03em;
|
||||||
|
padding:.75rem 1rem;
|
||||||
|
background:rgba(0,119,204,0.04);border:1px solid rgba(0,119,204,0.12);border-radius:4px;
|
||||||
|
}
|
||||||
|
.group-score-intro strong{color:var(--accent);}
|
||||||
|
|
||||||
|
.prize-bar-wrap{display:flex;align-items:center;gap:.6rem;min-width:120px;}
|
||||||
|
.prize-bar{height:6px;border-radius:3px;background:rgba(0,119,204,0.12);flex:1;overflow:hidden;}
|
||||||
|
.prize-bar-fill{height:100%;border-radius:3px;transition:width .4s ease;}
|
||||||
|
.prize-pts{
|
||||||
|
font-family:var(--mono);font-weight:700;font-size:.8rem;
|
||||||
|
min-width:36px;text-align:right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-pct{font-family:var(--mono);font-size:.72rem;color:var(--muted);}
|
||||||
|
.td-prize{font-family:var(--sans);font-weight:800;font-size:1rem;}
|
||||||
|
|
||||||
|
/* row highlight for top group */
|
||||||
|
tbody tr.group-winner td{background:rgba(255,184,0,.06);}
|
||||||
|
tbody tr.group-winner .td-prize{color:var(--warn);}
|
||||||
|
|
||||||
|
/* ── DEPT BLOCKS (individual breakdown) ── */
|
||||||
|
.dept-block{margin-bottom:1.25rem;border-radius:4px;overflow:hidden;border:1px solid rgba(0,119,204,0.15);}
|
||||||
|
.dept-header{
|
||||||
|
display:flex;align-items:center;gap:.7rem;
|
||||||
|
padding:.7rem 1.1rem;background:var(--surface);
|
||||||
|
border-bottom:1px solid rgba(0,119,204,0.1);
|
||||||
|
}
|
||||||
|
.dept-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;}
|
||||||
|
.dept-name{font-family:var(--sans);font-weight:800;font-size:.95rem;}
|
||||||
|
.dept-count{font-family:var(--mono);font-size:.5rem;color:var(--muted);margin-left:auto;}
|
||||||
|
|
||||||
.session-card{
|
.session-card{
|
||||||
background:var(--surface);border:1px solid rgba(0,255,224,.08);border-radius:4px;
|
background:var(--surface);border:1px solid rgba(0,119,204,0.15);border-radius:4px;
|
||||||
margin-bottom:.75rem;overflow:hidden;
|
margin-bottom:.75rem;overflow:hidden;
|
||||||
}
|
}
|
||||||
.session-header{
|
.session-header{
|
||||||
@@ -115,33 +163,14 @@ main{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:2rem;}
|
|||||||
.session-badge{
|
.session-badge{
|
||||||
font-family:var(--mono);font-size:.5rem;
|
font-family:var(--mono);font-size:.5rem;
|
||||||
padding:.2rem .55rem;border-radius:2px;
|
padding:.2rem .55rem;border-radius:2px;
|
||||||
background:rgba(0,255,224,.06);border:1px solid rgba(0,255,224,.15);color:var(--accent);
|
background:rgba(0,119,204,0.1);border:1px solid rgba(0,119,204,0.3);color:var(--accent);
|
||||||
}
|
}
|
||||||
.session-right{display:flex;align-items:center;gap:.75rem;}
|
.session-right{display:flex;align-items:center;gap:.75rem;}
|
||||||
.session-rounds{font-family:var(--mono);font-size:.5rem;color:var(--muted);}
|
.session-rounds{font-family:var(--mono);font-size:.5rem;color:var(--muted);}
|
||||||
.chevron{color:var(--muted);transition:transform .2s;font-size:.9rem;}
|
.chevron{color:var(--muted);transition:transform .2s;font-size:.9rem;}
|
||||||
|
.session-body{display:none;border-top:1px solid rgba(0,119,204,0.1);}
|
||||||
.session-body{display:none;border-top:1px solid rgba(0,255,224,.06);}
|
|
||||||
.session-body.open{display:block;}
|
.session-body.open{display:block;}
|
||||||
|
|
||||||
table{width:100%;border-collapse:collapse;}
|
|
||||||
thead th{
|
|
||||||
text-align:left;font-family:var(--mono);font-size:.5rem;letter-spacing:.12em;
|
|
||||||
color:var(--muted);padding:.65rem 1.1rem;
|
|
||||||
border-bottom:1px solid rgba(0,255,224,.06);
|
|
||||||
}
|
|
||||||
tbody td{padding:.6rem 1.1rem;font-size:.85rem;border-bottom:1px solid rgba(255,255,255,.02);}
|
|
||||||
tbody tr:last-child td{border:none;}
|
|
||||||
.td-rank{font-family:var(--mono);font-weight:700;color:var(--muted);font-size:.8rem;}
|
|
||||||
.td-score{font-family:var(--sans);font-weight:800;color:var(--accent);font-size:.95rem;}
|
|
||||||
.td-medal{font-size:1rem;}
|
|
||||||
|
|
||||||
/* global leaderboard table */
|
|
||||||
.global-table-wrap{
|
|
||||||
background:var(--surface);border:1px solid rgba(0,255,224,.08);border-radius:4px;overflow:hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* fade-up */
|
|
||||||
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
||||||
.fade-up{animation:fadeUp .4s ease both;}
|
.fade-up{animation:fadeUp .4s ease both;}
|
||||||
</style>
|
</style>
|
||||||
@@ -166,7 +195,7 @@ tbody tr:last-child td{border:none;}
|
|||||||
<div id="content">
|
<div id="content">
|
||||||
|
|
||||||
<!-- TOP PLAYERS -->
|
<!-- TOP PLAYERS -->
|
||||||
<div class="section" id="section-top">
|
<div class="section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>🏆 All-Time Top Players</h2>
|
<h2>🏆 All-Time Top Players</h2>
|
||||||
<span class="pill">BEST SCORE</span>
|
<span class="pill">BEST SCORE</span>
|
||||||
@@ -174,18 +203,26 @@ tbody tr:last-child td{border:none;}
|
|||||||
<div class="top-row" id="top-row"></div>
|
<div class="top-row" id="top-row"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GLOBAL RANKING -->
|
<!-- TABS -->
|
||||||
<div class="section">
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab('global')">📊 GLOBAL RANKING</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('dept')">🏢 BY DEPARTMENT</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('sessions')">📋 SESSIONS</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GLOBAL TAB -->
|
||||||
|
<div class="tab-panel active" id="tab-global">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>📊 Global Ranking</h2>
|
<h2>📊 Global Ranking</h2>
|
||||||
<span class="pill">ALL SESSIONS</span>
|
<span class="pill">ALL SESSIONS · BEST SCORE</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="global-table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>PLAYER</th>
|
<th>PLAYER</th>
|
||||||
|
<th>DEPARTMENT</th>
|
||||||
<th>BEST SCORE</th>
|
<th>BEST SCORE</th>
|
||||||
<th>SESSIONS</th>
|
<th>SESSIONS</th>
|
||||||
<th>AVG SCORE</th>
|
<th>AVG SCORE</th>
|
||||||
@@ -196,10 +233,52 @@ tbody tr:last-child td{border:none;}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SESSIONS -->
|
<!-- DEPT TAB -->
|
||||||
<div class="section">
|
<div class="tab-panel" id="tab-dept">
|
||||||
|
|
||||||
|
<!-- GROUP PRIZE SCORE -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>🎯 Group Prize Score</h2>
|
||||||
|
<span class="pill">AVG · MAX 25 PTS</span>
|
||||||
|
</div>
|
||||||
|
<div class="group-score-intro">
|
||||||
|
Prize points are based on each group's <strong>average individual score</strong> across all rounds.<br>
|
||||||
|
<strong>3,000 avg = 25 pts</strong> · Score scales linearly downward · Max score per player: <strong>4,500 pts</strong>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>DEPARTMENT</th>
|
||||||
|
<th>PLAYERS</th>
|
||||||
|
<th>AVG SCORE</th>
|
||||||
|
<th>% OF MAX</th>
|
||||||
|
<th>PRIZE PTS</th>
|
||||||
|
<th style="min-width:140px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="group-score-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INDIVIDUAL BREAKDOWN BY DEPT -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>🏢 Player Breakdown by Department</h2>
|
||||||
|
<span class="pill">BEST SCORE PER PLAYER</span>
|
||||||
|
</div>
|
||||||
|
<div id="dept-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SESSIONS TAB -->
|
||||||
|
<div class="tab-panel" id="tab-sessions">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>📋 Sessions</h2>
|
<h2>📋 Session History</h2>
|
||||||
<span class="pill" id="session-count-pill">0 GAMES</span>
|
<span class="pill" id="session-count-pill">0 GAMES</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="sessions-list"></div>
|
<div id="sessions-list"></div>
|
||||||
@@ -211,6 +290,30 @@ tbody tr:last-child td{border:none;}
|
|||||||
<script>
|
<script>
|
||||||
const medals = ['🥇','🥈','🥉'];
|
const medals = ['🥇','🥈','🥉'];
|
||||||
const rankClass = ['gold','silver','bronze'];
|
const rankClass = ['gold','silver','bronze'];
|
||||||
|
const DEPT_COLORS = [
|
||||||
|
'#0077cc','#cc0033','#ffb800','#cc44ff','#00994d','#ff6b00','#5b9cf6','#f472b6'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Scoring constants (must match main.py)
|
||||||
|
const MAX_SCORE = 4500;
|
||||||
|
const PRIZE_THRESHOLD = 3000;
|
||||||
|
const MAX_PRIZE = 25;
|
||||||
|
|
||||||
|
function switchTab(name) {
|
||||||
|
const names = ['global','dept','sessions'];
|
||||||
|
document.querySelectorAll('.tab-btn').forEach((b,i) => {
|
||||||
|
b.classList.toggle('active', names[i] === name);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + name).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function prizeColor(pts) {
|
||||||
|
if (pts >= 23) return '#ffb800'; // gold zone
|
||||||
|
if (pts >= 18) return '#0077cc'; // blue
|
||||||
|
if (pts >= 12) return '#00994d'; // green
|
||||||
|
return '#cc0033'; // red / low
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await fetch('api/scores');
|
const res = await fetch('api/scores');
|
||||||
@@ -230,19 +333,21 @@ async function load() {
|
|||||||
const playerMap = {};
|
const playerMap = {};
|
||||||
data.forEach(s => {
|
data.forEach(s => {
|
||||||
s.players.forEach(p => {
|
s.players.forEach(p => {
|
||||||
if (!playerMap[p.player]) {
|
const key = p.player;
|
||||||
playerMap[p.player] = { best: p.score, total: p.score, sessions: 1 };
|
if (!playerMap[key]) {
|
||||||
|
playerMap[key] = { best: p.score, total: p.score, sessions: 1, dept: p.dept || '—' };
|
||||||
} else {
|
} else {
|
||||||
if (p.score > playerMap[p.player].best) playerMap[p.player].best = p.score;
|
if (p.score > playerMap[key].best) playerMap[key].best = p.score;
|
||||||
playerMap[p.player].total += p.score;
|
playerMap[key].total += p.score;
|
||||||
playerMap[p.player].sessions++;
|
playerMap[key].sessions++;
|
||||||
|
if (p.dept) playerMap[key].dept = p.dept;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const sorted = Object.entries(playerMap).sort((a,b) => b[1].best - a[1].best);
|
const sorted = Object.entries(playerMap).sort((a,b) => b[1].best - a[1].best);
|
||||||
|
|
||||||
// ── TOP CARDS ─────────────────────────────────────────────────────────────
|
// ── TOP 5 CARDS ───────────────────────────────────────────────────────────
|
||||||
const topRow = document.getElementById('top-row');
|
const topRow = document.getElementById('top-row');
|
||||||
sorted.slice(0, 5).forEach(([name, info], i) => {
|
sorted.slice(0, 5).forEach(([name, info], i) => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -253,7 +358,7 @@ async function load() {
|
|||||||
<div class="top-info">
|
<div class="top-info">
|
||||||
<div class="top-name">${name}</div>
|
<div class="top-name">${name}</div>
|
||||||
<div class="top-score">${info.best.toLocaleString()}</div>
|
<div class="top-score">${info.best.toLocaleString()}</div>
|
||||||
<div class="top-meta">${info.sessions} SESSION${info.sessions>1?'S':''}</div>
|
<div class="top-meta">${info.dept} · ${info.sessions} SESSION${info.sessions>1?'S':''}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
topRow.appendChild(card);
|
topRow.appendChild(card);
|
||||||
});
|
});
|
||||||
@@ -266,12 +371,85 @@ async function load() {
|
|||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="td-medal">${i<3?medals[i]:''}<span class="td-rank" style="${i>=3?'':'display:none'}">#${i+1}</span></td>
|
<td class="td-medal">${i<3?medals[i]:''}<span class="td-rank" style="${i>=3?'':'display:none'}">#${i+1}</span></td>
|
||||||
<td style="font-weight:600">${name}</td>
|
<td style="font-weight:600">${name}</td>
|
||||||
|
<td class="td-dept">${info.dept}</td>
|
||||||
<td class="td-score">${info.best.toLocaleString()}</td>
|
<td class="td-score">${info.best.toLocaleString()}</td>
|
||||||
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${info.sessions}</td>
|
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${info.sessions}</td>
|
||||||
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${avg.toLocaleString()}</td>`;
|
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${avg.toLocaleString()}</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── BUILD DEPT MAP ────────────────────────────────────────────────────────
|
||||||
|
const depts = {};
|
||||||
|
sorted.forEach(([name, info]) => {
|
||||||
|
const d = info.dept || '—';
|
||||||
|
if (!depts[d]) depts[d] = [];
|
||||||
|
depts[d].push({ name, best: info.best, total: info.total, sessions: info.sessions });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GROUP PRIZE SCORE TABLE ───────────────────────────────────────────────
|
||||||
|
// Compute avg score per dept using best scores (fairest cross-session metric)
|
||||||
|
const groupRows = Object.entries(depts).map(([dept, players]) => {
|
||||||
|
const avgScore = Math.round(players.reduce((s, p) => s + p.best, 0) / players.length);
|
||||||
|
const pct = ((avgScore / MAX_SCORE) * 100).toFixed(1);
|
||||||
|
const prize = Math.min(MAX_PRIZE, Math.round((avgScore / PRIZE_THRESHOLD) * MAX_PRIZE));
|
||||||
|
return { dept, players: players.length, avgScore, pct, prize };
|
||||||
|
}).sort((a, b) => b.avgScore - a.avgScore);
|
||||||
|
|
||||||
|
const groupTbody = document.getElementById('group-score-tbody');
|
||||||
|
groupRows.forEach((row, i) => {
|
||||||
|
const color = prizeColor(row.prize);
|
||||||
|
const barPct = Math.min(100, (row.prize / MAX_PRIZE) * 100);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
if (i === 0) tr.className = 'group-winner';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="td-medal">${i<3 ? medals[i] : ''}<span class="td-rank" style="${i>=3?'':'display:none'}">#${i+1}</span></td>
|
||||||
|
<td style="font-weight:700">${row.dept}</td>
|
||||||
|
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${row.players}</td>
|
||||||
|
<td class="td-score">${row.avgScore.toLocaleString()}</td>
|
||||||
|
<td class="td-pct">${row.pct}%</td>
|
||||||
|
<td class="td-prize" style="color:${color}">${row.prize}</td>
|
||||||
|
<td>
|
||||||
|
<div class="prize-bar-wrap">
|
||||||
|
<div class="prize-bar">
|
||||||
|
<div class="prize-bar-fill" style="width:${barPct}%;background:${color}"></div>
|
||||||
|
</div>
|
||||||
|
<span class="prize-pts" style="color:${color}">${row.prize}/${MAX_PRIZE}</span>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
|
groupTbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DEPT INDIVIDUAL BREAKDOWN ─────────────────────────────────────────────
|
||||||
|
const deptsSorted = Object.entries(depts).sort((a,b) => b[1][0].best - a[1][0].best);
|
||||||
|
const deptList = document.getElementById('dept-list');
|
||||||
|
deptsSorted.forEach(([dept, players], di) => {
|
||||||
|
const color = DEPT_COLORS[di % DEPT_COLORS.length];
|
||||||
|
const block = document.createElement('div');
|
||||||
|
block.className = 'dept-block fade-up';
|
||||||
|
block.style.animationDelay = `${di*.05}s`;
|
||||||
|
block.innerHTML = `
|
||||||
|
<div class="dept-header">
|
||||||
|
<div class="dept-dot" style="background:${color}"></div>
|
||||||
|
<div class="dept-name">${dept}</div>
|
||||||
|
<div class="dept-count">${players.length} PLAYER${players.length>1?'S':''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap" style="border:none;border-radius:0;">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th></th><th>PLAYER</th><th>BEST SCORE</th><th>SESSIONS</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${players.map((p, i) => `
|
||||||
|
<tr>
|
||||||
|
<td class="td-medal">${i<3?medals[i]:''}<span class="td-rank" style="${i>=3?'':'display:none'}">#${i+1}</span></td>
|
||||||
|
<td style="font-weight:600">${p.name}</td>
|
||||||
|
<td class="td-score">${p.best.toLocaleString()}</td>
|
||||||
|
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${p.sessions}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
deptList.appendChild(block);
|
||||||
|
});
|
||||||
|
|
||||||
// ── SESSIONS ──────────────────────────────────────────────────────────────
|
// ── SESSIONS ──────────────────────────────────────────────────────────────
|
||||||
const list = document.getElementById('sessions-list');
|
const list = document.getElementById('sessions-list');
|
||||||
data.forEach(session => {
|
data.forEach(session => {
|
||||||
@@ -295,12 +473,13 @@ async function load() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="session-body">
|
<div class="session-body">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th></th><th>PLAYER</th><th>SCORE</th><th>RANK</th></tr></thead>
|
<thead><tr><th></th><th>PLAYER</th><th>DEPARTMENT</th><th>SCORE</th><th>RANK</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${session.players.map((p,i) => `
|
${session.players.map((p,i) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="td-medal">${i<3?medals[i]:''}</td>
|
<td class="td-medal">${i<3?medals[i]:''}</td>
|
||||||
<td style="font-weight:600">${p.player}</td>
|
<td style="font-weight:600">${p.player}</td>
|
||||||
|
<td class="td-dept">${p.dept || '—'}</td>
|
||||||
<td class="td-score">${p.score.toLocaleString()}</td>
|
<td class="td-score">${p.score.toLocaleString()}</td>
|
||||||
<td style="font-family:var(--mono);font-size:.7rem;color:var(--muted)">#${p.rank}</td>
|
<td style="font-family:var(--mono);font-size:.7rem;color:var(--muted)">#${p.rank}</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
|
|||||||
Reference in New Issue
Block a user