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:
2026-06-09 01:45:04 +00:00
co-authored by Claude Sonnet 4.6
parent d8f4cc97c6
commit 3539b1f75f
13 changed files with 451 additions and 408 deletions
+93 -146
View File
@@ -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)