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.