feat: refactor to SI Quiz with Space Invaders arcade interface
- Updated Dockerfile to Python 3.11, selective COPY for cleaner image - Expanded .gitignore with Python/OS patterns - Added config.json, questions.json, env_example, and static assets - Updated templates (host, player, scores) with Space Invaders UI - Rewrote README for SI Quiz branding and setup docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+14
-4
@@ -1,7 +1,17 @@
|
|||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
.env
|
.env
|
||||||
*.log
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
node_modules/
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
dist/
|
|
||||||
build/
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,16 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
|
||||||
|
COPY main.py .
|
||||||
|
COPY config.json .
|
||||||
|
COPY questions.json .
|
||||||
|
COPY templates/ templates/
|
||||||
|
COPY static/ static/
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -1,247 +1,157 @@
|
|||||||
# 🌐 NI Quiz
|
# 🛸 SI Quiz — Space Invaders Quiz
|
||||||
|
|
||||||
Multiplayer quiz game — self-hosted, no app install required. Built for the Network Intelligence team's show-and-tell event at LCPR. Players join from their mobile browser, answer questions via a Space Invaders arcade interface, and scores are tracked across sessions.
|
A multiplayer trivia game with a Space Invaders-style arcade twist. Players join from their phones, answer questions by shooting the correct answer with their spaceship, and compete on a live leaderboard — all projected from the host's screen.
|
||||||
|
|
||||||
**Live at:** `https://ni-quiz.carloselugo.com`
|
Built with FastAPI, WebSockets, SQLite, and Docker. No external services required.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Layer | Tech |
|
|
||||||
|---|---|
|
|
||||||
| Backend | Python · FastAPI · WebSockets |
|
|
||||||
| Storage | SQLite (Docker volume) |
|
|
||||||
| Frontend | Vanilla HTML/CSS/JS · Canvas 2D |
|
|
||||||
| Container | Docker Compose |
|
|
||||||
| Reverse proxy | Caddy (shared `web_web-net`) |
|
|
||||||
| Host | labmini-01 · Dell Optiplex · Ubuntu |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Player (mobile browser)
|
|
||||||
↓ HTTPS / WSS
|
|
||||||
Caddy (ni-quiz.carloselugo.com)
|
|
||||||
↓ reverse_proxy :8000
|
|
||||||
ni-quiz container (FastAPI)
|
|
||||||
↓
|
|
||||||
SQLite (/data/quiz.db) — Docker volume quiz-data
|
|
||||||
```
|
|
||||||
|
|
||||||
All game state lives in memory on the server. SQLite persists session scores across restarts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/opt/ni-quiz/
|
|
||||||
├── main.py # FastAPI app — game logic, WebSockets, SQLite
|
|
||||||
├── requirements.txt
|
|
||||||
├── Dockerfile
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── templates/
|
|
||||||
│ ├── player.html # Mobile arcade game (Space Invaders)
|
|
||||||
│ ├── host.html # Projector/host control panel
|
|
||||||
│ └── scores.html # Score history — global, by dept, by session
|
|
||||||
└── static/ # Static assets (empty — all inline)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Player (`/`)
|
- 🎮 Space Invaders mechanic — players shoot answers, not just tap buttons
|
||||||
- Language selector — English / Español
|
- 📱 Mobile-friendly player view — join via QR code, no install needed
|
||||||
- Registration — first name, last name, department
|
- 🖥️ Host panel — controls game flow, shows live answer count and ranking
|
||||||
- Space Invaders arcade — 4 lanes, one per answer; ship at bottom fires at the correct invader
|
- 🌐 Bilingual UI — English / Español toggle on the join screen
|
||||||
- 8-bit sound effects via Web Audio API
|
- 🎨 Theme switcher — Dark / Light / High Contrast (great for projection)
|
||||||
- Particle explosions on hit
|
- 📊 Score history — persistent leaderboard across sessions
|
||||||
- Score flash after each answer — points earned + running total
|
- 🏢 Department grouping — track scores by team
|
||||||
- Podium screen — top 10 with medals at game end
|
- ⚙️ Fully configurable — questions, org name, departments, time limit via JSON
|
||||||
- Exit button — leaves session cleanly from lobby or podium
|
|
||||||
|
|
||||||
### Host Panel (`/host`) — projector view
|
|
||||||
- Password-protected — blocks access before WebSocket connects
|
|
||||||
- QR code auto-generated with the player join URL
|
|
||||||
- Live lobby — player count + name chips update in real time
|
|
||||||
- Live leaderboard during questions — who answered, time taken, correct/wrong
|
|
||||||
- Full top 10 leaderboard after each question with department
|
|
||||||
- Game controls — Start, Next Question, Show Results, Reset
|
|
||||||
- Clear Scores — wipes all SQLite history with confirmation
|
|
||||||
- Auto-reset — if host disconnects mid-game, server resets automatically after 5 minutes if no host reconnects
|
|
||||||
|
|
||||||
### Score History (`/scores`)
|
|
||||||
- Top 5 cards — all-time best scores across all sessions
|
|
||||||
- **Global tab** — full player ranking by best historical score, with department and session count
|
|
||||||
- **By department tab** — each department ranked internally, departments sorted by their top player
|
|
||||||
- **Sessions tab** — collapsible session list with full rankings and department per player
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scoring System
|
## Quick Start
|
||||||
|
|
||||||
```
|
|
||||||
points = 200 + 800 × (time_remaining / time_limit)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Condition | Points |
|
|
||||||
|---|---|
|
|
||||||
| Correct, fastest possible | 1000 |
|
|
||||||
| Correct, just before timeout | 200 |
|
|
||||||
| Wrong answer | 0 |
|
|
||||||
| No answer / timeout | 0 |
|
|
||||||
|
|
||||||
Time limit: **20 seconds** per question.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SQLite Schema
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
started_at TEXT NOT NULL,
|
|
||||||
ended_at TEXT,
|
|
||||||
total_players INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE scores (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id),
|
|
||||||
player TEXT NOT NULL,
|
|
||||||
score INTEGER NOT NULL DEFAULT 0,
|
|
||||||
rank INTEGER,
|
|
||||||
dept TEXT NOT NULL DEFAULT '',
|
|
||||||
finished_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Scores are saved when the host advances past the last question. The `dept` column is added automatically via migration on startup if it doesn't exist (safe for existing DBs).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `GET` | `/` | Player game page |
|
|
||||||
| `GET` | `/host` | Host control panel (password protected) |
|
|
||||||
| `GET` | `/scores` | Score history page |
|
|
||||||
| `GET` | `/api/scores` | JSON — all sessions and scores |
|
|
||||||
| `DELETE` | `/api/scores/clear` | Wipe all sessions and scores |
|
|
||||||
| `WS` | `/ws/player/{id}` | Player WebSocket |
|
|
||||||
| `WS` | `/ws/host` | Host WebSocket |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Compose
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
ni-quiz:
|
|
||||||
build: .
|
|
||||||
volumes:
|
|
||||||
- quiz-data:/data
|
|
||||||
environment:
|
|
||||||
- DB_PATH=/data/quiz.db
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- web-net
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
quiz-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
web-net:
|
|
||||||
external: true
|
|
||||||
name: web_web-net
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Caddyfile Block
|
|
||||||
|
|
||||||
```caddy
|
|
||||||
ni-quiz.carloselugo.com {
|
|
||||||
import security_headers
|
|
||||||
reverse_proxy ni-quiz:8000 {
|
|
||||||
header_up Host {host}
|
|
||||||
header_up X-Real-IP {remote_host}
|
|
||||||
}
|
|
||||||
log {
|
|
||||||
output file /var/log/caddy/ni-quiz.log
|
|
||||||
format json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> No `encode gzip` — gzip breaks WebSocket upgrades.
|
|
||||||
> No `internal_only` — players need public access from mobile devices.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# First deploy
|
git clone https://github.com/carlitosbond/si-quiz.git
|
||||||
cd /opt/ni-quiz
|
cd si-quiz
|
||||||
docker compose up -d --build
|
|
||||||
docker network connect web_web-net ni-quiz-ni-quiz-1
|
|
||||||
|
|
||||||
# Subsequent deploys (network defined in docker-compose.yml)
|
# 1. Edit your questions and branding
|
||||||
docker compose down
|
cp config.json config.json # already provided, edit as needed
|
||||||
docker compose up -d --build
|
cp questions.json questions.json # already provided, customize freely
|
||||||
|
|
||||||
# Restart only (no rebuild — e.g. after editing main.py)
|
# 2. Start
|
||||||
docker compose restart ni-quiz
|
docker compose up -d
|
||||||
|
|
||||||
# Backup DB
|
# Host panel: http://localhost:8000/host
|
||||||
docker compose cp ni-quiz:/data/quiz.db ./quiz-backup-$(date +%Y%m%d).db
|
# Players join: http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Host password
|
### `config.json` — branding and game settings
|
||||||
Edit in `templates/host.html`:
|
|
||||||
```js
|
|
||||||
const HOST_PASSWORD = 'ni2026'; // ← change this
|
|
||||||
```
|
|
||||||
The password is checked client-side and stored in `sessionStorage` — valid for the browser tab session only.
|
|
||||||
|
|
||||||
### Auto-reset timeout
|
```json
|
||||||
Edit in `main.py` (`ConnectionManager.disconnect_host`):
|
{
|
||||||
```python
|
"app_name": "SI Quiz",
|
||||||
self._reset_task = asyncio.create_task(self._auto_reset(delay=300)) # seconds
|
"app_subtitle": "Space Invaders Quiz",
|
||||||
|
"org_name": "Your Team",
|
||||||
|
"departments": ["Engineering", "Operations", "IT", "Sales", "Finance", "HR", "Other"],
|
||||||
|
"time_limit_seconds": 15,
|
||||||
|
"questions_file": "questions.json"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Question time limit
|
| Field | Description |
|
||||||
Edit in `main.py` (two places must match):
|
|
||||||
```python
|
|
||||||
TIME_LIMIT = 20 # server-side validation
|
|
||||||
"time_limit": 20, # sent to client for countdown display
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Issues / Notes
|
|
||||||
|
|
||||||
- **Network on rebuild** — `docker compose up --build` recreates the container and drops it from `web_web-net`. Fix: define the network in `docker-compose.yml` (already done), or manually reconnect with `docker network connect web_web-net ni-quiz-ni-quiz-1`.
|
|
||||||
- **No gzip on WS proxies** — Caddy's `encode gzip` breaks WebSocket upgrades. Always omit for WS-heavy services.
|
|
||||||
- **Host password is client-side** — suitable for access control at an internal event. For stricter security, move auth to the backend with `fastapi.security`.
|
|
||||||
- **Single game instance** — all game state is a single global `GameState` object. Concurrent games are not supported.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## URLs
|
|
||||||
|
|
||||||
| | URL |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Players | https://ni-quiz.carloselugo.com |
|
| `app_name` | Displayed in browser title and UI headers |
|
||||||
| Host / Projector | https://ni-quiz.carloselugo.com/host |
|
| `app_subtitle` | Tagline shown on the join screen |
|
||||||
| Score History | https://ni-quiz.carloselugo.com/scores |
|
| `org_name` | Optional org/team name shown in the host panel |
|
||||||
|
| `departments` | List shown in the player join dropdown |
|
||||||
|
| `time_limit_seconds` | Seconds per question (default: 15) |
|
||||||
|
|
||||||
|
### `questions.json` — your question bank
|
||||||
|
|
||||||
|
Each question follows this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"q": "Question text here?",
|
||||||
|
"options": ["Answer A", "Answer B", "Answer C", "Answer D"],
|
||||||
|
"correct": 0,
|
||||||
|
"fun_fact": "Explanation shown after the answer is revealed."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `correct` is the **zero-based index** of the correct answer (0 = A, 1 = B, 2 = C, 3 = D)
|
||||||
|
- Questions are shuffled each session
|
||||||
|
- No minimum or maximum — add as many as you want
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scoring
|
||||||
|
|
||||||
|
| Component | Points |
|
||||||
|
|---|---|
|
||||||
|
| Correct answer | 200 pts base |
|
||||||
|
| Speed bonus | Up to +800 pts (scales with remaining time) |
|
||||||
|
| Wrong / no answer | 0 pts |
|
||||||
|
| **Max per question** | **1,000 pts** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reverse Proxy (Caddy)
|
||||||
|
|
||||||
|
If you're running behind Caddy, **do not** add `encode gzip` — it breaks the WebSocket upgrade.
|
||||||
|
|
||||||
|
```
|
||||||
|
si-quiz.yourdomain.com {
|
||||||
|
reverse_proxy si-quiz:8000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect the container to your Caddy network:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In docker-compose.yml, under the si-quiz service:
|
||||||
|
networks:
|
||||||
|
- si-quiz-net
|
||||||
|
- web-net # your Caddy shared network
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` to override defaults:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DB_PATH` | `/data/si-quiz.db` | SQLite database path (inside container) |
|
||||||
|
| `CONFIG_PATH` | `config.json` | Path to config file |
|
||||||
|
| `QUESTIONS_PATH` | `questions.json` | Path to questions file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
si-quiz/
|
||||||
|
├── main.py # FastAPI backend + WebSocket game logic
|
||||||
|
├── config.json # Branding, departments, time limit
|
||||||
|
├── questions.json # Your question bank
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── requirements.txt
|
||||||
|
├── .env.example
|
||||||
|
├── templates/
|
||||||
|
│ ├── player.html # Mobile player view (Space Invaders game)
|
||||||
|
│ ├── host.html # Host control panel
|
||||||
|
│ └── scores.html # Score history viewer
|
||||||
|
└── static/ # CSS, JS assets (add music.mp3 here if desired)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Music (optional)
|
||||||
|
|
||||||
|
The player view supports background music. Add a file named `music.mp3` to the `static/` folder — it will play automatically when a player joins and stop at the podium screen. Volume is set to 20%.
|
||||||
|
|
||||||
|
Music is **not included** in this repo. Bring your own royalty-free track.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — use it, fork it, adapt it for your team.
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"app_name": "SI Quiz",
|
||||||
|
"app_subtitle": "Space Invaders Quiz",
|
||||||
|
"org_name": "Your Team",
|
||||||
|
"host_password": "changeme",
|
||||||
|
"departments": [
|
||||||
|
"Engineering",
|
||||||
|
"Operations",
|
||||||
|
"IT",
|
||||||
|
"Sales",
|
||||||
|
"Finance",
|
||||||
|
"HR",
|
||||||
|
"Legal",
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"time_limit_seconds": 30,
|
||||||
|
"questions_file": "questions.json",
|
||||||
|
"question_selection": {
|
||||||
|
"mode": "grouped",
|
||||||
|
"groups": [
|
||||||
|
{ "tag": "general", "pick": 4 },
|
||||||
|
{ "tag": "tool", "pick": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-13
@@ -1,19 +1,38 @@
|
|||||||
services:
|
services:
|
||||||
ni-quiz:
|
si-quiz:
|
||||||
build: .
|
build: .
|
||||||
volumes:
|
container_name: si-quiz
|
||||||
- quiz-data:/data
|
|
||||||
- ./static:/app/static
|
|
||||||
environment:
|
|
||||||
- DB_PATH=/data/quiz.db
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- ./templates:/app/templates
|
||||||
|
- ./static:/app/static
|
||||||
|
- ./questions.json:/app/questions.json:ro
|
||||||
|
- ./config.json:/app/config.json:ro
|
||||||
|
environment:
|
||||||
|
- DB_PATH=${DB_PATH:-/data/si-quiz.db}
|
||||||
|
- CONFIG_PATH=${CONFIG_PATH:-config.json}
|
||||||
|
- QUESTIONS_PATH=${QUESTIONS_PATH:-questions.json}
|
||||||
networks:
|
networks:
|
||||||
- web-net
|
- si-quiz-net
|
||||||
|
|
||||||
volumes:
|
|
||||||
quiz-data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web-net:
|
si-quiz-net:
|
||||||
external: true
|
driver: bridge
|
||||||
name: web_web-net
|
|
||||||
|
# ── REVERSE PROXY NOTE ────────────────────────────────────────────────────────
|
||||||
|
# If you're using Caddy or nginx as a reverse proxy on the same Docker host,
|
||||||
|
# connect the container to your shared proxy network and remove the ports block.
|
||||||
|
# Example for Caddy (add to the si-quiz service):
|
||||||
|
#
|
||||||
|
# networks:
|
||||||
|
# - si-quiz-net
|
||||||
|
# - web-net # your Caddy shared network
|
||||||
|
#
|
||||||
|
# Caddyfile entry (no encode gzip — breaks WebSocket upgrade):
|
||||||
|
#
|
||||||
|
# si-quiz.yourdomain.com {
|
||||||
|
# reverse_proxy si-quiz:8000
|
||||||
|
# }
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# SI Quiz — Environment Variables
|
||||||
|
# Copy this file to .env and adjust as needed.
|
||||||
|
# docker-compose.yml reads these automatically.
|
||||||
|
|
||||||
|
# Path to the SQLite database (inside the container)
|
||||||
|
DB_PATH=/data/si-quiz.db
|
||||||
|
|
||||||
|
# Path to config and question files (relative to /app inside container)
|
||||||
|
CONFIG_PATH=config.json
|
||||||
|
QUESTIONS_PATH=questions.json
|
||||||
@@ -5,7 +5,62 @@ 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")
|
# ── CONFIG ────────────────────────────────────────────────────────────────────
|
||||||
|
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.json")
|
||||||
|
QUESTIONS_PATH = os.environ.get("QUESTIONS_PATH", "questions.json")
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/data/si-quiz.db")
|
||||||
|
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
CONFIG = json.load(f)
|
||||||
|
|
||||||
|
APP_NAME = CONFIG.get("app_name", "SI Quiz")
|
||||||
|
APP_SUB = CONFIG.get("app_subtitle", "Space Invaders Quiz")
|
||||||
|
ORG_NAME = CONFIG.get("org_name", "")
|
||||||
|
HOST_PASSWORD = CONFIG.get("host_password", "changeme")
|
||||||
|
DEPARTMENTS = CONFIG.get("departments", ["Engineering", "Operations", "IT", "Other"])
|
||||||
|
TIME_LIMIT = int(CONFIG.get("time_limit_seconds", 30))
|
||||||
|
Q_SELECTION = CONFIG.get("question_selection", {"mode": "all"})
|
||||||
|
|
||||||
|
with open(QUESTIONS_PATH) as f:
|
||||||
|
ALL_QUESTIONS = json.load(f)
|
||||||
|
|
||||||
|
def pick_questions() -> list[int]:
|
||||||
|
"""
|
||||||
|
Returns a list of question indices for a session.
|
||||||
|
|
||||||
|
config.json modes:
|
||||||
|
"mode": "all" → use every question, shuffled
|
||||||
|
"mode": "sample" → pick N random questions (requires "count")
|
||||||
|
"mode": "grouped" → pick N from each tagged group (requires "groups")
|
||||||
|
Each group: {"tag": "general", "pick": 4}
|
||||||
|
Questions without a matching tag are ignored for that group.
|
||||||
|
"""
|
||||||
|
mode = Q_SELECTION.get("mode", "all")
|
||||||
|
|
||||||
|
if mode == "all":
|
||||||
|
indices = list(range(len(ALL_QUESTIONS)))
|
||||||
|
random.shuffle(indices)
|
||||||
|
return indices
|
||||||
|
|
||||||
|
if mode == "sample":
|
||||||
|
count = int(Q_SELECTION.get("count", len(ALL_QUESTIONS)))
|
||||||
|
indices = list(range(len(ALL_QUESTIONS)))
|
||||||
|
return random.sample(indices, min(count, len(indices)))
|
||||||
|
|
||||||
|
if mode == "grouped":
|
||||||
|
selected = []
|
||||||
|
for group in Q_SELECTION.get("groups", []):
|
||||||
|
tag = group.get("tag")
|
||||||
|
pick = int(group.get("pick", 1))
|
||||||
|
pool = [i for i, q in enumerate(ALL_QUESTIONS) if q.get("group") == tag]
|
||||||
|
selected += random.sample(pool, min(pick, len(pool)))
|
||||||
|
random.shuffle(selected)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
# fallback
|
||||||
|
indices = list(range(len(ALL_QUESTIONS)))
|
||||||
|
random.shuffle(indices)
|
||||||
|
return indices
|
||||||
|
|
||||||
# ── DATABASE ──────────────────────────────────────────────────────────────────
|
# ── DATABASE ──────────────────────────────────────────────────────────────────
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -33,11 +88,10 @@ def init_db():
|
|||||||
finished_at TEXT NOT NULL
|
finished_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
# Safe migration: add dept column if it doesn't exist yet
|
|
||||||
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()
|
||||||
|
|
||||||
@@ -62,119 +116,15 @@ app = FastAPI()
|
|||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
# ── QUESTIONS ─────────────────────────────────────────────────────────────────
|
def base_ctx(request: Request) -> dict:
|
||||||
QUESTIONS = [
|
return {
|
||||||
{
|
"request": request,
|
||||||
"q": "NI construye herramientas propias en vez de depender de licencias comerciales. ¿Por qué?\n\n[EN] NI builds its own tools instead of relying on commercial licenses. Why?",
|
"app_name": APP_NAME,
|
||||||
"options": [
|
"app_sub": APP_SUB,
|
||||||
"Para tener control total, escalar sin costo por usuario y adaptarse a nuestros flujos reales / Full control, scale without per-user cost, adapt to real workflows",
|
"org_name": ORG_NAME,
|
||||||
"Para ahorrar en hardware de servidores / To save on server hardware",
|
"host_password": HOST_PASSWORD,
|
||||||
"Porque las herramientas comerciales no existen para redes / Commercial tools don't exist for networks",
|
"departments": DEPARTMENTS,
|
||||||
"Es un requisito del contrato de Liberty / It's a Liberty contract requirement"
|
|
||||||
],
|
|
||||||
"correct": 0,
|
|
||||||
"fun_fact": "Código propio = sin techo de plataforma. Podemos extender, integrar y mejorar sin pedir permiso ni pagar más. / Our code = no platform ceiling. We extend, integrate, and improve without asking permission or paying more."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "¿Para quién construye herramientas el equipo de Network Intelligence?\n\n[EN] Who does the Network Intelligence team build tools for?",
|
|
||||||
"options": [
|
|
||||||
"Solo para gerencia ejecutiva / Only for executive management",
|
|
||||||
"Para clientes residenciales de Liberty / For Liberty residential customers",
|
|
||||||
"Para los equipos de NOC, ingeniería, operaciones y técnicos de campo / For NOC, engineering, operations, and field technician teams",
|
|
||||||
"Para el equipo de ventas B2B / For the B2B sales team"
|
|
||||||
],
|
|
||||||
"correct": 2,
|
|
||||||
"fun_fact": "El objetivo no es reportar — es que quien opera la red pueda decidir y actuar más rápido. / The goal isn't reporting — it's enabling the people running the network to decide and act faster."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "Pathfinder junta monitoreo, rutas, topología y análisis operacional en un solo lugar. ¿Cuál es su propósito principal?\n\n[EN] Pathfinder brings together monitoring, routes, topology, and operational analysis in one place. What is its main purpose?",
|
|
||||||
"options": [
|
|
||||||
"Gestionar el presupuesto de proyectos de red / Manage the network project budget",
|
|
||||||
"Reemplazar el sistema de ticketing de la empresa / Replace the company ticketing system",
|
|
||||||
"Automatizar facturación a clientes B2B / Automate billing for B2B customers",
|
|
||||||
"Ayudar a ingeniería y NOC a ver el mismo contexto de red para decidir y actuar más rápido / Help engineering and NOC see the same network context to decide and act faster"
|
|
||||||
],
|
|
||||||
"correct": 3,
|
|
||||||
"fun_fact": "Pathfinder no es solo un dashboard — es un centro operativo. Monitoreo, rutas, topología, backbone e interfaces en una sola experiencia. / Pathfinder isn't just a dashboard — it's an operations center. Monitoring, routes, topology, backbone, and interfaces in one experience."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "MetricFlow es la plataforma KPI del equipo de NI. ¿Qué problema resuelve?\n\n[EN] MetricFlow is NI's KPI platform. What problem does it solve?",
|
|
||||||
"options": [
|
|
||||||
"Reemplaza los emails internos del equipo / Replaces internal team emails",
|
|
||||||
"Centraliza métricas de desempeño, capacidad y tendencias para que los dominios puedan medir cumplimiento / Centralizes performance, capacity, and trend metrics so domains can measure compliance",
|
|
||||||
"Administra las cuentas de acceso de los empleados / Manages employee access accounts",
|
|
||||||
"Genera facturas automáticas para clientes / Generates automatic invoices for customers"
|
|
||||||
],
|
|
||||||
"correct": 1,
|
|
||||||
"fun_fact": "MetricFlow convierte datos dispersos en KPIs por dominio — desempeño, capacidad, tendencia y cumplimiento en un solo lugar. / MetricFlow turns scattered data into domain KPIs — performance, capacity, trend, and compliance in one place."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "Fiber Admin organiza el inventario físico de fibra de LCPR. ¿Qué información centraliza?\n\n[EN] Fiber Admin organizes LCPR's physical fiber inventory. What information does it centralize?",
|
|
||||||
"options": [
|
|
||||||
"Contratos de proveedores y facturas de mantenimiento / Vendor contracts and maintenance invoices",
|
|
||||||
"Horarios del personal de planta externa / External plant staff schedules",
|
|
||||||
"Hubs, puertos, conexiones y empalmes de fibra para coordinar trabajo entre dominios / Hubs, ports, connections, and fiber splices to coordinate work across domains",
|
|
||||||
"Configuraciones de routers y switches de core / Core router and switch configurations"
|
|
||||||
],
|
|
||||||
"correct": 2,
|
|
||||||
"fun_fact": "Fiber Admin convierte inventario físico en información confiable. Sin eso, coordinar cambios entre dominios depende de quién recuerda qué. / Fiber Admin turns physical inventory into reliable information. Without it, coordinating changes across domains depends on who remembers what."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "Los Reportes Automatizados con AI transforman datos crudos en algo más útil. ¿Qué produce exactamente?\n\n[EN] Automated AI Reports transform raw data into something more useful. What exactly does it produce?",
|
|
||||||
"options": [
|
|
||||||
"Alertas de red en tiempo real para el NOC / Real-time network alerts for NOC",
|
|
||||||
"Backups automáticos de bases de datos / Automatic database backups",
|
|
||||||
"Resúmenes, narrativas y reportes ejecutivos listos para compartir — no solo tablas de números / Summaries, narratives, and executive reports ready to share — not just number tables",
|
|
||||||
"Configuraciones automáticas de equipos de red / Automatic network equipment configurations"
|
|
||||||
],
|
|
||||||
"correct": 2,
|
|
||||||
"fun_fact": "Un reporte que solo muestra números no explica qué cambió ni qué hacer. AI convierte métricas en comunicación accionable para cualquier audiencia. / A report that only shows numbers doesn't explain what changed or what to do. AI turns metrics into actionable communication for any audience."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "Znuny es la pieza que cierra el ciclo operacional del pipeline de NI. ¿Cuál es su rol clave?\n\n[EN] Znuny is the piece that closes the operational cycle in NI's pipeline. What is its key role?",
|
|
||||||
"options": [
|
|
||||||
"Conectar detección, notificación, tarea, seguimiento y resolución — que cada señal tenga dueño y cierre / Connect detection, notification, task, follow-up, and resolution — every signal has an owner and a close",
|
|
||||||
"Almacenar backups de configuración de red / Store network configuration backups",
|
|
||||||
"Procesar pagos de nómina del equipo / Process team payroll payments",
|
|
||||||
"Monitorear el consumo eléctrico del datacenter / Monitor datacenter power consumption"
|
|
||||||
],
|
|
||||||
"correct": 0,
|
|
||||||
"fun_fact": "Znuny no es solo un sistema de tickets. Es la pieza que ata el pipeline completo: lo que se detecta termina resuelto, con historial y evidencia. / Znuny isn't just a ticketing system. It's the piece that ties the whole pipeline together: what gets detected ends up resolved, with history and evidence."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "El equipo de NI también apoya dominios fuera de la red pura. ¿Cuál de estas áreas es un ejemplo de eso?\n\n[EN] NI also supports domains outside pure network work. Which of these is an example?",
|
|
||||||
"options": [
|
|
||||||
"Administración de torres celulares / Cell tower administration",
|
|
||||||
"Gestión de inventario de almacén / Warehouse inventory management",
|
|
||||||
"Soporte técnico a clientes de cable / Technical support for cable customers",
|
|
||||||
"Dashboards de BI y automatización de flujos para Construcción y PNM en Power BI y Excel / BI dashboards and workflow automation for Construction and PNM in Power BI and Excel"
|
|
||||||
],
|
|
||||||
"correct": 3,
|
|
||||||
"fun_fact": "NI no es solo red — construimos soluciones analíticas para otros dominios VPTO usando las herramientas correctas para cada caso. / NI isn't just network — we build analytical solutions for other VPTO domains using the right tools for each case."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "¿Cuál es la visión del equipo para el próximo capítulo — AIOps?\n\n[EN] What is the team's vision for the next chapter — AIOps?",
|
|
||||||
"options": [
|
|
||||||
"Reemplazar a todos los ingenieros con inteligencia artificial / Replace all engineers with artificial intelligence",
|
|
||||||
"Comprar una plataforma comercial de AIOps / Purchase a commercial AIOps platform",
|
|
||||||
"Detectar y predecir fallas de red antes de que el cliente las reporte, con recomendaciones automatizadas / Detect and predict network failures before the customer reports them, with automated recommendations",
|
|
||||||
"Migrar toda la infraestructura a la nube pública / Migrate all infrastructure to public cloud"
|
|
||||||
],
|
|
||||||
"correct": 2,
|
|
||||||
"fun_fact": "La meta: el técnico sale a campo con el diagnóstico ya hecho — no a diagnosticar. La red se auto-diagnostica. / The goal: the technician goes to the field with the diagnosis already done — not to diagnose. The network self-diagnoses."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "¿Qué diferencia al equipo de NI de un equipo de soporte TI tradicional?\n\n[EN] What sets the NI team apart from a traditional IT support team?",
|
|
||||||
"options": [
|
|
||||||
"NI solo atiende tickets de soporte / NI only handles support tickets",
|
|
||||||
"NI construye producto interno — herramientas, plataformas y automatización que transforman cómo opera la red / NI builds internal product — tools, platforms, and automation that transform how the network operates",
|
|
||||||
"NI administra el presupuesto de licencias de software / NI manages the software license budget",
|
|
||||||
"NI hace instalaciones físicas de equipos en campo / NI does physical equipment installations in the field"
|
|
||||||
],
|
|
||||||
"correct": 1,
|
|
||||||
"fun_fact": "El backlog de NI no es soporte — es producto interno. Cada tarea construye capacidad operacional real. / The NI backlog isn't support — it's internal product. Every task builds real operational capability."
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
# ── GAME STATE ────────────────────────────────────────────────────────────────
|
# ── GAME STATE ────────────────────────────────────────────────────────────────
|
||||||
class GameState:
|
class GameState:
|
||||||
@@ -187,12 +137,7 @@ class GameState:
|
|||||||
self.current_q = -1
|
self.current_q = -1
|
||||||
self.q_start_time = 0
|
self.q_start_time = 0
|
||||||
self.answers_this_round = []
|
self.answers_this_round = []
|
||||||
# 4 generals (indices 0-4) + 1 tool (indices 5-9), shuffled together
|
self.question_order = pick_questions()
|
||||||
generals = random.sample(range(5), 4)
|
|
||||||
tool = random.sample(range(5, 10), 1)
|
|
||||||
combined = generals + tool
|
|
||||||
random.shuffle(combined)
|
|
||||||
self.question_order = combined
|
|
||||||
|
|
||||||
game = GameState()
|
game = GameState()
|
||||||
|
|
||||||
@@ -254,20 +199,20 @@ mgr = ConnectionManager()
|
|||||||
|
|
||||||
def get_leaderboard():
|
def get_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]
|
||||||
|
|
||||||
# ── 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", base_ctx(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", base_ctx(request))
|
||||||
|
|
||||||
@app.get("/scores", response_class=HTMLResponse)
|
@app.get("/scores", response_class=HTMLResponse)
|
||||||
async def scores_page(request: Request):
|
async def scores_page(request: Request):
|
||||||
return templates.TemplateResponse("scores.html", {"request": request})
|
return templates.TemplateResponse("scores.html", base_ctx(request))
|
||||||
|
|
||||||
@app.delete("/api/scores/clear")
|
@app.delete("/api/scores/clear")
|
||||||
async def clear_scores():
|
async def clear_scores():
|
||||||
@@ -293,7 +238,8 @@ async def api_scores():
|
|||||||
"started_at": s["started_at"],
|
"started_at": s["started_at"],
|
||||||
"ended_at": s["ended_at"],
|
"ended_at": s["ended_at"],
|
||||||
"total_players": s["total_players"],
|
"total_players": s["total_players"],
|
||||||
"players": [{"player": p["player"], "score": p["score"], "rank": p["rank"], "dept": p["dept"] if "dept" in p.keys() else ""} for p in players]
|
"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)
|
||||||
|
|
||||||
@@ -307,8 +253,8 @@ async def player_ws(ws: WebSocket, ws_id: str):
|
|||||||
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()[:40]
|
||||||
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()[:40]
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
@@ -316,7 +262,8 @@ async def player_ws(ws: WebSocket, ws_id: str):
|
|||||||
await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept})
|
await mgr.send_to(ws_id, {"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": [{"name": p["name"], "display": p.get("display", p["name"]),
|
||||||
|
"dept": p.get("dept", "")} for p in game.players.values()],
|
||||||
"count": len(game.players)
|
"count": len(game.players)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -327,14 +274,13 @@ async def player_ws(ws: WebSocket, ws_id: str):
|
|||||||
if not player or player["answered"]:
|
if not player or player["answered"]:
|
||||||
continue
|
continue
|
||||||
elapsed = time.time() - game.q_start_time
|
elapsed = time.time() - game.q_start_time
|
||||||
TIME_LIMIT = 30
|
|
||||||
if elapsed > TIME_LIMIT:
|
if elapsed > TIME_LIMIT:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
player["answered"] = True
|
player["answered"] = True
|
||||||
chosen = msg["choice"]
|
chosen = msg["choice"]
|
||||||
q_idx = game.question_order[game.current_q]
|
q_idx = game.question_order[game.current_q]
|
||||||
correct = QUESTIONS[q_idx]["correct"]
|
correct = ALL_QUESTIONS[q_idx]["correct"]
|
||||||
is_correct = chosen == correct
|
is_correct = chosen == correct
|
||||||
|
|
||||||
points = 0
|
points = 0
|
||||||
@@ -367,7 +313,8 @@ async def player_ws(ws: WebSocket, ws_id: str):
|
|||||||
mgr.disconnect(ws_id)
|
mgr.disconnect(ws_id)
|
||||||
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": [{"name": p["name"], "display": p.get("display", p["name"]),
|
||||||
|
"dept": p.get("dept", "")} for p in game.players.values()],
|
||||||
"count": len(game.players)
|
"count": len(game.players)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -378,7 +325,8 @@ 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": [{"name": p["name"], "display": p.get("display", p["name"]),
|
||||||
|
"dept": p.get("dept", "")} for p in game.players.values()],
|
||||||
"count": len(game.players)
|
"count": len(game.players)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -386,10 +334,9 @@ async def host_ws(ws: WebSocket):
|
|||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
|
|
||||||
if msg["type"] == "start_game":
|
if msg["type"] == "start_game":
|
||||||
existing_players = dict(game.players) # preserve joined players
|
existing_players = dict(game.players)
|
||||||
game.reset()
|
game.reset()
|
||||||
game.players = existing_players # restore them
|
game.players = existing_players
|
||||||
# Reset scores and state for a fresh game
|
|
||||||
for p in game.players.values():
|
for p in game.players.values():
|
||||||
p["score"] = 0
|
p["score"] = 0
|
||||||
p["answered"] = False
|
p["answered"] = False
|
||||||
@@ -399,7 +346,7 @@ async def host_ws(ws: WebSocket):
|
|||||||
game.current_q += 1
|
game.current_q += 1
|
||||||
if game.current_q >= len(game.question_order):
|
if game.current_q >= len(game.question_order):
|
||||||
lb = get_leaderboard()
|
lb = get_leaderboard()
|
||||||
save_session(list(game.players.values())) # ← persist to SQLite
|
save_session(list(game.players.values()))
|
||||||
game.phase = "podium"
|
game.phase = "podium"
|
||||||
await mgr.broadcast_all({"type": "podium", "leaderboard": lb})
|
await mgr.broadcast_all({"type": "podium", "leaderboard": lb})
|
||||||
continue
|
continue
|
||||||
@@ -411,21 +358,21 @@ async def host_ws(ws: WebSocket):
|
|||||||
p["answered"] = False
|
p["answered"] = False
|
||||||
|
|
||||||
q_idx = game.question_order[game.current_q]
|
q_idx = game.question_order[game.current_q]
|
||||||
q = QUESTIONS[q_idx]
|
q = ALL_QUESTIONS[q_idx]
|
||||||
await mgr.broadcast_all({
|
await mgr.broadcast_all({
|
||||||
"type": "question",
|
"type": "question",
|
||||||
"number": game.current_q + 1,
|
"number": game.current_q + 1,
|
||||||
"total": len(game.question_order),
|
"total": len(game.question_order),
|
||||||
"q": q["q"],
|
"q": q["q"],
|
||||||
"options": q["options"],
|
"options": q["options"],
|
||||||
"time_limit": 30,
|
"time_limit": TIME_LIMIT,
|
||||||
"correct_idx_hint": q["correct"]
|
"correct_idx_hint": q["correct"]
|
||||||
})
|
})
|
||||||
|
|
||||||
elif msg["type"] == "show_results":
|
elif msg["type"] == "show_results":
|
||||||
game.phase = "results"
|
game.phase = "results"
|
||||||
q_idx = game.question_order[game.current_q]
|
q_idx = game.question_order[game.current_q]
|
||||||
q = QUESTIONS[q_idx]
|
q = ALL_QUESTIONS[q_idx]
|
||||||
await mgr.broadcast_all({
|
await mgr.broadcast_all({
|
||||||
"type": "results",
|
"type": "results",
|
||||||
"correct_idx": q["correct"],
|
"correct_idx": q["correct"],
|
||||||
@@ -433,14 +380,14 @@ async def host_ws(ws: WebSocket):
|
|||||||
"leaderboard": get_leaderboard()
|
"leaderboard": get_leaderboard()
|
||||||
})
|
})
|
||||||
|
|
||||||
elif msg["type"] == "reset":
|
|
||||||
game.reset()
|
|
||||||
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
|
|
||||||
|
|
||||||
elif msg["type"] == "clear_lobby":
|
elif msg["type"] == "clear_lobby":
|
||||||
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})
|
||||||
|
|
||||||
|
elif msg["type"] == "reset":
|
||||||
|
game.reset()
|
||||||
|
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
mgr.disconnect_host(ws)
|
mgr.disconnect_host(ws)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"q": "¿Cuántos huesos tiene el cuerpo humano adulto?\n\n[EN] How many bones does the adult human body have?",
|
||||||
|
"options": [
|
||||||
|
"206 / 206",
|
||||||
|
"185 / 185",
|
||||||
|
"230 / 230",
|
||||||
|
"172 / 172"
|
||||||
|
],
|
||||||
|
"correct": 0,
|
||||||
|
"fun_fact": "Al nacer tenemos ~270 huesos. Con el tiempo, muchos se fusionan hasta llegar a 206 en la adultez. / We're born with ~270 bones. Over time, many fuse together until we reach 206 in adulthood.",
|
||||||
|
"group": "general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "¿Qué país tiene más volcanes activos en el mundo?\n\n[EN] Which country has the most active volcanoes in the world?",
|
||||||
|
"options": [
|
||||||
|
"Japón / Japan",
|
||||||
|
"Indonesia / Indonesia",
|
||||||
|
"Estados Unidos / United States",
|
||||||
|
"México / Mexico"
|
||||||
|
],
|
||||||
|
"correct": 1,
|
||||||
|
"fun_fact": "Indonesia tiene más de 130 volcanes activos, ubicados en el 'Anillo de Fuego' del Pacífico. / Indonesia has over 130 active volcanoes, located in the Pacific 'Ring of Fire'.",
|
||||||
|
"group": "general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "¿Qué planeta del sistema solar tiene más lunas conocidas?\n\n[EN] Which planet in the solar system has the most known moons?",
|
||||||
|
"options": [
|
||||||
|
"Júpiter / Jupiter",
|
||||||
|
"Neptuno / Neptune",
|
||||||
|
"Saturno / Saturn",
|
||||||
|
"Urano / Uranus"
|
||||||
|
],
|
||||||
|
"correct": 2,
|
||||||
|
"fun_fact": "Saturno lidera con 146 lunas confirmadas, superando a Júpiter en 2023. ¡El récord sigue cambiando! / Saturn leads with 146 confirmed moons, surpassing Jupiter in 2023. The record keeps changing!",
|
||||||
|
"group": "general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "¿En qué año fue fundada la empresa Apple?\n\n[EN] In what year was Apple founded?",
|
||||||
|
"options": [
|
||||||
|
"1980 / 1980",
|
||||||
|
"1972 / 1972",
|
||||||
|
"1984 / 1984",
|
||||||
|
"1976 / 1976"
|
||||||
|
],
|
||||||
|
"correct": 3,
|
||||||
|
"fun_fact": "Apple fue fundada el 1 de abril de 1976 por Steve Jobs, Steve Wozniak y Ronald Wayne. / Apple was founded on April 1, 1976 by Steve Jobs, Steve Wozniak, and Ronald Wayne.",
|
||||||
|
"group": "general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "¿Cuál es el océano más grande del mundo?\n\n[EN] What is the largest ocean in the world?",
|
||||||
|
"options": [
|
||||||
|
"Atlántico / Atlantic",
|
||||||
|
"Índico / Indian",
|
||||||
|
"Pacífico / Pacific",
|
||||||
|
"Ártico / Arctic"
|
||||||
|
],
|
||||||
|
"correct": 2,
|
||||||
|
"fun_fact": "El Océano Pacífico cubre más de 165 millones de km² — más que toda la superficie terrestre combinada. / The Pacific Ocean covers over 165 million km² — more than all land surfaces combined.",
|
||||||
|
"group": "general"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
fastapi==0.111.0
|
fastapi==0.111.0
|
||||||
uvicorn[standard]==0.29.0
|
uvicorn[standard]==0.29.0
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
|
python-multipart==0.0.9
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
Binary file not shown.
+36
-6
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NI Quiz · Host</title>
|
<title>{{ app_name }} · Host</title>
|
||||||
<!-- QR Code library -->
|
<!-- QR Code library -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@@ -155,15 +155,35 @@ html,body{width:100%;height:100vh;background:var(--bg);color:var(--text);font-fa
|
|||||||
}
|
}
|
||||||
#auth-btn:hover{opacity:.85;}
|
#auth-btn:hover{opacity:.85;}
|
||||||
#auth-err{font-family:var(--pixel);font-size:.4rem;color:var(--red);min-height:1rem;}
|
#auth-err{font-family:var(--pixel);font-size:.4rem;color:var(--red);min-height:1rem;}
|
||||||
|
|
||||||
|
/* ── THEME TOGGLE ── */
|
||||||
|
.theme-toggle{
|
||||||
|
position:fixed;top:1rem;right:1rem;z-index:10000;
|
||||||
|
display:flex;gap:.3rem;
|
||||||
|
background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:6px;padding:.3rem .4rem;
|
||||||
|
}
|
||||||
|
.t-btn{
|
||||||
|
padding:.28rem .55rem;border-radius:4px;font-size:.5rem;cursor:pointer;
|
||||||
|
border:1px solid transparent;background:transparent;color:var(--muted);
|
||||||
|
font-family:var(--pixel);transition:all .15s;
|
||||||
|
}
|
||||||
|
.t-btn:hover{color:var(--text);}
|
||||||
|
.t-btn.active{background:var(--accent);color:#000;}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="theme-toggle">
|
||||||
|
<button class="t-btn" onclick="setTheme('dark')" id="t-dark">DARK</button>
|
||||||
|
<button class="t-btn" onclick="setTheme('light')" id="t-light">LIGHT</button>
|
||||||
|
<button class="t-btn" onclick="setTheme('hc')" id="t-hc">HC</button>
|
||||||
|
</div>
|
||||||
<div class="grid-bg"></div>
|
<div class="grid-bg"></div>
|
||||||
|
|
||||||
<!-- LOBBY -->
|
<!-- LOBBY -->
|
||||||
<div class="screen active" id="lobby-screen">
|
<div class="screen active" id="lobby-screen">
|
||||||
<div class="logo">🌐 NI QUIZ</div>
|
<div class="logo">🌐 {{ app_name }}</div>
|
||||||
<div class="tagline">Network Intelligence · LCPR — Host Panel</div>
|
<div class="tagline">{{ org_name }}{% if org_name %} — {% endif %}Host Panel</div>
|
||||||
<div class="lobby-main">
|
<div class="lobby-main">
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<div class="qr-label">SCAN TO PLAY</div>
|
<div class="qr-label">SCAN TO PLAY</div>
|
||||||
@@ -256,7 +276,8 @@ document.getElementById('join-url').textContent = baseUrl;
|
|||||||
new QRCode(document.getElementById('qrcode'), {
|
new QRCode(document.getElementById('qrcode'), {
|
||||||
text: baseUrl,
|
text: baseUrl,
|
||||||
width: 160, height: 160,
|
width: 160, height: 160,
|
||||||
colorDark: '#00c8ff', colorLight: '#0d1117',
|
colorDark: getComputedStyle(document.documentElement).getPropertyValue('--qr-dark').trim() || '#00c8ff',
|
||||||
|
colorLight: getComputedStyle(document.documentElement).getPropertyValue('--qr-light').trim() || '#0d1117',
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -416,8 +437,17 @@ function confetti() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── THEME ─────────────────────────────────────────────────────────────────────
|
||||||
|
function setTheme(t) {
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
localStorage.setItem('si-quiz-theme', t);
|
||||||
|
document.querySelectorAll('.t-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('t-' + t)?.classList.add('active');
|
||||||
|
}
|
||||||
|
(function(){ setTheme(localStorage.getItem('si-quiz-theme') || 'dark'); })();
|
||||||
|
|
||||||
// ── AUTH ─────────────────────────────────────────────────────────────────────
|
// ── AUTH ─────────────────────────────────────────────────────────────────────
|
||||||
const HOST_PASSWORD = 'ni2026'; // ← cambia esto
|
const HOST_PASSWORD = '{{ host_password }}';
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
const saved = sessionStorage.getItem('host_auth');
|
const saved = sessionStorage.getItem('host_auth');
|
||||||
@@ -428,7 +458,7 @@ function checkAuth() {
|
|||||||
gate.id = 'auth-gate';
|
gate.id = 'auth-gate';
|
||||||
gate.innerHTML = `
|
gate.innerHTML = `
|
||||||
<div id="auth-box">
|
<div id="auth-box">
|
||||||
<div id="auth-logo">🌐 NI QUIZ</div>
|
<div id="auth-logo">🌐 {{ app_name }}</div>
|
||||||
<div id="auth-sub">Host Panel · Acceso restringido</div>
|
<div id="auth-sub">Host Panel · Acceso restringido</div>
|
||||||
<input id="auth-input" type="password" placeholder="contraseña" autocomplete="off" />
|
<input id="auth-input" type="password" placeholder="contraseña" autocomplete="off" />
|
||||||
<button id="auth-btn">ENTRAR →</button>
|
<button id="auth-btn">ENTRAR →</button>
|
||||||
|
|||||||
+8
-11
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>NI Quiz · Arcade</title>
|
<title>{{ app_name }} · Arcade</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
|
||||||
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
|
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
|
||||||
@@ -156,16 +156,16 @@ body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradie
|
|||||||
|
|
||||||
<!-- SPLASH — goes straight to join -->
|
<!-- SPLASH — goes straight to join -->
|
||||||
<div class="screen active" id="lang-screen">
|
<div class="screen active" id="lang-screen">
|
||||||
<div class="title">🌐 NI QUIZ</div>
|
<div class="title">🌐 {{ app_name }}</div>
|
||||||
<div class="subtitle">NETWORK INTELLIGENCE · LCPR</div>
|
<div class="subtitle">{{ org_name }}</div>
|
||||||
|
|
||||||
<button class="lang-btn es" onclick="goJoin()">▶ JUGAR / PLAY</button>
|
<button class="lang-btn es" onclick="goJoin()">▶ JUGAR / PLAY</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JOIN -->
|
<!-- JOIN -->
|
||||||
<div class="screen" id="join-screen">
|
<div class="screen" id="join-screen">
|
||||||
<div class="logo">🚀 NI QUIZ</div>
|
<div class="logo">🚀 {{ app_name }}</div>
|
||||||
<div class="sub" id="join-sub">Network Intelligence · LCPR</div>
|
<div class="sub" id="join-sub">{{ org_name }}</div>
|
||||||
<div class="field-wrap">
|
<div class="field-wrap">
|
||||||
<label id="lbl-fname">FIRST NAME</label>
|
<label id="lbl-fname">FIRST NAME</label>
|
||||||
<input id="inp-fname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
|
<input id="inp-fname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
|
||||||
@@ -178,11 +178,8 @@ body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradie
|
|||||||
<label id="lbl-dept">DEPARTMENT</label>
|
<label id="lbl-dept">DEPARTMENT</label>
|
||||||
<select id="inp-dept">
|
<select id="inp-dept">
|
||||||
<option value="">-- Select --</option>
|
<option value="">-- Select --</option>
|
||||||
<option>Finanzas</option>
|
{% for dept in departments %}<option>{{ dept }}</option>
|
||||||
<option>People</option>
|
{% endfor %}
|
||||||
<option>Legal</option>
|
|
||||||
<option>B2B</option>
|
|
||||||
<option>B2C</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button id="join-btn">▶ ENTER GAME</button>
|
<button id="join-btn">▶ ENTER GAME</button>
|
||||||
@@ -227,7 +224,7 @@ body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradie
|
|||||||
<!-- PODIUM -->
|
<!-- PODIUM -->
|
||||||
<div class="screen" id="podium-screen">
|
<div class="screen" id="podium-screen">
|
||||||
<h2 id="pod-title">🏆 GAME OVER</h2>
|
<h2 id="pod-title">🏆 GAME OVER</h2>
|
||||||
<p class="sub" id="pod-sub">Network Intelligence · LCPR</p>
|
<p class="sub" id="pod-sub">{{ org_name }}</p>
|
||||||
<div class="podium-list" id="podium-list"></div>
|
<div class="podium-list" id="podium-list"></div>
|
||||||
<button class="exit-btn" onclick="exitGame()">✕ salir</button>
|
<button class="exit-btn" onclick="exitGame()">✕ salir</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+25
-2
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NI Quiz · Historial</title>
|
<title>{{ app_name }} · Historial</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@400;600;800;900&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@400;600;800;900&display=swap');
|
||||||
|
|
||||||
@@ -156,6 +156,21 @@ tbody tr:hover{background:var(--surface2);}
|
|||||||
/* animations */
|
/* animations */
|
||||||
@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;}
|
||||||
|
|
||||||
|
/* ── THEME TOGGLE ── */
|
||||||
|
.theme-toggle{
|
||||||
|
position:fixed;top:1rem;right:1rem;z-index:10000;
|
||||||
|
display:flex;gap:.3rem;
|
||||||
|
background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:6px;padding:.3rem .4rem;box-shadow:0 2px 8px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
.t-btn{
|
||||||
|
padding:.28rem .55rem;border-radius:4px;font-size:.7rem;cursor:pointer;
|
||||||
|
border:1px solid transparent;background:transparent;color:var(--muted);
|
||||||
|
font-weight:700;transition:all .15s;
|
||||||
|
}
|
||||||
|
.t-btn:hover{color:var(--text);}
|
||||||
|
.t-btn.active{background:var(--accent);color:#000;}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -163,7 +178,7 @@ tbody tr:hover{background:var(--surface2);}
|
|||||||
<header>
|
<header>
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1>NI<span>QUIZ</span></h1>
|
<h1>NI<span>QUIZ</span></h1>
|
||||||
<p>// HISTORIAL DE SCORES · NETWORK INTELLIGENCE · LCPR</p>
|
<p>// HISTORIAL DE SCORES · {{ org_name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a href="/host">← HOST PANEL</a>
|
<a href="/host">← HOST PANEL</a>
|
||||||
@@ -238,6 +253,14 @@ tbody tr:hover{background:var(--surface2);}
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function setTheme(t) {
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
localStorage.setItem('si-quiz-theme', t);
|
||||||
|
document.querySelectorAll('.t-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('t-' + t)?.classList.add('active');
|
||||||
|
}
|
||||||
|
(function(){ setTheme(localStorage.getItem('si-quiz-theme') || 'dark'); })();
|
||||||
|
|
||||||
const medals = ['🥇','🥈','🥉'];
|
const medals = ['🥇','🥈','🥉'];
|
||||||
const rankClass = ['gold','silver','bronze'];
|
const rankClass = ['gold','silver','bronze'];
|
||||||
const DEPT_COLORS = [
|
const DEPT_COLORS = [
|
||||||
|
|||||||
Reference in New Issue
Block a user