diff --git a/.gitignore b/.gitignore index 6b14151..bb99e55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,17 @@ +# Runtime data +data/ +*.db + +# Environment .env -*.log + +# Python __pycache__/ -node_modules/ +*.pyc +*.pyo +.venv/ venv/ -dist/ -build/ + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile index 57258b3..7670673 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,16 @@ -FROM python:3.12-slim +FROM python:3.11-slim + WORKDIR /app + COPY 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 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index 9f9dcd5..ca7b89a 100644 --- a/README.md +++ b/README.md @@ -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` - ---- - -## 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) -``` +Built with FastAPI, WebSockets, SQLite, and Docker. No external services required. --- ## Features -### Player (`/`) -- Language selector — English / Español -- Registration — first name, last name, department -- Space Invaders arcade — 4 lanes, one per answer; ship at bottom fires at the correct invader -- 8-bit sound effects via Web Audio API -- Particle explosions on hit -- Score flash after each answer — points earned + running total -- Podium screen — top 10 with medals at game end -- 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 +- 🎮 Space Invaders mechanic — players shoot answers, not just tap buttons +- 📱 Mobile-friendly player view — join via QR code, no install needed +- 🖥️ Host panel — controls game flow, shows live answer count and ranking +- 🌐 Bilingual UI — English / Español toggle on the join screen +- 🎨 Theme switcher — Dark / Light / High Contrast (great for projection) +- 📊 Score history — persistent leaderboard across sessions +- 🏢 Department grouping — track scores by team +- ⚙️ Fully configurable — questions, org name, departments, time limit via JSON --- -## Scoring System - -``` -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 +## Quick Start ```bash -# First deploy -cd /opt/ni-quiz -docker compose up -d --build -docker network connect web_web-net ni-quiz-ni-quiz-1 +git clone https://github.com/carlitosbond/si-quiz.git +cd si-quiz -# Subsequent deploys (network defined in docker-compose.yml) -docker compose down -docker compose up -d --build +# 1. Edit your questions and branding +cp config.json config.json # already provided, edit as needed +cp questions.json questions.json # already provided, customize freely -# Restart only (no rebuild — e.g. after editing main.py) -docker compose restart ni-quiz +# 2. Start +docker compose up -d -# Backup DB -docker compose cp ni-quiz:/data/quiz.db ./quiz-backup-$(date +%Y%m%d).db +# Host panel: http://localhost:8000/host +# Players join: http://localhost:8000 ``` --- ## Configuration -### Host password -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. +### `config.json` — branding and game settings -### Auto-reset timeout -Edit in `main.py` (`ConnectionManager.disconnect_host`): -```python -self._reset_task = asyncio.create_task(self._auto_reset(delay=300)) # seconds +```json +{ + "app_name": "SI Quiz", + "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 -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 | +| Field | Description | |---|---| -| Players | https://ni-quiz.carloselugo.com | -| Host / Projector | https://ni-quiz.carloselugo.com/host | -| Score History | https://ni-quiz.carloselugo.com/scores | \ No newline at end of file +| `app_name` | Displayed in browser title and UI headers | +| `app_subtitle` | Tagline shown on the join screen | +| `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. \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..879d7b7 --- /dev/null +++ b/config.json @@ -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 } + ] + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 87b424c..112d452 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,38 @@ services: - ni-quiz: + si-quiz: build: . - volumes: - - quiz-data:/data - - ./static:/app/static - environment: - - DB_PATH=/data/quiz.db + container_name: si-quiz 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: - - web-net - -volumes: - quiz-data: + - si-quiz-net networks: - web-net: - external: true - name: web_web-net \ No newline at end of file + si-quiz-net: + driver: bridge + +# ── 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 +# } \ No newline at end of file diff --git a/env_example b/env_example new file mode 100644 index 0000000..c6ec041 --- /dev/null +++ b/env_example @@ -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 \ No newline at end of file diff --git a/main.py b/main.py index 98e2c9d..2054a05 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,62 @@ from fastapi.responses import HTMLResponse, JSONResponse import json, time, random, sqlite3, os 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 ────────────────────────────────────────────────────────────────── def get_db(): @@ -33,11 +88,10 @@ def init_db(): finished_at TEXT NOT NULL ); """) - # Safe migration: add dept column if it doesn't exist yet try: conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''") except Exception: - pass # column already exists + pass init_db() @@ -62,119 +116,15 @@ app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") -# ── QUESTIONS ───────────────────────────────────────────────────────────────── -QUESTIONS = [ - { - "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?", - "options": [ - "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", - "Para ahorrar en hardware de servidores / To save on server hardware", - "Porque las herramientas comerciales no existen para redes / Commercial tools don't exist for networks", - "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." +def base_ctx(request: Request) -> dict: + return { + "request": request, + "app_name": APP_NAME, + "app_sub": APP_SUB, + "org_name": ORG_NAME, + "host_password": HOST_PASSWORD, + "departments": DEPARTMENTS, } -] # ── GAME STATE ──────────────────────────────────────────────────────────────── class GameState: @@ -187,12 +137,7 @@ class GameState: self.current_q = -1 self.q_start_time = 0 self.answers_this_round = [] - # 4 generals (indices 0-4) + 1 tool (indices 5-9), shuffled together - generals = random.sample(range(5), 4) - tool = random.sample(range(5, 10), 1) - combined = generals + tool - random.shuffle(combined) - self.question_order = combined + self.question_order = pick_questions() game = GameState() @@ -254,20 +199,20 @@ mgr = ConnectionManager() def get_leaderboard(): 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 ──────────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) 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) 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) 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") async def clear_scores(): @@ -293,7 +238,8 @@ async def api_scores(): "started_at": s["started_at"], "ended_at": s["ended_at"], "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) @@ -307,8 +253,8 @@ async def player_ws(ws: WebSocket, ws_id: str): msg = json.loads(raw) if msg["type"] == "join": - name = msg["name"].strip()[:40] - dept = msg.get("dept", "").strip()[:40] + name = msg["name"].strip()[:40] + dept = msg.get("dept", "").strip()[:40] display = msg.get("display", name).strip()[:40] if not name: 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.broadcast_hosts({ "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) }) @@ -327,14 +274,13 @@ async def player_ws(ws: WebSocket, ws_id: str): if not player or player["answered"]: continue elapsed = time.time() - game.q_start_time - TIME_LIMIT = 30 if elapsed > TIME_LIMIT: continue player["answered"] = True - chosen = msg["choice"] - q_idx = game.question_order[game.current_q] - correct = QUESTIONS[q_idx]["correct"] + chosen = msg["choice"] + q_idx = game.question_order[game.current_q] + correct = ALL_QUESTIONS[q_idx]["correct"] is_correct = chosen == correct points = 0 @@ -367,7 +313,8 @@ async def player_ws(ws: WebSocket, ws_id: str): mgr.disconnect(ws_id) await mgr.broadcast_hosts({ "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) }) @@ -378,7 +325,8 @@ async def host_ws(ws: WebSocket): try: await ws.send_json({ "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) }) @@ -386,10 +334,9 @@ async def host_ws(ws: WebSocket): msg = json.loads(raw) if msg["type"] == "start_game": - existing_players = dict(game.players) # preserve joined players + existing_players = dict(game.players) game.reset() - game.players = existing_players # restore them - # Reset scores and state for a fresh game + game.players = existing_players for p in game.players.values(): p["score"] = 0 p["answered"] = False @@ -399,7 +346,7 @@ async def host_ws(ws: WebSocket): game.current_q += 1 if game.current_q >= len(game.question_order): lb = get_leaderboard() - save_session(list(game.players.values())) # ← persist to SQLite + save_session(list(game.players.values())) game.phase = "podium" await mgr.broadcast_all({"type": "podium", "leaderboard": lb}) continue @@ -411,21 +358,21 @@ async def host_ws(ws: WebSocket): p["answered"] = False q_idx = game.question_order[game.current_q] - q = QUESTIONS[q_idx] + q = ALL_QUESTIONS[q_idx] await mgr.broadcast_all({ "type": "question", "number": game.current_q + 1, "total": len(game.question_order), "q": q["q"], "options": q["options"], - "time_limit": 30, + "time_limit": TIME_LIMIT, "correct_idx_hint": q["correct"] }) elif msg["type"] == "show_results": game.phase = "results" q_idx = game.question_order[game.current_q] - q = QUESTIONS[q_idx] + q = ALL_QUESTIONS[q_idx] await mgr.broadcast_all({ "type": "results", "correct_idx": q["correct"], @@ -433,14 +380,14 @@ async def host_ws(ws: WebSocket): "leaderboard": get_leaderboard() }) - elif msg["type"] == "reset": - game.reset() - await mgr.broadcast_all({"type": "phase", "phase": "lobby"}) - elif msg["type"] == "clear_lobby": await mgr.broadcast_players({"type": "kicked"}) game.reset() 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: mgr.disconnect_host(ws) \ No newline at end of file diff --git a/questions.json b/questions.json new file mode 100644 index 0000000..f18439a --- /dev/null +++ b/questions.json @@ -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" + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1c6f777..27c18a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi==0.111.0 uvicorn[standard]==0.29.0 jinja2==3.1.4 -websockets==12.0 +python-multipart==0.0.9 +websockets==12.0 \ No newline at end of file diff --git a/static/music.mp3 b/static/music.mp3 new file mode 100644 index 0000000..516aa7a Binary files /dev/null and b/static/music.mp3 differ diff --git a/templates/host.html b/templates/host.html index 6cdc74d..03f84f5 100644 --- a/templates/host.html +++ b/templates/host.html @@ -3,7 +3,7 @@
-// HISTORIAL DE SCORES · NETWORK INTELLIGENCE · LCPR
+// HISTORIAL DE SCORES · {{ org_name }}