commit a8deb6dd700b0b2de6d313a9081b3fafd7c96143 Author: Carlos Lugo Date: Wed May 13 12:11:35 2026 +0000 Upload files to "/" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..510ea2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7465e43 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + route-rush: + build: . + container_name: route-rush + ports: + - "8001:8001" + restart: unless-stopped + networks: + - ipfix-net + +networks: + ipfix-net: + name: ipfix-stack_ipfix-net + external: true diff --git a/main.py b/main.py new file mode 100644 index 0000000..a8e5222 --- /dev/null +++ b/main.py @@ -0,0 +1,399 @@ +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request +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") + +# ── DATABASE ────────────────────────────────────────────────────────────────── +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +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 + ); + 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 + ); + """) + # safe migration if dept column missing from older DB + try: + conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''") + except Exception: + pass + +init_db() + +# ── GRID ────────────────────────────────────────────────────────────────────── +GRID_SIZE = 4 + +MODIFIER_TYPES = [ + "normal", "normal", "normal", "normal", "normal", "normal", + "slow", "slow", "boost", "blocked" +] + +def generate_grid(): + nodes = [] + for row in range(GRID_SIZE): + for col in range(GRID_SIZE): + nodes.append({"id": row * GRID_SIZE + col, "row": row, "col": col}) + + corners = [0, 3, 12, 15] + start = random.choice(corners) + opposite = {0: 15, 3: 12, 12: 3, 15: 0} + end = opposite[start] + + for attempt in range(20): + edges = [] + for row in range(GRID_SIZE): + for col in range(GRID_SIZE): + nid = row * GRID_SIZE + col + if col + 1 < GRID_SIZE: + edges.append({"a": nid, "b": nid + 1, "modifier": random.choice(MODIFIER_TYPES)}) + if row + 1 < GRID_SIZE: + edges.append({"a": nid, "b": nid + GRID_SIZE, "modifier": random.choice(MODIFIER_TYPES)}) + + # Validate: start node must have at least one non-blocked neighbor + start_edges = [e for e in edges if e["a"] == start or e["b"] == start] + if any(e["modifier"] != "blocked" for e in start_edges): + break + # If all attempts failed (extremely unlikely), force one start edge open + else: + for e in edges: + if e["a"] == start or e["b"] == start: + e["modifier"] = "normal" + break + + return {"nodes": nodes, "edges": edges, "start": start, "end": end} + +def score_path(path: list, edges: list, elapsed: float, time_limit: float, end_node: int) -> dict: + if not path or path[-1] != end_node: + return {"points": 0, "reason": "no_path"} + + edge_map = {} + for e in edges: + edge_map[(e["a"], e["b"])] = e["modifier"] + edge_map[(e["b"], e["a"])] = e["modifier"] + + hop_cost = 0 + valid = True + for i in range(len(path) - 1): + a, b = path[i], path[i + 1] + mod = edge_map.get((a, b)) + if mod is None or mod == "blocked": + valid = False + break + elif mod == "slow": + hop_cost += 2 + elif mod == "boost": + hop_cost += 0.5 + else: + hop_cost += 1 + + if not valid: + return {"points": 0, "reason": "invalid_path", "hops": len(path) - 1} + + base = 1000 + speed_bonus = int(500 * max(0, (time_limit - elapsed) / time_limit)) + efficiency_penalty = int(max(0, hop_cost - (GRID_SIZE - 1)) * 50) + points = max(0, base + speed_bonus - efficiency_penalty) + + return { + "points": points, + "reason": "ok", + "hops": len(path) - 1, + "hop_cost": hop_cost, + "speed_bonus": speed_bonus, + "efficiency_penalty": efficiency_penalty, + } + +# ── GAME STATE ──────────────────────────────────────────────────────────────── +class GameState: + def __init__(self): + self.reset() + + 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 + +game = GameState() + +# ── CONNECTION MANAGER ──────────────────────────────────────────────────────── +class ConnMgr: + def __init__(self): + self.players = {} + self.hosts = [] + + async def connect_player(self, pid, ws): + await ws.accept() + self.players[pid] = ws + + async def connect_host(self, ws): + await ws.accept() + self.hosts.append(ws) + + def disconnect(self, pid): + self.players.pop(pid, None) + game.players.pop(pid, None) + + def disconnect_host(self, ws): + if ws in self.hosts: + self.hosts.remove(ws) + + async def send(self, pid, data): + ws = self.players.get(pid) + if ws: + try: + await ws.send_json(data) + except Exception: + pass + + async def broadcast_players(self, data): + dead = [] + for pid, ws in list(self.players.items()): + try: + await ws.send_json(data) + except Exception: + dead.append(pid) + for d in dead: + self.disconnect(d) + + async def broadcast_hosts(self, data): + dead = [] + for ws in list(self.hosts): + try: + await ws.send_json(data) + except Exception: + dead.append(ws) + for d in dead: + self.disconnect_host(d) + + async def broadcast_all(self, data): + await self.broadcast_players(data) + await self.broadcast_hosts(data) + +mgr = ConnMgr() + +def 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) + ) + +# ── ROUTES ──────────────────────────────────────────────────────────────────── +@app.get("/", response_class=HTMLResponse) +async def player_page(request: Request): + return templates.TemplateResponse("player.html", {"request": request}) + +@app.get("/host", response_class=HTMLResponse) +async def host_page(request: Request): + return templates.TemplateResponse("host.html", {"request": request}) + +@app.get("/scores", response_class=HTMLResponse) +async def scores_page(request: Request): + return templates.TemplateResponse("scores.html", {"request": request}) + +@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" + ).fetchall() + result = [] + for s in sessions: + players = conn.execute( + "SELECT player, score, rank, dept FROM scores WHERE session_id=? ORDER BY rank", + (s["id"],) + ).fetchall() + 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 + ], + }) + 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) + try: + await mgr.send(pid, {"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] + 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"}) + 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}) + await mgr.broadcast_hosts({ + "type": "lobby_update", + "players": [p.get("display", p["name"]) for p in game.players.values()], + "count": len(game.players), + }) + + elif msg["type"] == "submit_path": + if game.phase != "playing": + continue + player = game.players.get(pid) + if not player or player["submitted"]: + 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), + }) + await mgr.broadcast_hosts({ + "type": "submission_update", + "submitted": sum(1 for p in game.players.values() if p["submitted"]), + "total": len(game.players), + "submissions": list(game.submissions.values()), + }) + + except WebSocketDisconnect: + mgr.disconnect(pid) + await mgr.broadcast_hosts({ + "type": "lobby_update", + "players": [p.get("display", p["name"]) for p in game.players.values()], + "count": len(game.players), + }) + +# ── HOST WEBSOCKET ──────────────────────────────────────────────────────────── +@app.websocket("/ws/host") +async def host_ws(ws: WebSocket): + await mgr.connect_host(ws) + try: + await ws.send_json({ + "type": "lobby_update", + "players": [p.get("display", p["name"]) 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 = {} + for p in game.players.values(): + p["submitted"] = False + await mgr.broadcast_all({ + "type": "round_start", + "round": game.current_round, + "total_rounds": game.total_rounds, + "grid": game.grid, + "time_limit": game.time_limit, + }) + + elif msg["type"] == "end_round": + game.phase = "results" + lb = leaderboard() + save_session() + 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, + }) + + 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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c8740a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.111.0 +uvicorn +websockets +jinja2==3.1.4 +python-multipart +starlette==0.37.2