From 767956406f1496c1046ef394909e1b41e22f62ae Mon Sep 17 00:00:00 2001 From: Carlos Lugo Date: Wed, 13 May 2026 15:50:51 +0000 Subject: [PATCH] Update main.py --- main.py | 461 +++++++++++++++++++++++++++----------------------------- 1 file changed, 223 insertions(+), 238 deletions(-) diff --git a/main.py b/main.py index a8e5222..42a3555 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,11 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request +from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse import json, time, random, sqlite3, os from datetime import datetime -DB_PATH = os.environ.get("DB_PATH", "/data/rr.db") -app = FastAPI() -templates = Jinja2Templates(directory="templates") +DB_PATH = os.environ.get("DB_PATH", "/data/quiz.db") # ── DATABASE ────────────────────────────────────────────────────────────────── def get_db(): @@ -19,111 +18,108 @@ def init_db(): with get_db() as conn: conn.executescript(""" CREATE TABLE IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - started_at TEXT NOT NULL, - ended_at TEXT, - rounds INTEGER DEFAULT 0 + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + ended_at TEXT, + total_players INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS scores ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER REFERENCES sessions(id), - player TEXT NOT NULL, - score INTEGER DEFAULT 0, - rank INTEGER, - dept TEXT NOT NULL DEFAULT '', - finished_at TEXT NOT NULL + 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 ); """) - # safe migration if dept column missing from older DB + # 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 + pass # column already exists init_db() -# ── GRID ────────────────────────────────────────────────────────────────────── -GRID_SIZE = 4 +def save_session(players: list) -> int: + now = datetime.utcnow().isoformat() + with get_db() as conn: + cur = conn.execute( + "INSERT INTO sessions (started_at, ended_at, total_players) VALUES (?,?,?)", + (now, now, len(players)) + ) + session_id = cur.lastrowid + ranked = sorted(players, key=lambda p: p["score"], reverse=True) + for rank, p in enumerate(ranked, 1): + conn.execute( + "INSERT INTO scores (session_id, player, score, rank, dept, finished_at) VALUES (?,?,?,?,?,?)", + (session_id, p.get("display", p["name"]), p["score"], rank, p.get("dept", ""), now) + ) + return session_id -MODIFIER_TYPES = [ - "normal", "normal", "normal", "normal", "normal", "normal", - "slow", "slow", "boost", "blocked" -] +# ── APP ─────────────────────────────────────────────────────────────────────── +app = FastAPI() +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") -def generate_grid(): - nodes = [] - for row in range(GRID_SIZE): - for col in range(GRID_SIZE): - nodes.append({"id": row * GRID_SIZE + col, "row": row, "col": col}) - - corners = [0, 3, 12, 15] - start = random.choice(corners) - opposite = {0: 15, 3: 12, 12: 3, 15: 0} - end = opposite[start] - - for attempt in range(20): - edges = [] - for row in range(GRID_SIZE): - for col in range(GRID_SIZE): - nid = row * GRID_SIZE + col - if col + 1 < GRID_SIZE: - edges.append({"a": nid, "b": nid + 1, "modifier": random.choice(MODIFIER_TYPES)}) - if row + 1 < GRID_SIZE: - edges.append({"a": nid, "b": nid + GRID_SIZE, "modifier": random.choice(MODIFIER_TYPES)}) - - # Validate: start node must have at least one non-blocked neighbor - start_edges = [e for e in edges if e["a"] == start or e["b"] == start] - if any(e["modifier"] != "blocked" for e in start_edges): - break - # If all attempts failed (extremely unlikely), force one start edge open - else: - for e in edges: - if e["a"] == start or e["b"] == start: - e["modifier"] = "normal" - break - - return {"nodes": nodes, "edges": edges, "start": start, "end": end} - -def score_path(path: list, edges: list, elapsed: float, time_limit: float, end_node: int) -> dict: - if not path or path[-1] != end_node: - return {"points": 0, "reason": "no_path"} - - edge_map = {} - for e in edges: - edge_map[(e["a"], e["b"])] = e["modifier"] - edge_map[(e["b"], e["a"])] = e["modifier"] - - hop_cost = 0 - valid = True - for i in range(len(path) - 1): - a, b = path[i], path[i + 1] - mod = edge_map.get((a, b)) - if mod is None or mod == "blocked": - valid = False - break - elif mod == "slow": - hop_cost += 2 - elif mod == "boost": - hop_cost += 0.5 - else: - hop_cost += 1 - - if not valid: - return {"points": 0, "reason": "invalid_path", "hops": len(path) - 1} - - base = 1000 - speed_bonus = int(500 * max(0, (time_limit - elapsed) / time_limit)) - efficiency_penalty = int(max(0, hop_cost - (GRID_SIZE - 1)) * 50) - points = max(0, base + speed_bonus - efficiency_penalty) - - return { - "points": points, - "reason": "ok", - "hops": len(path) - 1, - "hop_cost": hop_cost, - "speed_bonus": speed_bonus, - "efficiency_penalty": efficiency_penalty, +# ── QUESTIONS ───────────────────────────────────────────────────────────────── +QUESTIONS = [ + { + "q": "¿Cuál es el enfoque principal del equipo de Network Intelligence?", + "options": [ + "Vender licencias de software a otras empresas", + "Construir herramientas propias que empoderan a los equipos operacionales", + "Administrar el presupuesto de TI de LCPR", + "Instalar equipos físicos en planta externa" + ], + "correct": 1, + "fun_fact": "¡Exacto! Construimos para quienes operan la red día a día — no solo dashboards bonitos." + }, + { + "q": "¿Qué problema resolvió el equipo al reemplazar Power BI con frontend propio?", + "options": [ + "La pantalla se veía más bonita", + "Eliminamos la dependencia de licencias costosas y ganamos control total", + "Fue más fácil de instalar en los servidores", + "Los colores del dashboard coincidían con los de Liberty" + ], + "correct": 1, + "fun_fact": "$0 en licencias de visualización. El código es nuestro y lo extendemos como queremos." + }, + { + "q": "¿Qué es Pathfinder?", + "options": [ + "Una aplicación para rastrear camiones de campo", + "Un sistema de GPS para la red de fibra", + "Una plataforma web que centraliza herramientas operacionales de red en un solo lugar", + "Un reporte mensual de disponibilidad de red" + ], + "correct": 2, + "fun_fact": "Pathfinder es el 'panel de control' propio de LCPR — construido exactamente para nuestros flujos de trabajo." + }, + { + "q": "¿Qué logró el proyecto de Firmware Upgrade Automation?", + "options": [ + "Actualizar manualmente 10 modems por semana", + "Contratar más técnicos para visitas a domicilio", + "Automatizar la actualización masiva de modems sin intervención humana", + "Comprar equipos nuevos para reemplazar los modems viejos" + ], + "correct": 2, + "fun_fact": "400+ modems actualizados en batches automáticos. Antes era trabajo manual uno por uno." + }, + { + "q": "¿Hacia dónde se dirige el equipo en su visión AIOps?", + "options": [ + "Depender más de herramientas comerciales como ServiceNow", + "Detectar y predecir fallas antes de que el cliente las reporte", + "Reducir el equipo de ingeniería a la mitad", + "Migrar toda la red a la nube pública" + ], + "correct": 1, + "fun_fact": "La meta: que el técnico salga a campo con el diagnóstico ya hecho — no a diagnosticar." } +] # ── GAME STATE ──────────────────────────────────────────────────────────────── class GameState: @@ -132,58 +128,56 @@ class GameState: def reset(self): self.phase = "lobby" - self.players = {} - self.current_round = 0 - self.total_rounds = 3 - self.grid = None - self.round_start = 0 - self.time_limit = 30 - self.submissions = {} - self.session_id = None + self.players: dict = {} + self.current_q = -1 + self.q_start_time = 0 + self.answers_this_round = [] + self.question_order = list(range(len(QUESTIONS))) + random.shuffle(self.question_order) game = GameState() # ── CONNECTION MANAGER ──────────────────────────────────────────────────────── -class ConnMgr: +class ConnectionManager: def __init__(self): - self.players = {} - self.hosts = [] + self.players: dict = {} + self.hosts: list = [] - async def connect_player(self, pid, ws): + async def connect_player(self, ws_id: str, ws: WebSocket): await ws.accept() - self.players[pid] = ws + self.players[ws_id] = ws - async def connect_host(self, ws): + async def connect_host(self, ws: WebSocket): await ws.accept() self.hosts.append(ws) - def disconnect(self, pid): - self.players.pop(pid, None) - game.players.pop(pid, None) + def disconnect(self, ws_id: str): + self.players.pop(ws_id, None) + game.players.pop(ws_id, None) - def disconnect_host(self, ws): + def disconnect_host(self, ws: WebSocket): if ws in self.hosts: self.hosts.remove(ws) - async def send(self, pid, data): - ws = self.players.get(pid) + async def send_to(self, ws_id: str, data: dict): + ws = self.players.get(ws_id) if ws: try: await ws.send_json(data) except Exception: pass - async def broadcast_players(self, data): + async def broadcast_players(self, data: dict): dead = [] - for pid, ws in list(self.players.items()): + for ws_id, ws in list(self.players.items()): try: await ws.send_json(data) except Exception: - dead.append(pid) + dead.append(ws_id) for d in dead: self.disconnect(d) - async def broadcast_hosts(self, data): + async def broadcast_hosts(self, data: dict): dead = [] for ws in list(self.hosts): try: @@ -193,37 +187,15 @@ class ConnMgr: for d in dead: self.disconnect_host(d) - async def broadcast_all(self, data): + async def broadcast_all(self, data: dict): await self.broadcast_players(data) await self.broadcast_hosts(data) -mgr = ConnMgr() +mgr = ConnectionManager() -def leaderboard(): +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"]} for p in ranked] - -def save_session(): - now = datetime.utcnow().isoformat() - with get_db() as conn: - if not game.session_id: - cur = conn.execute( - "INSERT INTO sessions (started_at, ended_at, rounds) VALUES (?,?,?)", - (now, now, game.current_round) - ) - game.session_id = cur.lastrowid - else: - conn.execute( - "UPDATE sessions SET ended_at=?, rounds=? WHERE id=?", - (now, game.current_round, game.session_id) - ) - ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True) - conn.execute("DELETE FROM scores WHERE session_id=?", (game.session_id,)) - for rank, p in enumerate(ranked, 1): - conn.execute( - "INSERT INTO scores (session_id, player, score, rank, dept, finished_at) VALUES (?,?,?,?,?,?)", - (game.session_id, p.get("display", p["name"]), p["score"], rank, p.get("dept", ""), now) - ) + return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept","")} for p in ranked] # ── ROUTES ──────────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -238,11 +210,18 @@ async def host_page(request: Request): async def scores_page(request: Request): return templates.TemplateResponse("scores.html", {"request": request}) +@app.delete("/api/scores/clear") +async def clear_scores(): + with get_db() as conn: + conn.execute("DELETE FROM scores") + conn.execute("DELETE FROM sessions") + return {"ok": True} + @app.get("/api/scores") async def api_scores(): with get_db() as conn: sessions = conn.execute( - "SELECT * FROM sessions ORDER BY started_at DESC LIMIT 20" + "SELECT * FROM sessions ORDER BY started_at DESC LIMIT 50" ).fetchall() result = [] for s in sessions: @@ -253,94 +232,84 @@ async def api_scores(): result.append({ "id": s["id"], "started_at": s["started_at"], - "rounds": s["rounds"], - "players": [ - { - "player": p["player"], - "score": p["score"], - "rank": p["rank"], - "dept": p["dept"] if "dept" in p.keys() else "", - } - for p in players - ], + "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] }) return JSONResponse(result) -@app.delete("/api/scores/clear") -async def clear_scores(): - with get_db() as conn: - conn.execute("DELETE FROM scores") - conn.execute("DELETE FROM sessions") - return {"ok": True} - # ── PLAYER WEBSOCKET ────────────────────────────────────────────────────────── -@app.websocket("/ws/player/{pid}") -async def player_ws(ws: WebSocket, pid: str): - await mgr.connect_player(pid, ws) +@app.websocket("/ws/player/{ws_id}") +async def player_ws(ws: WebSocket, ws_id: str): + await mgr.connect_player(ws_id, ws) try: - await mgr.send(pid, {"type": "phase", "phase": game.phase}) + await mgr.send_to(ws_id, {"type": "phase", "phase": game.phase}) async for raw in ws.iter_text(): msg = json.loads(raw) if msg["type"] == "join": - name = msg["name"].strip()[:30] + name = msg["name"].strip()[:40] dept = msg.get("dept", "").strip()[:40] - display = msg.get("display", name).strip()[:30] - if not name or len(game.players) >= 4: - await mgr.send(pid, {"type": "error", "msg": "Game full or invalid name"}) + display = msg.get("display", name).strip()[:40] + if not name: continue - game.players[pid] = { - "name": name, - "display": display, - "dept": dept, - "score": 0, - "submitted": False, - } - await mgr.send(pid, {"type": "joined", "name": name, "display": display, "dept": dept}) + game.players[ws_id] = {"name": name, "score": 0, "answered": False, "dept": dept, "display": display} + await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept}) await mgr.broadcast_hosts({ "type": "lobby_update", - "players": [p.get("display", p["name"]) for p in game.players.values()], - "count": len(game.players), + "players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], + "count": len(game.players) }) - elif msg["type"] == "submit_path": - if game.phase != "playing": + elif msg["type"] == "answer": + if game.phase != "question": continue - player = game.players.get(pid) - if not player or player["submitted"]: + player = game.players.get(ws_id) + if not player or player["answered"]: continue - elapsed = time.time() - game.round_start - path = msg.get("path", []) - result = score_path(path, game.grid["edges"], elapsed, game.time_limit, game.grid["end"]) - player["score"] += result["points"] - player["submitted"] = True - game.submissions[pid] = { - "name": player.get("display", player["name"]), - "path": path, - "points": result["points"], - "elapsed": round(elapsed, 2), - "reason": result["reason"], - } - await mgr.send(pid, { - "type": "path_result", - "points": result["points"], - "total": player["score"], - "reason": result["reason"], - "elapsed": round(elapsed, 2), + elapsed = time.time() - game.q_start_time + TIME_LIMIT = 15 + if elapsed > TIME_LIMIT: + continue + + player["answered"] = True + chosen = msg["choice"] + q_idx = game.question_order[game.current_q] + correct = QUESTIONS[q_idx]["correct"] + is_correct = chosen == correct + + points = 0 + if is_correct: + speed_bonus = max(0, (TIME_LIMIT - elapsed) / TIME_LIMIT) + points = int(200 + 800 * speed_bonus) + player["score"] += points + + game.answers_this_round.append({ + "name": player["name"], "correct": is_correct, "elapsed": round(elapsed, 2) }) + + await mgr.send_to(ws_id, { + "type": "answer_result", + "correct": is_correct, + "points": points, + "total": player["score"], + "correct_idx": correct + }) + + answered = sum(1 for p in game.players.values() if p["answered"]) await mgr.broadcast_hosts({ - "type": "submission_update", - "submitted": sum(1 for p in game.players.values() if p["submitted"]), + "type": "answer_update", + "answered": answered, "total": len(game.players), - "submissions": list(game.submissions.values()), + "details": game.answers_this_round }) except WebSocketDisconnect: - mgr.disconnect(pid) + mgr.disconnect(ws_id) await mgr.broadcast_hosts({ "type": "lobby_update", - "players": [p.get("display", p["name"]) for p in game.players.values()], - "count": len(game.players), + "players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], + "count": len(game.players) }) # ── HOST WEBSOCKET ──────────────────────────────────────────────────────────── @@ -350,50 +319,66 @@ async def host_ws(ws: WebSocket): try: await ws.send_json({ "type": "lobby_update", - "players": [p.get("display", p["name"]) for p in game.players.values()], - "count": len(game.players), + "players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], + "count": len(game.players) }) async for raw in ws.iter_text(): msg = json.loads(raw) - if msg["type"] == "start_round": - # accept config params from host on first round - if "total_rounds" in msg: - game.total_rounds = int(msg["total_rounds"]) - if "time_limit" in msg: - game.time_limit = int(msg["time_limit"]) - game.grid = generate_grid() - game.phase = "playing" - game.round_start = time.time() - game.current_round += 1 - game.submissions = {} + if msg["type"] == "start_game": + existing_players = dict(game.players) # preserve joined players + game.reset() + game.players = existing_players # restore them + # Reset scores and state for a fresh game for p in game.players.values(): - p["submitted"] = False + p["score"] = 0 + p["answered"] = False + await mgr.broadcast_all({"type": "phase", "phase": "lobby"}) + + elif msg["type"] == "next_question": + game.current_q += 1 + if game.current_q >= len(QUESTIONS): + lb = get_leaderboard() + save_session(list(game.players.values())) # ← persist to SQLite + game.phase = "podium" + await mgr.broadcast_all({"type": "podium", "leaderboard": lb}) + continue + + game.phase = "question" + game.q_start_time = time.time() + game.answers_this_round = [] + for p in game.players.values(): + p["answered"] = False + + q_idx = game.question_order[game.current_q] + q = QUESTIONS[q_idx] await mgr.broadcast_all({ - "type": "round_start", - "round": game.current_round, - "total_rounds": game.total_rounds, - "grid": game.grid, - "time_limit": game.time_limit, + "type": "question", + "number": game.current_q + 1, + "total": len(QUESTIONS), + "q": q["q"], + "options": q["options"], + "time_limit": 15, + "correct_idx_hint": -1 # not revealed until show_results }) - elif msg["type"] == "end_round": + elif msg["type"] == "show_results": game.phase = "results" - lb = leaderboard() - save_session() + q_idx = game.question_order[game.current_q] + q = QUESTIONS[q_idx] await mgr.broadcast_all({ - "type": "round_results", - "round": game.current_round, - "grid": game.grid, - "submissions": list(game.submissions.values()), - "leaderboard": lb, - "is_final": game.current_round >= game.total_rounds, + "type": "results", + "correct_idx": q["correct"], + "fun_fact": q["fun_fact"], + "leaderboard": get_leaderboard() }) elif msg["type"] == "reset": + # Kick all players back to join screen, then wipe state + await mgr.broadcast_players({"type": "kicked"}) game.reset() - await mgr.broadcast_all({"type": "phase", "phase": "lobby"}) + await mgr.broadcast_hosts({"type": "lobby_update", "players": [], "count": 0}) except WebSocketDisconnect: mgr.disconnect_host(ws) \ No newline at end of file