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 # ========================= @app.get("/", response_class=HTMLResponse) 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""" Queens & Tango Classroom Game

๐Ÿ‘‘ Queens & Tango Classroom Game

๐Ÿ“˜ Game Rules & Scoring

๐Ÿ‘‘ Queens Rules

Place exactly one queen in each row, column, and color region. Queens cannot touch each other, even diagonally.

๐ŸŒ— Tango Rules

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.

๐Ÿ† Scoring

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.

Enter your name, choose a Queens level, then press Start.
Enter your name, choose a Tango level, then press Start.

๐Ÿง‘โ€๐Ÿซ Teacher Dashboard

๐Ÿ“‹ Detailed Attempts

Name Game Level Time Penalty Score Completed
No results loaded yet.

๐Ÿ† Player Summary by Level

Name Q1 Q2 Q3 Q4 Q5 Q6 Queens Total T1 T2 T3 T4 T5 T6 Tango Total Grand Total
No summary loaded yet.
""") @app.get("/competition_status") async def competition_status(): return {"is_open": is_competition_open()} @app.post("/set_competition") 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." } @app.post("/save_result") 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} @app.get("/results") async def results(): return {"results": get_results_rows()} @app.get("/summary_results") async def summary_results(): return {"summary": get_summary_rows()} @app.post("/clear_results") 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."} @app.get("/export_csv") 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"} ) @app.get("/tango_level") 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 )