Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import csv | |
| import io | |
| import random | |
| import sqlite3 | |
| from datetime import datetime | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse | |
| # ========================= | |
| # App / DB Config | |
| # ========================= | |
| DATA_DIR = "/data" if os.path.exists("/data") else "." | |
| os.makedirs(DATA_DIR, exist_ok=True) | |
| DB_FILE = os.path.join(DATA_DIR, "classroom_games_results.db") | |
| ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") | |
| app = FastAPI() | |
| # ========================= | |
| # Constants | |
| # ========================= | |
| T_EMPTY, T_SUN, T_MOON = -1, 0, 1 | |
| QUEENS_LEVELS = { | |
| "Q1 - Beginner": {"code": "Q1", "size": 7, "limit": 120}, | |
| "Q2 - Beginner Hard": {"code": "Q2", "size": 7, "limit": 150}, | |
| "Q3 - Intermediate": {"code": "Q3", "size": 8, "limit": 210}, | |
| "Q4 - Intermediate Hard": {"code": "Q4", "size": 8, "limit": 210}, | |
| "Q5 - Advanced": {"code": "Q5", "size": 8, "limit": 210}, | |
| "Q6 - Expert": {"code": "Q6", "size": 9, "limit": 240}, | |
| } | |
| PREMADE_QUEENS_REGIONS = { | |
| "Q1": [ | |
| [1, 1, 0, 0, 0, 2, 2], | |
| [1, 6, 3, 3, 3, 6, 2], | |
| [1, 6, 3, 3, 3, 6, 6], | |
| [6, 6, 6, 3, 6, 6, 6], | |
| [6, 6, 6, 6, 6, 6, 6], | |
| [6, 6, 4, 6, 5, 5, 6], | |
| [6, 6, 4, 6, 5, 5, 6], | |
| ], | |
| "Q2": [ | |
| [1, 1, 1, 1, 0, 0, 0], | |
| [1, 1, 6, 6, 6, 0, 4], | |
| [1, 6, 6, 6, 6, 6, 4], | |
| [1, 6, 5, 5, 5, 6, 4], | |
| [1, 6, 6, 6, 6, 6, 3], | |
| [1, 6, 2, 2, 2, 6, 3], | |
| [2, 2, 2, 2, 3, 3, 3], | |
| ], | |
| "Q3": [ | |
| [0, 0, 0, 0, 7, 7, 7, 7], | |
| [0, 4, 4, 6, 7, 3, 3, 7], | |
| [0, 4, 6, 6, 7, 7, 3, 7], | |
| [0, 4, 6, 1, 7, 7, 3, 7], | |
| [0, 5, 6, 1, 1, 1, 2, 7], | |
| [1, 5, 6, 1, 7, 7, 2, 7], | |
| [1, 5, 5, 1, 7, 2, 2, 7], | |
| [1, 1, 1, 1, 7, 7, 7, 7], | |
| ], | |
| "Q4": [ | |
| [0, 1, 1, 4, 4, 4, 5, 5], | |
| [0, 0, 0, 4, 4, 5, 5, 5], | |
| [0, 4, 4, 4, 5, 5, 5, 7], | |
| [0, 4, 4, 5, 5, 5, 3, 7], | |
| [2, 2, 5, 5, 5, 7, 7, 7], | |
| [2, 5, 5, 5, 7, 7, 7, 6], | |
| [5, 5, 5, 7, 7, 7, 6, 6], | |
| [5, 5, 6, 6, 6, 6, 6, 6], | |
| ], | |
| "Q5": [ | |
| [0, 0, 0, 0, 0, 0, 0, 0], | |
| [5, 5, 5, 5, 0, 0, 0, 0], | |
| [5, 2, 2, 5, 1, 7, 7, 0], | |
| [5, 2, 2, 2, 7, 7, 7, 7], | |
| [5, 3, 3, 3, 7, 7, 7, 7], | |
| [5, 3, 3, 5, 6, 7, 7, 6], | |
| [5, 5, 5, 5, 6, 6, 6, 6], | |
| [4, 4, 6, 6, 6, 6, 6, 6], | |
| ], | |
| "Q6": [ | |
| [2, 2, 2, 2, 2, 8, 6, 6, 6], | |
| [2, 3, 2, 2, 8, 8, 8, 6, 6], | |
| [2, 3, 2, 8, 8, 7, 8, 8, 6], | |
| [2, 3, 8, 8, 7, 7, 7, 8, 8], | |
| [2, 3, 3, 8, 8, 7, 8, 8, 5], | |
| [2, 2, 3, 3, 8, 8, 8, 5, 5], | |
| [2, 1, 3, 3, 3, 8, 5, 5, 4], | |
| [1, 1, 1, 3, 3, 5, 5, 4, 4], | |
| [0, 1, 3, 3, 3, 4, 4, 4, 4], | |
| ], | |
| } | |
| TANGO_LEVELS = { | |
| "T1 - Beginner Easy": {"code": "T1", "size": 6, "seed": 4101, "limit": 90, "clues": 12}, | |
| "T2 - Beginner Hard": {"code": "T2", "size": 6, "seed": 4125, "limit": 120, "clues": 10}, | |
| "T3 - Intermediate Easy": {"code": "T3", "size": 8, "seed": 4208, "limit": 210, "clues": 24}, | |
| "T4 - Intermediate Hard": {"code": "T4", "size": 8, "seed": 4255, "limit": 240, "clues": 20}, | |
| "T5 - Advanced Easy": {"code": "T5", "size": 10, "seed": 4309, "limit": 270, "clues": 36}, | |
| "T6 - Advanced Hard": {"code": "T6", "size": 10, "seed": 4399, "limit": 300, "clues": 30}, | |
| } | |
| QUEENS_PALETTE = [ | |
| "#4A90FF", "#23C18A", "#FFA043", "#8E61FF", | |
| "#F2D94E", "#E11D48", "#B06A35", "#9CA3AF", "#2DD4BF" | |
| ] | |
| TANGO_CACHE = {} | |
| # ========================= | |
| # DB / Scoring | |
| # ========================= | |
| def format_seconds(seconds): | |
| seconds = int(seconds or 0) | |
| return f"{seconds // 60:02d}:{seconds % 60:02d}" | |
| def calculate_score(status, time_seconds, time_limit_seconds, penalty_count): | |
| if status != "SOLVED": | |
| return 0.0 | |
| grace_seconds = 20 | |
| if time_seconds <= grace_seconds: | |
| timing_score = 80.0 | |
| else: | |
| effective_limit = max(1, time_limit_seconds - grace_seconds) | |
| used_after_grace = time_seconds - grace_seconds | |
| timing_score = 80 * (1 - used_after_grace / effective_limit) | |
| timing_score = max(0.0, timing_score) | |
| accuracy_score = max(0.0, 20 - penalty_count * 2) | |
| return round(timing_score + accuracy_score, 2) | |
| def init_db(): | |
| conn = sqlite3.connect(DB_FILE) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS results ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| student_name TEXT NOT NULL, | |
| game_name TEXT NOT NULL, | |
| level_code TEXT NOT NULL, | |
| status TEXT NOT NULL, | |
| time_seconds INTEGER NOT NULL, | |
| time_limit_seconds INTEGER NOT NULL, | |
| penalty_count INTEGER NOT NULL, | |
| total_score REAL NOT NULL, | |
| completed_at TEXT NOT NULL | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS competition_settings ( | |
| id INTEGER PRIMARY KEY CHECK (id = 1), | |
| is_open INTEGER NOT NULL DEFAULT 1 | |
| ) | |
| """) | |
| cur.execute(""" | |
| INSERT OR IGNORE INTO competition_settings (id, is_open) | |
| VALUES (1, 1) | |
| """) | |
| conn.commit() | |
| conn.close() | |
| def save_result(student_name, game_name, level_code, status, time_seconds, time_limit_seconds, penalty_count): | |
| student_name = (student_name or "").strip() | |
| game_name = (game_name or "").strip() | |
| level_code = (level_code or "").strip() | |
| status = (status or "").strip() | |
| if not student_name: | |
| return False, "Missing student name" | |
| if game_name not in ["Queens", "Tango"]: | |
| return False, "Invalid game" | |
| if status not in ["SOLVED", "TIMEOUT"]: | |
| return False, "Invalid status" | |
| time_seconds = int(time_seconds or 0) | |
| time_limit_seconds = int(time_limit_seconds or 0) | |
| penalty_count = int(penalty_count or 0) | |
| total_score = calculate_score(status, time_seconds, time_limit_seconds, penalty_count) | |
| conn = sqlite3.connect(DB_FILE) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| INSERT INTO results ( | |
| student_name, | |
| game_name, | |
| level_code, | |
| status, | |
| time_seconds, | |
| time_limit_seconds, | |
| penalty_count, | |
| total_score, | |
| completed_at | |
| ) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, ( | |
| student_name, | |
| game_name, | |
| level_code, | |
| status, | |
| time_seconds, | |
| time_limit_seconds, | |
| penalty_count, | |
| total_score, | |
| datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| )) | |
| conn.commit() | |
| conn.close() | |
| return True, "Saved" | |
| def get_results_rows(): | |
| conn = sqlite3.connect(DB_FILE) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| SELECT | |
| student_name, | |
| game_name, | |
| level_code, | |
| time_seconds, | |
| penalty_count, | |
| total_score, | |
| completed_at | |
| FROM results | |
| ORDER BY | |
| total_score DESC, | |
| time_seconds ASC, | |
| penalty_count ASC, | |
| completed_at ASC | |
| """) | |
| rows = cur.fetchall() | |
| conn.close() | |
| return [ | |
| { | |
| "name": r[0], | |
| "game": r[1], | |
| "level": r[2], | |
| "time": format_seconds(r[3]), | |
| "penalty": r[4], | |
| "score": r[5], | |
| "completed_at": r[6].split(" ")[1] if " " in r[6] else r[6], | |
| } | |
| for r in rows | |
| ] | |
| def get_summary_rows(): | |
| conn = sqlite3.connect(DB_FILE) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| SELECT | |
| student_name, | |
| game_name, | |
| level_code, | |
| MAX(total_score) AS best_score | |
| FROM results | |
| WHERE status = 'SOLVED' | |
| GROUP BY student_name, game_name, level_code | |
| """) | |
| rows = cur.fetchall() | |
| conn.close() | |
| queens_levels = ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6"] | |
| tango_levels = ["T1", "T2", "T3", "T4", "T5", "T6"] | |
| summary = {} | |
| for student_name, game_name, level_code, best_score in rows: | |
| if student_name not in summary: | |
| summary[student_name] = { | |
| "name": student_name, | |
| "Q1": 0, "Q2": 0, "Q3": 0, "Q4": 0, "Q5": 0, "Q6": 0, | |
| "T1": 0, "T2": 0, "T3": 0, "T4": 0, "T5": 0, "T6": 0, | |
| "queens_total": 0, | |
| "tango_total": 0, | |
| "grand_total": 0, | |
| } | |
| if level_code in summary[student_name]: | |
| summary[student_name][level_code] = round(float(best_score or 0), 2) | |
| for student_name, row in summary.items(): | |
| row["queens_total"] = round(sum(row[level] for level in queens_levels), 2) | |
| row["tango_total"] = round(sum(row[level] for level in tango_levels), 2) | |
| row["grand_total"] = round(row["queens_total"] + row["tango_total"], 2) | |
| return sorted( | |
| summary.values(), | |
| key=lambda x: x["grand_total"], | |
| reverse=True | |
| ) | |
| def is_admin(password): | |
| return password == ADMIN_PASSWORD | |
| def is_competition_open(): | |
| conn = sqlite3.connect(DB_FILE) | |
| cur = conn.cursor() | |
| cur.execute("SELECT is_open FROM competition_settings WHERE id = 1") | |
| row = cur.fetchone() | |
| conn.close() | |
| return bool(row[0]) if row else True | |
| def set_competition_open_value(open_value): | |
| conn = sqlite3.connect(DB_FILE) | |
| cur = conn.cursor() | |
| cur.execute( | |
| "UPDATE competition_settings SET is_open = ? WHERE id = 1", | |
| (1 if open_value else 0,) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| init_db() | |
| # ========================= | |
| # Tango generation | |
| # ========================= | |
| def tango_is_valid_partial(board, n): | |
| half = n // 2 | |
| for row in board: | |
| if row.count(T_SUN) > half or row.count(T_MOON) > half: | |
| return False | |
| for c in range(n - 2): | |
| triple = row[c:c + 3] | |
| if triple[0] != T_EMPTY and triple[0] == triple[1] == triple[2]: | |
| return False | |
| for c in range(n): | |
| col = [board[r][c] for r in range(n)] | |
| if col.count(T_SUN) > half or col.count(T_MOON) > half: | |
| return False | |
| for r in range(n - 2): | |
| triple = col[r:r + 3] | |
| if triple[0] != T_EMPTY and triple[0] == triple[1] == triple[2]: | |
| return False | |
| return True | |
| def tango_is_complete_valid(board, n): | |
| half = n // 2 | |
| for r in range(n): | |
| if board[r].count(T_EMPTY) > 0: | |
| return False | |
| if board[r].count(T_SUN) != half or board[r].count(T_MOON) != half: | |
| return False | |
| for c in range(n): | |
| col = [board[r][c] for r in range(n)] | |
| if col.count(T_SUN) != half or col.count(T_MOON) != half: | |
| return False | |
| return tango_is_valid_partial(board, n) | |
| def generate_tango_solution(n, seed): | |
| rng = random.Random(seed) | |
| board = [[T_EMPTY] * n for _ in range(n)] | |
| cells = [(r, c) for r in range(n) for c in range(n)] | |
| def backtrack(idx): | |
| if idx == len(cells): | |
| return tango_is_complete_valid(board, n) | |
| r, c = cells[idx] | |
| values = [T_SUN, T_MOON] | |
| rng.shuffle(values) | |
| for value in values: | |
| board[r][c] = value | |
| if tango_is_valid_partial(board, n): | |
| if backtrack(idx + 1): | |
| return True | |
| board[r][c] = T_EMPTY | |
| return False | |
| backtrack(0) | |
| return [row[:] for row in board] | |
| def count_tango_solutions(puzzle, n, limit=2): | |
| board = [row[:] for row in puzzle] | |
| cells = [(r, c) for r in range(n) for c in range(n) if board[r][c] == T_EMPTY] | |
| count = 0 | |
| def backtrack(idx): | |
| nonlocal count | |
| if count >= limit: | |
| return | |
| if idx == len(cells): | |
| if tango_is_complete_valid(board, n): | |
| count += 1 | |
| return | |
| r, c = cells[idx] | |
| for value in [T_SUN, T_MOON]: | |
| board[r][c] = value | |
| if tango_is_valid_partial(board, n): | |
| backtrack(idx + 1) | |
| board[r][c] = T_EMPTY | |
| backtrack(0) | |
| return count | |
| def generate_unique_tango(level_name): | |
| if level_name in TANGO_CACHE: | |
| return TANGO_CACHE[level_name] | |
| config = TANGO_LEVELS[level_name] | |
| n = config["size"] | |
| seed = config["seed"] | |
| clues = config["clues"] | |
| rng = random.Random(seed) | |
| solution = generate_tango_solution(n, seed) | |
| puzzle = [row[:] for row in solution] | |
| positions = [(r, c) for r in range(n) for c in range(n)] | |
| rng.shuffle(positions) | |
| cells_to_remove = n * n - clues | |
| for r, c in positions: | |
| if cells_to_remove <= 0: | |
| break | |
| old = puzzle[r][c] | |
| puzzle[r][c] = T_EMPTY | |
| if count_tango_solutions(puzzle, n, limit=2) == 1: | |
| cells_to_remove -= 1 | |
| else: | |
| puzzle[r][c] = old | |
| fixed = [[puzzle[r][c] != T_EMPTY for c in range(n)] for r in range(n)] | |
| TANGO_CACHE[level_name] = { | |
| "puzzle": puzzle, | |
| "solution": solution, | |
| "fixed": fixed, | |
| } | |
| return TANGO_CACHE[level_name] | |
| # ========================= | |
| # API | |
| # ========================= | |
| async def index(): | |
| queens_levels_json = json.dumps(QUEENS_LEVELS) | |
| tango_levels_json = json.dumps(TANGO_LEVELS) | |
| queens_regions_json = json.dumps(PREMADE_QUEENS_REGIONS) | |
| queens_palette_json = json.dumps(QUEENS_PALETTE) | |
| return HTMLResponse(f""" | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" /> | |
| <title>Queens & Tango Classroom Game</title> | |
| <style> | |
| :root {{ | |
| --bg: #f6f8fb; | |
| --card: #ffffff; | |
| --text: #102033; | |
| --muted: #64748b; | |
| --primary: #2563eb; | |
| --primary-soft: #dbeafe; | |
| --orange: #f97316; | |
| --border: rgba(15, 23, 42, 0.12); | |
| --shadow: 0 12px 32px rgba(15, 23, 42, 0.08); | |
| --cell-size: min(10.5vw, 54px); | |
| --queen-font-size: min(7vw, 30px); | |
| --tango-font-size: min(6vw, 26px); | |
| }} | |
| * {{ | |
| box-sizing: border-box; | |
| }} | |
| body {{ | |
| margin: 0; | |
| font-family: Inter, Arial, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| }} | |
| .container {{ | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 18px; | |
| }} | |
| .title {{ | |
| font-size: clamp(28px, 5vw, 42px); | |
| font-weight: 900; | |
| text-align: center; | |
| margin: 8px 0 22px; | |
| letter-spacing: -0.04em; | |
| }} | |
| .card {{ | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 18px; | |
| box-shadow: var(--shadow); | |
| padding: 18px; | |
| margin-bottom: 16px; | |
| }} | |
| details.card {{ | |
| padding: 0; | |
| overflow: hidden; | |
| }} | |
| summary {{ | |
| padding: 16px 18px; | |
| cursor: pointer; | |
| font-weight: 800; | |
| list-style: none; | |
| }} | |
| summary::-webkit-details-marker {{ | |
| display: none; | |
| }} | |
| .rules-body {{ | |
| padding: 0 18px 18px; | |
| color: var(--muted); | |
| line-height: 1.55; | |
| }} | |
| label {{ | |
| display: block; | |
| font-weight: 800; | |
| color: var(--primary); | |
| margin-bottom: 8px; | |
| }} | |
| input, select {{ | |
| width: 100%; | |
| border: 1px solid var(--border); | |
| background: #fff; | |
| border-radius: 12px; | |
| padding: 13px 14px; | |
| font-size: 16px; | |
| outline: none; | |
| }} | |
| input:focus, select:focus {{ | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 3px var(--primary-soft); | |
| }} | |
| .tabs {{ | |
| display: flex; | |
| gap: 8px; | |
| border-bottom: 1px solid var(--border); | |
| margin: 18px 0; | |
| overflow-x: auto; | |
| }} | |
| .tab-btn {{ | |
| border: none; | |
| background: transparent; | |
| color: var(--text); | |
| padding: 12px 18px; | |
| font-size: 16px; | |
| cursor: pointer; | |
| border-bottom: 3px solid transparent; | |
| white-space: nowrap; | |
| }} | |
| .tab-btn.active {{ | |
| color: var(--orange); | |
| border-bottom-color: var(--orange); | |
| font-weight: 800; | |
| }} | |
| .tab-panel {{ | |
| display: none; | |
| }} | |
| .tab-panel.active {{ | |
| display: block; | |
| }} | |
| .primary-btn, .secondary-btn, .danger-btn {{ | |
| width: 100%; | |
| border: none; | |
| border-radius: 14px; | |
| padding: 14px 18px; | |
| font-size: 17px; | |
| font-weight: 900; | |
| cursor: pointer; | |
| margin: 10px 0; | |
| }} | |
| .primary-btn {{ | |
| background: var(--primary); | |
| color: white; | |
| }} | |
| .secondary-btn {{ | |
| background: #e5e7eb; | |
| color: #111827; | |
| }} | |
| .danger-btn {{ | |
| background: #fee2e2; | |
| color: #991b1b; | |
| }} | |
| .grid-actions {{ | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 10px; | |
| }} | |
| .game-panel {{ | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 24px; | |
| box-shadow: var(--shadow); | |
| padding: 18px; | |
| margin: 20px auto 0; | |
| max-width: 760px; | |
| }} | |
| .game-header {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| }} | |
| .game-title {{ | |
| font-size: 26px; | |
| font-weight: 900; | |
| }} | |
| .game-subtitle {{ | |
| color: var(--muted); | |
| font-size: 14px; | |
| }} | |
| .timer-pill {{ | |
| font-weight: 900; | |
| background: #eef2ff; | |
| padding: 9px 13px; | |
| border-radius: 999px; | |
| white-space: nowrap; | |
| }} | |
| .game-message {{ | |
| text-align: center; | |
| font-weight: 800; | |
| margin: 12px 0 16px; | |
| }} | |
| .empty-state {{ | |
| text-align: center; | |
| padding: 36px 18px; | |
| font-weight: 800; | |
| color: var(--muted); | |
| }} | |
| .js-board {{ | |
| display: grid; | |
| gap: 0; | |
| width: fit-content; | |
| max-width: 96vw; | |
| margin: auto; | |
| touch-action: manipulation; | |
| }} | |
| .js-cell {{ | |
| width: var(--cell-size); | |
| height: var(--cell-size); | |
| min-width: var(--cell-size); | |
| min-height: var(--cell-size); | |
| max-width: var(--cell-size); | |
| max-height: var(--cell-size); | |
| border: 1px solid rgba(0,0,0,0.35); | |
| border-radius: 0; | |
| padding: 0; | |
| margin: 0; | |
| color: #111; | |
| font-weight: 900; | |
| cursor: pointer; | |
| user-select: none; | |
| touch-action: manipulation; | |
| background: white; | |
| }} | |
| .queens-board .js-cell {{ | |
| font-size: var(--queen-font-size); | |
| }} | |
| .tango-board .js-cell {{ | |
| font-size: var(--tango-font-size); | |
| }} | |
| .fixed-cell {{ | |
| border: 3px solid #111 !important; | |
| }} | |
| table {{ | |
| width: 100%; | |
| border-collapse: collapse; | |
| background: white; | |
| overflow: hidden; | |
| border-radius: 12px; | |
| }} | |
| th, td {{ | |
| padding: 10px; | |
| border-bottom: 1px solid var(--border); | |
| text-align: left; | |
| font-size: 14px; | |
| white-space: nowrap; | |
| }} | |
| th {{ | |
| background: #f1f5f9; | |
| font-weight: 900; | |
| }} | |
| .summary-title {{ | |
| margin-top: 26px; | |
| margin-bottom: 10px; | |
| font-size: 22px; | |
| font-weight: 900; | |
| }} | |
| .status {{ | |
| font-weight: 800; | |
| color: var(--muted); | |
| margin: 8px 0; | |
| }} | |
| @media (max-width: 640px) {{ | |
| .container {{ | |
| padding: 10px; | |
| }} | |
| .title {{ | |
| font-size: 31px; | |
| line-height: 1.08; | |
| }} | |
| .card {{ | |
| padding: 14px; | |
| border-radius: 14px; | |
| }} | |
| .game-panel {{ | |
| padding: 12px; | |
| border-radius: 16px; | |
| }} | |
| .game-header {{ | |
| flex-direction: column; | |
| text-align: center; | |
| }} | |
| .grid-actions {{ | |
| grid-template-columns: 1fr; | |
| }} | |
| th, td {{ | |
| font-size: 12px; | |
| padding: 8px 6px; | |
| }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <main class="container"> | |
| <h1 class="title">π Queens & Tango Classroom Game</h1> | |
| <details class="card"> | |
| <summary>π Game Rules & Scoring</summary> | |
| <div class="rules-body"> | |
| <h3>π Queens Rules</h3> | |
| <p>Place exactly one queen in each row, column, and color region. Queens cannot touch each other, even diagonally.</p> | |
| <h3>π Tango Rules</h3> | |
| <p>Fill the grid using β and π. Each row and column must have an equal number of both symbols. Avoid three identical symbols in a row or column.</p> | |
| <h3>π Scoring</h3> | |
| <p>Total score is out of 100. Timing = 80 points, accuracy = 20 points. The first 20 seconds are a grace period. Each correction removes 2 accuracy points. Timeout gives 0 points.</p> | |
| </div> | |
| </details> | |
| <section class="card"> | |
| <label for="studentName">Student Name</label> | |
| <input id="studentName" placeholder="Enter your name here" /> | |
| </section> | |
| <nav class="tabs"> | |
| <button class="tab-btn active" onclick="showTab('queens', this)">Queens Game</button> | |
| <button class="tab-btn" onclick="showTab('tango', this)">Tango Game</button> | |
| <button class="tab-btn" onclick="showTab('teacher', this)">Teacher Dashboard</button> | |
| </nav> | |
| <section id="queens" class="tab-panel active"> | |
| <div class="card"> | |
| <label for="queensLevel">Choose Queens Level</label> | |
| <select id="queensLevel"></select> | |
| </div> | |
| <button class="primary-btn" onclick="startQueens()">Start / Restart Queens Level</button> | |
| <div id="queensBoardArea" class="game-panel"> | |
| <div class="empty-state">Enter your name, choose a Queens level, then press Start.</div> | |
| </div> | |
| </section> | |
| <section id="tango" class="tab-panel"> | |
| <div class="card"> | |
| <label for="tangoLevel">Choose Tango Level</label> | |
| <select id="tangoLevel"></select> | |
| </div> | |
| <button class="primary-btn" onclick="startTango()">Start / Restart Tango Level</button> | |
| <div id="tangoBoardArea" class="game-panel"> | |
| <div class="empty-state">Enter your name, choose a Tango level, then press Start.</div> | |
| </div> | |
| </section> | |
| <section id="teacher" class="tab-panel"> | |
| <div class="card"> | |
| <h2>π§βπ« Teacher Dashboard</h2> | |
| <label for="adminPassword">Admin Password</label> | |
| <input id="adminPassword" type="password" placeholder="Enter admin password" /> | |
| <div class="grid-actions"> | |
| <button class="secondary-btn" onclick="refreshResults()">Refresh Results</button> | |
| <button class="secondary-btn" onclick="exportCsv()">Export CSV</button> | |
| <button class="danger-btn" onclick="clearResults()">Clear Results</button> | |
| </div> | |
| <div class="grid-actions"> | |
| <button class="secondary-btn" onclick="setCompetition(true)">Open Competition</button> | |
| <button class="secondary-btn" onclick="setCompetition(false)">Lock Competition</button> | |
| <button class="secondary-btn" onclick="checkCompetition()">Check Status</button> | |
| </div> | |
| <div id="adminStatus" class="status"></div> | |
| <h3 class="summary-title">π Detailed Attempts</h3> | |
| <div style="overflow-x:auto;"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Name</th> | |
| <th>Game</th> | |
| <th>Level</th> | |
| <th>Time</th> | |
| <th>Penalty</th> | |
| <th>Score</th> | |
| <th>Completed</th> | |
| </tr> | |
| </thead> | |
| <tbody id="resultsBody"> | |
| <tr><td colspan="7">No results loaded yet.</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <h3 class="summary-title">π Player Summary by Level</h3> | |
| <div style="overflow-x:auto;"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Name</th> | |
| <th>Q1</th> | |
| <th>Q2</th> | |
| <th>Q3</th> | |
| <th>Q4</th> | |
| <th>Q5</th> | |
| <th>Q6</th> | |
| <th>Queens Total</th> | |
| <th>T1</th> | |
| <th>T2</th> | |
| <th>T3</th> | |
| <th>T4</th> | |
| <th>T5</th> | |
| <th>T6</th> | |
| <th>Tango Total</th> | |
| <th>Grand Total</th> | |
| </tr> | |
| </thead> | |
| <tbody id="summaryBody"> | |
| <tr><td colspan="16">No summary loaded yet.</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| const QUEENS_LEVELS = {queens_levels_json}; | |
| const TANGO_LEVELS = {tango_levels_json}; | |
| const QUEENS_REGIONS = {queens_regions_json}; | |
| const QUEENS_PALETTE = {queens_palette_json}; | |
| let currentGameInterval = null; | |
| function populateLevels() {{ | |
| const qSelect = document.getElementById("queensLevel"); | |
| const tSelect = document.getElementById("tangoLevel"); | |
| Object.keys(QUEENS_LEVELS).forEach(level => {{ | |
| const opt = document.createElement("option"); | |
| opt.value = level; | |
| opt.textContent = level; | |
| qSelect.appendChild(opt); | |
| }}); | |
| Object.keys(TANGO_LEVELS).forEach(level => {{ | |
| const opt = document.createElement("option"); | |
| opt.value = level; | |
| opt.textContent = level; | |
| tSelect.appendChild(opt); | |
| }}); | |
| }} | |
| function showTab(id, btn) {{ | |
| document.querySelectorAll(".tab-panel").forEach(p => p.classList.remove("active")); | |
| document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); | |
| document.getElementById(id).classList.add("active"); | |
| btn.classList.add("active"); | |
| }} | |
| function fmt(seconds) {{ | |
| seconds = Math.max(0, Math.floor(seconds)); | |
| const m = Math.floor(seconds / 60).toString().padStart(2, "0"); | |
| const s = Math.floor(seconds % 60).toString().padStart(2, "0"); | |
| return `${{m}}:${{s}}`; | |
| }} | |
| function getStudentName() {{ | |
| return document.getElementById("studentName").value.trim(); | |
| }} | |
| async function postJson(url, payload) {{ | |
| const res = await fetch(url, {{ | |
| method: "POST", | |
| headers: {{ "Content-Type": "application/json" }}, | |
| body: JSON.stringify(payload) | |
| }}); | |
| return await res.json(); | |
| }} | |
| async function saveResult(payload) {{ | |
| try {{ | |
| const data = await postJson("/save_result", payload); | |
| console.log("Saved", data); | |
| }} catch (e) {{ | |
| console.error("Save failed", e); | |
| }} | |
| }} | |
| async function ensureCompetitionOpen() {{ | |
| const res = await fetch("/competition_status"); | |
| const data = await res.json(); | |
| return data.is_open; | |
| }} | |
| function stopCurrentTimer() {{ | |
| if (currentGameInterval) {{ | |
| clearInterval(currentGameInterval); | |
| currentGameInterval = null; | |
| }} | |
| }} | |
| async function startQueens() {{ | |
| const studentName = getStudentName(); | |
| const levelName = document.getElementById("queensLevel").value; | |
| const area = document.getElementById("queensBoardArea"); | |
| if (!studentName) {{ | |
| area.innerHTML = `<div class="empty-state">Please enter your name first.</div>`; | |
| return; | |
| }} | |
| const open = await ensureCompetitionOpen(); | |
| if (!open) {{ | |
| area.innerHTML = `<div class="empty-state">π Competition is locked. Please wait for the teacher to open it.</div>`; | |
| return; | |
| }} | |
| stopCurrentTimer(); | |
| const cfg = QUEENS_LEVELS[levelName]; | |
| const levelCode = cfg.code; | |
| const n = cfg.size; | |
| const timeLimit = cfg.limit; | |
| const regs = QUEENS_REGIONS[levelCode]; | |
| let userBoard = Array.from({{ length: n }}, () => Array(n).fill(0)); | |
| let penalty = 0; | |
| let gameOver = false; | |
| let saved = false; | |
| const startTime = Date.now(); | |
| area.innerHTML = ` | |
| <div class="game-header"> | |
| <div> | |
| <div class="game-title">π Queens ${{levelCode}}</div> | |
| <div class="game-subtitle">Player: ${{studentName}} Β· Time Limit: ${{fmt(timeLimit)}}</div> | |
| </div> | |
| <div id="qTimer" class="timer-pill">00:00 / ${{fmt(timeLimit)}}</div> | |
| </div> | |
| <div id="qMessage" class="game-message">Place one queen per row, column, and color region.</div> | |
| <div id="qBoard" class="js-board queens-board"></div> | |
| `; | |
| const boardEl = document.getElementById("qBoard"); | |
| const msgEl = document.getElementById("qMessage"); | |
| const timerEl = document.getElementById("qTimer"); | |
| function submit(status, seconds) {{ | |
| if (saved) return; | |
| saved = true; | |
| saveResult({{ | |
| student_name: studentName, | |
| game_name: "Queens", | |
| level_code: levelCode, | |
| status, | |
| time_seconds: seconds, | |
| time_limit_seconds: timeLimit, | |
| penalty_count: penalty | |
| }}); | |
| }} | |
| function displayBoard() {{ | |
| const display = userBoard.map(row => row.slice()); | |
| const queens = []; | |
| for (let r = 0; r < n; r++) {{ | |
| for (let c = 0; c < n; c++) {{ | |
| if (userBoard[r][c] === 2) queens.push([r, c]); | |
| }} | |
| }} | |
| for (const [qr, qc] of queens) {{ | |
| const queenRegion = regs[qr][qc]; | |
| for (let r = 0; r < n; r++) {{ | |
| for (let c = 0; c < n; c++) {{ | |
| const sameRow = r === qr; | |
| const sameCol = c === qc; | |
| const adjacent = Math.max(Math.abs(r - qr), Math.abs(c - qc)) === 1; | |
| const sameRegion = regs[r][c] === queenRegion; | |
| if ((sameRow || sameCol || adjacent || sameRegion) && display[r][c] === 0) {{ | |
| display[r][c] = 3; | |
| }} | |
| }} | |
| }} | |
| }} | |
| return display; | |
| }} | |
| function symbol(value) {{ | |
| if (value === 2) return "β"; | |
| if (value === 1 || value === 3) return "β"; | |
| return ""; | |
| }} | |
| function checkWin() {{ | |
| const queens = []; | |
| for (let r = 0; r < n; r++) {{ | |
| for (let c = 0; c < n; c++) {{ | |
| if (userBoard[r][c] === 2) queens.push([r, c]); | |
| }} | |
| }} | |
| if (queens.length !== n) return false; | |
| const rows = new Set(); | |
| const cols = new Set(); | |
| const regions = new Set(); | |
| for (const [r, c] of queens) {{ | |
| if (rows.has(r) || cols.has(c) || regions.has(regs[r][c])) return false; | |
| rows.add(r); | |
| cols.add(c); | |
| regions.add(regs[r][c]); | |
| for (const [rr, cc] of queens) {{ | |
| if ((r !== rr || c !== cc) && Math.max(Math.abs(r - rr), Math.abs(c - cc)) === 1) {{ | |
| return false; | |
| }} | |
| }} | |
| }} | |
| return true; | |
| }} | |
| function render() {{ | |
| const display = displayBoard(); | |
| boardEl.innerHTML = ""; | |
| boardEl.style.gridTemplateColumns = `repeat(${{n}}, var(--cell-size))`; | |
| for (let r = 0; r < n; r++) {{ | |
| for (let c = 0; c < n; c++) {{ | |
| const cell = document.createElement("button"); | |
| cell.className = "js-cell"; | |
| cell.textContent = symbol(display[r][c]); | |
| cell.style.background = QUEENS_PALETTE[regs[r][c] % QUEENS_PALETTE.length]; | |
| cell.addEventListener("click", () => {{ | |
| if (gameOver) return; | |
| const current = userBoard[r][c]; | |
| if (current === 0) {{ | |
| userBoard[r][c] = 1; | |
| }} else if (current === 1) {{ | |
| userBoard[r][c] = 2; | |
| }} else if (current === 2) {{ | |
| userBoard[r][c] = 0; | |
| penalty += 1; | |
| }} | |
| render(); | |
| if (checkWin()) {{ | |
| gameOver = true; | |
| stopCurrentTimer(); | |
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | |
| msgEl.innerHTML = `π Solved! Time: ${{fmt(elapsed)}} Β· Removed queens: ${{penalty}} Β· Saved to leaderboard`; | |
| timerEl.textContent = `Final: ${{fmt(elapsed)}}`; | |
| submit("SOLVED", elapsed); | |
| }} | |
| }}); | |
| boardEl.appendChild(cell); | |
| }} | |
| }} | |
| }} | |
| function tick() {{ | |
| if (gameOver) return; | |
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | |
| const remaining = Math.max(0, timeLimit - elapsed); | |
| timerEl.textContent = `${{fmt(elapsed)}} / ${{fmt(timeLimit)}} Β· Left: ${{fmt(remaining)}}`; | |
| if (elapsed >= timeLimit) {{ | |
| gameOver = true; | |
| stopCurrentTimer(); | |
| msgEl.innerHTML = "β° Time is over. Result saved as timeout."; | |
| timerEl.textContent = `Final: ${{fmt(timeLimit)}}`; | |
| submit("TIMEOUT", timeLimit); | |
| }} | |
| }} | |
| render(); | |
| tick(); | |
| currentGameInterval = setInterval(tick, 1000); | |
| }} | |
| async function startTango() {{ | |
| const studentName = getStudentName(); | |
| const levelName = document.getElementById("tangoLevel").value; | |
| const area = document.getElementById("tangoBoardArea"); | |
| if (!studentName) {{ | |
| area.innerHTML = `<div class="empty-state">Please enter your name first.</div>`; | |
| return; | |
| }} | |
| const open = await ensureCompetitionOpen(); | |
| if (!open) {{ | |
| area.innerHTML = `<div class="empty-state">π Competition is locked. Please wait for the teacher to open it.</div>`; | |
| return; | |
| }} | |
| stopCurrentTimer(); | |
| const levelDataRes = await fetch(`/tango_level?level=${{encodeURIComponent(levelName)}}`); | |
| const levelData = await levelDataRes.json(); | |
| const cfg = levelData.config; | |
| const levelCode = cfg.code; | |
| const n = cfg.size; | |
| const timeLimit = cfg.limit; | |
| let board = levelData.puzzle; | |
| const solution = levelData.solution; | |
| const fixed = levelData.fixed; | |
| let penalty = 0; | |
| let gameOver = false; | |
| let saved = false; | |
| const startTime = Date.now(); | |
| area.innerHTML = ` | |
| <div class="game-header"> | |
| <div> | |
| <div class="game-title">π Tango ${{levelCode}}</div> | |
| <div class="game-subtitle">Player: ${{studentName}} Β· Time Limit: ${{fmt(timeLimit)}}</div> | |
| </div> | |
| <div id="tTimer" class="timer-pill">00:00 / ${{fmt(timeLimit)}}</div> | |
| </div> | |
| <div id="tMessage" class="game-message">Fill the board using β and π.</div> | |
| <div id="tBoard" class="js-board tango-board"></div> | |
| `; | |
| const boardEl = document.getElementById("tBoard"); | |
| const msgEl = document.getElementById("tMessage"); | |
| const timerEl = document.getElementById("tTimer"); | |
| function submit(status, seconds) {{ | |
| if (saved) return; | |
| saved = true; | |
| saveResult({{ | |
| student_name: studentName, | |
| game_name: "Tango", | |
| level_code: levelCode, | |
| status, | |
| time_seconds: seconds, | |
| time_limit_seconds: timeLimit, | |
| penalty_count: penalty | |
| }}); | |
| }} | |
| function symbol(value) {{ | |
| if (value === 0) return "β"; | |
| if (value === 1) return "π"; | |
| return ""; | |
| }} | |
| function color(value) {{ | |
| if (value === 0) return "#FFD166"; | |
| if (value === 1) return "#7AA7FF"; | |
| return "#FFFFFF"; | |
| }} | |
| function checkWin() {{ | |
| for (let r = 0; r < n; r++) {{ | |
| for (let c = 0; c < n; c++) {{ | |
| if (board[r][c] !== solution[r][c]) return false; | |
| }} | |
| }} | |
| return true; | |
| }} | |
| function render() {{ | |
| boardEl.innerHTML = ""; | |
| boardEl.style.gridTemplateColumns = `repeat(${{n}}, var(--cell-size))`; | |
| for (let r = 0; r < n; r++) {{ | |
| for (let c = 0; c < n; c++) {{ | |
| const cell = document.createElement("button"); | |
| cell.className = fixed[r][c] ? "js-cell fixed-cell" : "js-cell"; | |
| cell.textContent = symbol(board[r][c]); | |
| cell.style.background = color(board[r][c]); | |
| cell.addEventListener("click", () => {{ | |
| if (gameOver || fixed[r][c]) return; | |
| const current = board[r][c]; | |
| if (current === -1) {{ | |
| board[r][c] = 0; | |
| }} else if (current === 0) {{ | |
| board[r][c] = 1; | |
| }} else if (current === 1) {{ | |
| board[r][c] = -1; | |
| penalty += 1; | |
| }} | |
| render(); | |
| if (checkWin()) {{ | |
| gameOver = true; | |
| stopCurrentTimer(); | |
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | |
| msgEl.innerHTML = `π Solved! Time: ${{fmt(elapsed)}} Β· Corrections: ${{penalty}} Β· Saved to leaderboard`; | |
| timerEl.textContent = `Final: ${{fmt(elapsed)}}`; | |
| submit("SOLVED", elapsed); | |
| }} | |
| }}); | |
| boardEl.appendChild(cell); | |
| }} | |
| }} | |
| }} | |
| function tick() {{ | |
| if (gameOver) return; | |
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | |
| const remaining = Math.max(0, timeLimit - elapsed); | |
| timerEl.textContent = `${{fmt(elapsed)}} / ${{fmt(timeLimit)}} Β· Left: ${{fmt(remaining)}}`; | |
| if (elapsed >= timeLimit) {{ | |
| gameOver = true; | |
| stopCurrentTimer(); | |
| msgEl.innerHTML = "β° Time is over. Result saved as timeout."; | |
| timerEl.textContent = `Final: ${{fmt(timeLimit)}}`; | |
| submit("TIMEOUT", timeLimit); | |
| }} | |
| }} | |
| render(); | |
| tick(); | |
| currentGameInterval = setInterval(tick, 1000); | |
| }} | |
| async function refreshResults() {{ | |
| const res = await fetch("/results"); | |
| const data = await res.json(); | |
| const body = document.getElementById("resultsBody"); | |
| body.innerHTML = ""; | |
| if (!data.results.length) {{ | |
| body.innerHTML = `<tr><td colspan="7">No results yet.</td></tr>`; | |
| }} else {{ | |
| data.results.forEach(row => {{ | |
| const tr = document.createElement("tr"); | |
| tr.innerHTML = ` | |
| <td>${{row.name}}</td> | |
| <td>${{row.game}}</td> | |
| <td>${{row.level}}</td> | |
| <td><strong>${{row.time}}</strong></td> | |
| <td>${{row.penalty}}</td> | |
| <td><strong>${{row.score}}</strong></td> | |
| <td>${{row.completed_at}}</td> | |
| `; | |
| body.appendChild(tr); | |
| }}); | |
| }} | |
| refreshSummary(); | |
| }} | |
| async function refreshSummary() {{ | |
| const res = await fetch("/summary_results"); | |
| const data = await res.json(); | |
| const body = document.getElementById("summaryBody"); | |
| body.innerHTML = ""; | |
| if (!data.summary.length) {{ | |
| body.innerHTML = `<tr><td colspan="16">No solved results yet.</td></tr>`; | |
| return; | |
| }} | |
| data.summary.forEach(row => {{ | |
| const tr = document.createElement("tr"); | |
| tr.innerHTML = ` | |
| <td><strong>${{row.name}}</strong></td> | |
| <td>${{row.Q1}}</td> | |
| <td>${{row.Q2}}</td> | |
| <td>${{row.Q3}}</td> | |
| <td>${{row.Q4}}</td> | |
| <td>${{row.Q5}}</td> | |
| <td>${{row.Q6}}</td> | |
| <td><strong>${{row.queens_total}}</strong></td> | |
| <td>${{row.T1}}</td> | |
| <td>${{row.T2}}</td> | |
| <td>${{row.T3}}</td> | |
| <td>${{row.T4}}</td> | |
| <td>${{row.T5}}</td> | |
| <td>${{row.T6}}</td> | |
| <td><strong>${{row.tango_total}}</strong></td> | |
| <td><strong>${{row.grand_total}}</strong></td> | |
| `; | |
| body.appendChild(tr); | |
| }}); | |
| }} | |
| async function clearResults() {{ | |
| const password = document.getElementById("adminPassword").value; | |
| const data = await postJson("/clear_results", {{ password }}); | |
| document.getElementById("adminStatus").textContent = data.message; | |
| refreshResults(); | |
| }} | |
| async function setCompetition(openValue) {{ | |
| const password = document.getElementById("adminPassword").value; | |
| const data = await postJson("/set_competition", {{ password, open: openValue }}); | |
| document.getElementById("adminStatus").textContent = data.message; | |
| }} | |
| async function checkCompetition() {{ | |
| const res = await fetch("/competition_status"); | |
| const data = await res.json(); | |
| document.getElementById("adminStatus").textContent = data.is_open | |
| ? "β Competition is currently open." | |
| : "π Competition is currently locked."; | |
| }} | |
| function exportCsv() {{ | |
| const password = document.getElementById("adminPassword").value; | |
| window.location.href = `/export_csv?password=${{encodeURIComponent(password)}}`; | |
| }} | |
| populateLevels(); | |
| </script> | |
| </body> | |
| </html> | |
| """) | |
| async def competition_status(): | |
| return {"is_open": is_competition_open()} | |
| async def set_competition(request: Request): | |
| data = await request.json() | |
| if not is_admin(data.get("password", "")): | |
| return JSONResponse({"ok": False, "message": "β Invalid admin password."}, status_code=403) | |
| open_value = bool(data.get("open", True)) | |
| set_competition_open_value(open_value) | |
| return { | |
| "ok": True, | |
| "message": "β Competition opened." if open_value else "π Competition locked." | |
| } | |
| async def save_result_api(request: Request): | |
| data = await request.json() | |
| ok, message = save_result( | |
| data.get("student_name", ""), | |
| data.get("game_name", ""), | |
| data.get("level_code", ""), | |
| data.get("status", ""), | |
| int(data.get("time_seconds", 0)), | |
| int(data.get("time_limit_seconds", 0)), | |
| int(data.get("penalty_count", 0)), | |
| ) | |
| return {"ok": ok, "message": message} | |
| async def results(): | |
| return {"results": get_results_rows()} | |
| async def summary_results(): | |
| return {"summary": get_summary_rows()} | |
| async def clear_results(request: Request): | |
| data = await request.json() | |
| if not is_admin(data.get("password", "")): | |
| return JSONResponse({"ok": False, "message": "β Invalid admin password."}, status_code=403) | |
| conn = sqlite3.connect(DB_FILE) | |
| cur = conn.cursor() | |
| cur.execute("DELETE FROM results") | |
| conn.commit() | |
| conn.close() | |
| return {"ok": True, "message": "β Results cleared."} | |
| async def export_csv(password: str = ""): | |
| if not is_admin(password): | |
| return JSONResponse({"ok": False, "message": "β Invalid admin password."}, status_code=403) | |
| rows = get_results_rows() | |
| output = io.StringIO() | |
| writer = csv.writer(output) | |
| writer.writerow([ | |
| "Name", | |
| "Game", | |
| "Level", | |
| "Time Taken", | |
| "Penalty", | |
| "Total Score", | |
| "Completed At" | |
| ]) | |
| for row in rows: | |
| writer.writerow([ | |
| row["name"], | |
| row["game"], | |
| row["level"], | |
| row["time"], | |
| row["penalty"], | |
| row["score"], | |
| row["completed_at"], | |
| ]) | |
| output.seek(0) | |
| return StreamingResponse( | |
| iter([output.getvalue()]), | |
| media_type="text/csv", | |
| headers={"Content-Disposition": "attachment; filename=classroom_game_results.csv"} | |
| ) | |
| async def tango_level(level: str): | |
| if level not in TANGO_LEVELS: | |
| return JSONResponse({"error": "Invalid Tango level"}, status_code=400) | |
| data = generate_unique_tango(level) | |
| config = TANGO_LEVELS[level] | |
| return { | |
| "config": config, | |
| "puzzle": data["puzzle"], | |
| "solution": data["solution"], | |
| "fixed": data["fixed"], | |
| } | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run( | |
| app, | |
| host="0.0.0.0", | |
| port=7860 | |
| ) |