Games / app.py
EuriskoMobility's picture
Update app.py
319189b verified
Raw
History Blame Contribute Delete
51.5 kB
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"""
<!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>
""")
@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
)