puzzle.unturf.com/app.py
2024-12-26 18:17:37 -05:00

1119 lines
35 KiB
Python

###############################################################################
# app.py - Single-file Pyramid puzzle app with additional features:
# - Logout functionality.
# - Leaderboard supports sorting by top speed and top moves.
# - Navigation links are displayed on all screens.
# - Optional profile page to enable Gravatar (off by default).
###############################################################################
import os
import base64
import datetime
import json
import random
import string
import bcrypt
import re
import hashlib
from pyramid.config import Configurator
from pyramid.view import view_config
from pyramid.response import Response, FileResponse
from pyramid.httpexceptions import HTTPFound, HTTPFound
from pyramid.session import SignedCookieSessionFactory
from sqlalchemy import (
create_engine,
Column,
Integer,
String,
Float,
Boolean,
Date,
DateTime,
ForeignKey,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from waitress import serve
from jinja2 import Template
###############################################################################
# Database Setup
###############################################################################
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False) # used for login
username = Column(String, unique=True, nullable=True) # user-chosen handle
code_hash = Column(String, nullable=True) # bcrypt hash of code
code_expires = Column(DateTime, nullable=True) # time limit for code
is_verified = Column(Boolean, default=False)
enable_gravatar = Column(Boolean, default=False) # New field for Gravatar
class Puzzle(Base):
__tablename__ = "puzzles"
id = Column(Integer, primary_key=True)
date = Column(Date, unique=True, nullable=False)
image_b64 = Column(String, nullable=False)
initial_state_json = Column(
String, nullable=False
) # Stores initial positions and rotations
title = Column(String, default="Daily Puzzle")
class Attempt(Base):
__tablename__ = "attempts"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
user_display_name = Column(String, default="anonymous")
puzzle_id = Column(Integer, ForeignKey("puzzles.id"))
attempt_date = Column(Date, default=datetime.date.today())
attempt_number = Column(Integer, default=1)
move_count = Column(Integer, default=0)
rotation_count = Column(Integer, default=0)
time_started = Column(DateTime, default=datetime.datetime.utcnow)
time_completed = Column(DateTime, nullable=True)
is_solved = Column(Boolean, default=False)
state_json = Column(String, nullable=False) # Stores the current game state
puzzle = relationship("Puzzle", backref="attempts")
user = relationship("User", backref="attempts")
DB_URL = "sqlite:///puzzle_game.db"
engine = create_engine(DB_URL, echo=False)
Session = sessionmaker(bind=engine)
Base.metadata.create_all(engine)
###############################################################################
# Session Factory
###############################################################################
session_factory = SignedCookieSessionFactory(secret="my_insecure_secret")
###############################################################################
# Helper Functions
###############################################################################
def slugify(text):
text = text.lower()
text = re.sub(r"\s+", "-", text)
text = re.sub(r"[^\w\-]", "", text)
return text
def get_gravatar_url(email, size=100):
email = email.strip().lower()
hash_code = hashlib.md5(email.encode("utf-8")).hexdigest()
return f"https://www.gravatar.com/avatar/{hash_code}?s={size}&d=identicon"
###############################################################################
# Navigation Template
###############################################################################
NAVIGATION_TEMPLATE = r"""
<ul>
<li><a href="{{ request.route_url('home') }}">Home</a></li>
<li><a href="{{ request.route_url('daily_puzzle') }}">Today's Puzzle</a></li>
<li><a href="{{ request.route_url('leaderboard_index') }}">Leaderboards</a></li>
{% if user %}
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
{% else %}
<li><a href="{{ request.route_url('login') }}">Login</a></li>
{% endif %}
</ul>
"""
###############################################################################
# Inline Templates
###############################################################################
HOME_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>Daily Puzzle - Home</title></head>
<body>
{{ navigation }}
<h1>Daily Puzzle - Home</h1>
<ul>
<li><a href="{{ request.route_url('upload') }}">Upload a new puzzle (admin)</a></li>
<li><a href="{{ request.route_url('daily_puzzle') }}">Today's Puzzle</a></li>
<li><a href="{{ request.route_url('leaderboard_index') }}">All Leaderboards</a></li>
<li><a href="{{ request.route_url('login') }}">Login with Email</a></li>
<li><a href="{{ request.route_url('change_username') }}">Change Username</a></li>
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
</ul>
{% if user %}
<p>You are logged in as <strong>{{ user.username }}</strong>.</p>
{% else %}
<p>You are not logged in.</p>
<p>Your guest name: <strong>{{ guest_name }}</strong></p>
{% endif %}
</body>
</html>
"""
LOGIN_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>Login with Email</title></head>
<body>
{{ navigation }}
<h1>Login (Email Only)</h1>
<p>Enter your email to receive a 6-digit code. If you're a new user, an account is created for you. If you already exist, we'll re-send a code.</p>
<form method="POST">
<label>Email: <input type="email" name="email" required></label>
<button type="submit">Get Code</button>
</form>
<p><a href="{{ request.route_url('home') }}">Home</a></p>
</body>
</html>
"""
VERIFY_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>Verify Code</title></head>
<body>
{{ navigation }}
<h1>Enter 6-digit code</h1>
<form method="POST">
<label>Code: <input type="text" name="code" required></label>
<button type="submit">Verify</button>
</form>
<p><a href="{{ request.route_url('home') }}">Home</a></p>
</body>
</html>
"""
CHANGE_USERNAME_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>Change Username</title></head>
<body>
{{ navigation }}
<h1>Change Username</h1>
<form method="POST">
<label>New Username: <input type="text" name="new_username" required></label>
<button type="submit">Change</button>
</form>
<p><a href="{{ request.route_url('home') }}">Home</a></p>
</body>
</html>
"""
PROFILE_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>Profile</title></head>
<body>
{{ navigation }}
<h1>Your Profile</h1>
<p>Email: {{ user.email }}</p>
<p>Username: {{ user.username }}</p>
{% if user.enable_gravatar %}
<img src="{{ gravatar_url }}" alt="Gravatar Image">
{% else %}
<p>Gravatar is disabled.</p>
{% endif %}
<form method="POST">
<label>
<input type="checkbox" name="enable_gravatar" {% if user.enable_gravatar %}checked{% endif %}>
Enable Gravatar
</label>
<button type="submit">Update Profile</button>
</form>
<p><a href="{{ request.route_url('change_username') }}">Change Username</a></p>
</body>
</html>
"""
UPLOAD_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>Upload Puzzle</title></head>
<body>
{{ navigation }}
<h1>Upload Puzzle Image / Schedule</h1>
<form method="POST" enctype="multipart/form-data">
<label for="puzzle_date">Puzzle Date (YYYY-MM-DD):</label>
<input type="text" name="puzzle_date" required><br><br>
<label for="title">Puzzle Title:</label>
<input type="text" name="title" value="AI Puzzle"><br><br>
<label for="image_file">Choose Image:</label>
<input type="file" name="image_file" accept="image/*" required><br><br>
<button type="submit">Upload</button>
</form>
<p><a href="{{ request.route_url('home') }}">Back to Home</a></p>
</body>
</html>
"""
DAILY_PUZZLE_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head>
<title>{{ puzzle.title }} - {{ puzzle.date }}</title>
<style>
body { font-family: sans-serif; }
#boardContainer {
width: 400px;
height: 400px;
border: 2px solid #333;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
margin: 20px auto;
}
.piece {
width: 100%;
height: 100%;
border: 1px solid #999;
box-sizing: border-box;
background-image: url("data:image/png;base64,{{ puzzle.image_b64 }}");
background-size: 400px 400px;
cursor: pointer;
position: relative;
}
.empty-cell {
width: 100%;
height: 100%;
border: 1px solid #ccc;
box-sizing: border-box;
}
#downloadButton {
display: none;
margin: 10px auto;
padding: 10px 20px;
font-size: 16px;
}
</style>
</head>
<body>
{{ navigation }}
{% if puzzle %}
<h1>Today's Puzzle: {{ puzzle.title }}</h1>
<p>Date: {{ puzzle.date }}</p>
<p>
Up to 2 attempts per day.<br>
Swap pieces by dragging and dropping.<br>
Click a piece to rotate (+90°).<br>
We track <em>moves</em> (swaps) and <em>rotations</em>.
</p>
<p>
Moves: <span id="moveCount">{{ move_count }}</span><br>
Rotations: <span id="rotationCount">{{ rotation_count }}</span><br>
</p>
<div id="boardContainer"></div>
<button id="downloadButton">Download Image</button>
<script>
let puzzleData = {{ attempt_state_json | safe }};
const gridSize = 4;
let isSolved = {{ 'true' if attempt_is_solved else 'false' }};
let moveCount = {{ move_count }};
let rotationCount = {{ rotation_count }};
const boardContainer = document.getElementById("boardContainer");
const moveCountDisplay = document.getElementById("moveCount");
const rotationCountDisplay = document.getElementById("rotationCount");
const downloadButton = document.getElementById("downloadButton");
function updateScores() {
moveCountDisplay.textContent = moveCount;
rotationCountDisplay.textContent = rotationCount;
}
// Initialize the game board
function initBoard() {
boardContainer.innerHTML = '';
// Create an array to hold the pieces in their shuffled positions
const boardPieces = [];
// Place each piece in the array according to its currentIndex
puzzleData.forEach(piece => {
boardPieces[piece.currentIndex] = piece;
});
// Loop through the board positions and add the pieces
for (let i = 0; i < gridSize * gridSize; i++) {
const pieceData = boardPieces[i];
if (pieceData) {
const div = document.createElement("div");
div.className = "piece";
div.dataset.correctIndex = pieceData.correctIndex;
div.dataset.currentIndex = pieceData.currentIndex;
div.dataset.rotation = pieceData.rotation;
placePiece(div);
if (!isSolved) {
// Rotate on click
div.addEventListener("click", rotatePiece);
// Drag
div.draggable = true;
div.addEventListener("dragstart", dragStart);
div.addEventListener("dragover", dragOver);
div.addEventListener("drop", drop);
}
boardContainer.appendChild(div);
} else {
// If no piece, create an empty cell (optional)
const emptyDiv = document.createElement("div");
emptyDiv.className = "empty-cell";
boardContainer.appendChild(emptyDiv);
}
}
}
function placePiece(div) {
const correctIndex = parseInt(div.dataset.correctIndex, 10);
const rotation = parseInt(div.dataset.rotation, 10) || 0;
div.style.transform = `rotate(${rotation}deg)`;
let cRow = Math.floor(correctIndex / gridSize);
let cCol = correctIndex % gridSize;
div.style.backgroundPosition = (-cCol * 100) + "px " + (-cRow * 100) + "px";
}
let draggedEl = null;
function dragStart(e) {
if (isSolved) return;
draggedEl = e.target;
e.dataTransfer.setData("text/plain", "");
}
function dragOver(e) {
e.preventDefault();
}
function drop(e) {
e.preventDefault();
if (isSolved || !draggedEl) return;
const dropTarget = e.target;
if (dropTarget.classList.contains("piece") && dropTarget !== draggedEl) {
// Swap pieces
swapPieces(draggedEl, dropTarget);
moveCount++;
updateScores();
sendActionToServer('swap', {
pieceAIndex: draggedEl.dataset.currentIndex,
pieceBIndex: dropTarget.dataset.currentIndex
});
}
draggedEl = null;
}
function swapPieces(a, b) {
// Swap in DOM
const temp = document.createElement('div');
a.parentNode.replaceChild(temp, a);
b.parentNode.replaceChild(a, b);
temp.parentNode.replaceChild(b, temp);
// Swap currentIndex data attributes
const tempIndex = a.dataset.currentIndex;
a.dataset.currentIndex = b.dataset.currentIndex;
b.dataset.currentIndex = tempIndex;
}
function lockBoard() {
isSolved = true;
const pieces = document.querySelectorAll(".piece");
pieces.forEach(piece => {
piece.draggable = false;
piece.removeEventListener("click", rotatePiece);
});
downloadButton.style.display = "block";
}
function sendActionToServer(action, data) {
fetch("{{ request.route_url('record_action') }}", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: action, data: data})
})
.then(res => res.json())
.then(response => {
if (response.status === 'win') {
// Handle win condition
alert("Puzzle solved!");
lockBoard();
} else if (response.status !== 'ok') {
console.error('Error recording action:', response.message);
}
})
.catch(err => console.error(err));
}
function rotatePiece(e) {
if (isSolved) return;
const div = e.target;
let curRot = parseInt(div.dataset.rotation, 10) || 0;
let newRot = (curRot + 90) % 360;
div.dataset.rotation = newRot;
div.style.transform = `rotate(${newRot}deg)`;
rotationCount++;
updateScores();
sendActionToServer('rotate', {pieceIndex: div.dataset.currentIndex, rotation: newRot});
}
// Initialize
initBoard();
updateScores();
if (isSolved) {
lockBoard();
}
// Download button functionality
downloadButton.addEventListener('click', () => {
const link = document.createElement('a');
link.href = "data:image/png;base64,{{ puzzle.image_b64 }}";
link.download = "{{ puzzle_filename }}";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
</script>
{% else %}
<h1>No puzzle for today!</h1>
{% endif %}
</body>
</html>
"""
LEADERBOARD_INDEX_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>All Leaderboards</title></head>
<body>
{{ navigation }}
<h1>All Leaderboards</h1>
<p>Click a puzzle date to see its full scoreboard.</p>
<table border="1" cellpadding="5" cellspacing="0">
<tr>
<th>Puzzle Date</th>
<th>Title</th>
<th>Actions</th>
</tr>
{% for p in puzzles %}
<tr>
<td>{{ p.date }}</td>
<td>{{ p.title }}</td>
<td><a href="{{ request.route_url('daily_leaderboard', puzzle_id=p.id) }}">View Leaderboard</a></td>
</tr>
{% endfor %}
</table>
<p><a href="{{ request.route_url('home') }}">Back to Home</a></p>
</body>
</html>
"""
DAILY_LEADERBOARD_TEMPLATE = r"""
<!DOCTYPE html>
<html>
<head><title>Daily Leaderboard</title></head>
<body>
{{ navigation }}
<h1>Leaderboard for {{ puzzle.title }} ({{ puzzle.date }})</h1>
<p>
<strong>Sort by:</strong>
<a href="{{ request.route_url('daily_leaderboard', puzzle_id=puzzle.id, sort='time') }}">Top Speed</a> |
<a href="{{ request.route_url('daily_leaderboard', puzzle_id=puzzle.id, sort='moves') }}">Top Moves</a>
</p>
<table border="1" cellpadding="5" cellspacing="0">
<tr>
<th>Username/Guest</th>
<th>Attempt #</th>
<th>Moves</th>
<th>Rotations</th>
<th>Total Actions</th>
<th>Time (sec)</th>
</tr>
{% for row in attempts %}
<tr>
<td>{{ row.user_display_name }}</td>
<td>{{ row.attempt_number }}</td>
<td>{{ row.move_count }}</td>
<td>{{ row.rotation_count }}</td>
<td>{{ row.move_count + row.rotation_count }}</td>
<td>{{ "%.2f"|format((row.time_completed - row.time_started).total_seconds() if row.time_completed else 0) }}</td>
</tr>
{% endfor %}
</table>
<p><a href="{{ request.route_url('leaderboard_index') }}">All Leaderboards</a></p>
<p><a href="{{ request.route_url('home') }}">Back to Home</a></p>
</body>
</html>
"""
###############################################################################
# Helper / Auth Functions
###############################################################################
def get_current_user(request):
"""Return the currently logged-in user (if any) from session, and ensure is_verified=True."""
user_id = request.session.get("user_id")
if not user_id:
return None
s = request.dbsession
return s.query(User).filter_by(id=user_id, is_verified=True).first()
def ensure_guest_name_in_session(request):
"""If no guest_name is in session, generate one like 'Guest-ABCD'."""
if "guest_name" not in request.session:
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
request.session["guest_name"] = f"Guest-{suffix}"
def get_navigation(request):
"""Render the navigation template."""
user = get_current_user(request)
template = Template(NAVIGATION_TEMPLATE)
return template.render(request=request, user=user)
###############################################################################
# Routes
###############################################################################
@view_config(route_name="home")
def home_view(request):
user = get_current_user(request)
ensure_guest_name_in_session(request)
guest_name = request.session["guest_name"]
navigation = get_navigation(request)
template = Template(HOME_TEMPLATE)
rendered = template.render(
request=request, user=user, guest_name=guest_name, navigation=navigation
)
return Response(rendered)
@view_config(route_name="login", request_method="GET")
def login_get_view(request):
navigation = get_navigation(request)
template = Template(LOGIN_TEMPLATE)
rendered = template.render(request=request, navigation=navigation)
return Response(rendered)
@view_config(route_name="login", request_method="POST")
def login_post_view(request):
email = request.POST.get("email", "").strip().lower()
if not email:
return Response("Email required.", status=400)
session = request.dbsession
user = session.query(User).filter_by(email=email).first()
if not user:
# Create new user with null username for now
user = User(email=email, username=None)
session.add(user)
session.commit()
# Generate 6-digit code
code_str = f"{random.randint(0,999999):06d}"
code_hash = bcrypt.hashpw(code_str.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
user.code_hash = code_hash
user.code_expires = datetime.datetime.now() + datetime.timedelta(minutes=15)
user.is_verified = False
session.commit()
# In a real app, you'd send code_str via email. We'll just display it for MVP.
return Response(
f"Your 6-digit code is: {code_str} (MVP, not real email).<br>"
f"<a href='{request.route_url('verify')}'>Enter code</a>"
)
@view_config(route_name="verify", request_method="GET")
def verify_get_view(request):
navigation = get_navigation(request)
template = Template(VERIFY_TEMPLATE)
rendered = template.render(request=request, navigation=navigation)
return Response(rendered)
@view_config(route_name="verify", request_method="POST")
def verify_post_view(request):
code_entered = request.POST.get("code", "").strip()
if not code_entered or len(code_entered) != 6:
return Response("Invalid code.", status=400)
s = request.dbsession
# Find any user with code_expires > now, is_verified=False, matching code
potential_users = (
s.query(User)
.filter(
User.is_verified == False,
User.code_expires > datetime.datetime.now(),
User.code_hash != None,
)
.all()
)
user_matched = None
for u in potential_users:
if bcrypt.checkpw(code_entered.encode("utf-8"), u.code_hash.encode("utf-8")):
user_matched = u
break
if not user_matched:
return Response("Code not found or expired.", status=400)
user_matched.is_verified = True
user_matched.code_hash = None
user_matched.code_expires = None
s.commit()
request.session["user_id"] = user_matched.id
return Response(
f"Verified! Logged in. <br><a href='{request.route_url('home')}'>Home</a>"
)
@view_config(route_name="change_username", request_method="GET")
def change_username_get_view(request):
user = get_current_user(request)
if not user:
return Response("You must be logged in to change your username.", status=403)
navigation = get_navigation(request)
template = Template(CHANGE_USERNAME_TEMPLATE)
rendered = template.render(request=request, navigation=navigation)
return Response(rendered)
@view_config(route_name="change_username", request_method="POST")
def change_username_post_view(request):
user = get_current_user(request)
if not user:
return Response("You must be logged in to change your username.", status=403)
new_username = request.POST.get("new_username", "").strip()
if not new_username:
return Response("Username cannot be empty.", status=400)
s = request.dbsession
# Check if some other user has that username
existing = s.query(User).filter(User.username == new_username).first()
if existing and existing.id != user.id:
return Response("Username is taken by another user.", status=400)
user.username = new_username
s.commit()
return Response(
f"Username changed to {new_username}. "
f"<br><br><a href='{request.route_url('home')}'>Home</a>"
)
@view_config(route_name="logout")
def logout_view(request):
request.session.invalidate()
return HTTPFound(location=request.route_url("home"))
###############################################################################
# Profile and Gravatar
###############################################################################
@view_config(route_name="profile", request_method="GET")
def profile_get_view(request):
user = get_current_user(request)
if not user:
return Response("You must be logged in to access your profile.", status=403)
navigation = get_navigation(request)
gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else ""
template = Template(PROFILE_TEMPLATE)
rendered = template.render(
request=request, user=user, navigation=navigation, gravatar_url=gravatar_url
)
return Response(rendered)
@view_config(route_name="profile", request_method="POST")
def profile_post_view(request):
user = get_current_user(request)
if not user:
return Response("You must be logged in to update your profile.", status=403)
s = request.dbsession
enable_gravatar = request.POST.get("enable_gravatar") == "on"
user.enable_gravatar = enable_gravatar
s.commit()
return HTTPFound(location=request.route_url("profile"))
###############################################################################
# Puzzle and Leaderboard
###############################################################################
@view_config(route_name="upload", request_method="GET")
def upload_get_view(request):
navigation = get_navigation(request)
template = Template(UPLOAD_TEMPLATE)
rendered = template.render(request=request, navigation=navigation)
return Response(rendered)
@view_config(route_name="upload", request_method="POST")
def upload_post_view(request):
puzzle_date_str = request.POST.get("puzzle_date")
title = request.POST.get("title") or "AI Puzzle"
image_file = request.POST.get("image_file")
if (
not puzzle_date_str
or image_file is None
or not getattr(image_file, "filename", "").strip()
):
return Response("Missing date or file", status=400)
# Validate file type
allowed_extensions = {"png", "jpg", "jpeg", "gif"}
if not any(
image_file.filename.lower().endswith(f".{ext}") for ext in allowed_extensions
):
return Response(
"Unsupported file type. Allowed types: png, jpg, jpeg, gif.", status=400
)
try:
puzzle_date = datetime.datetime.strptime(puzzle_date_str, "%Y-%m-%d").date()
except ValueError:
return Response("Invalid date format. Use YYYY-MM-DD.", status=400)
raw_bytes = image_file.file.read()
max_size = 5 * 1024 * 1024 # 5 MB
if len(raw_bytes) > max_size:
return Response("File size exceeds the 5MB limit.", status=400)
encoded_str = base64.b64encode(raw_bytes).decode("utf-8")
# Generate initial puzzle state (random configuration)
gridSize = 4
total_pieces = gridSize * gridSize
indices = list(range(total_pieces))
random.shuffle(indices)
rotations = [0, 90, 180, 270]
initial_state = []
for idx, current_idx in enumerate(indices):
piece_info = {
"correctIndex": idx,
"currentIndex": current_idx,
"rotation": random.choice(rotations),
}
initial_state.append(piece_info)
initial_state_json = json.dumps(initial_state)
s = request.dbsession
existing = s.query(Puzzle).filter_by(date=puzzle_date).first()
if existing:
return Response(f"Puzzle already exists for {puzzle_date}.", status=400)
puzzle = Puzzle(
date=puzzle_date,
image_b64=encoded_str,
title=title,
initial_state_json=initial_state_json,
)
s.add(puzzle)
s.commit()
return HTTPFound(location=request.route_url("home"))
@view_config(route_name="daily_puzzle")
def daily_puzzle_view(request):
s = request.dbsession
today = datetime.date.today()
puzzle = s.query(Puzzle).filter_by(date=today).first()
if not puzzle:
navigation = get_navigation(request)
rendered = f"<h1>No puzzle for today!</h1><p><a href='/'>Back to Home</a></p>{navigation}"
return Response(rendered)
# Check if user has an existing attempt
user = get_current_user(request)
ensure_guest_name_in_session(request)
guest_name = request.session["guest_name"]
user_id = user.id if user else None
display_name = user.username if (user and user.username) else guest_name
attempt = (
s.query(Attempt)
.filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
.order_by(Attempt.attempt_number.desc())
.first()
)
attempts_count = (
s.query(Attempt).filter_by(user_id=user_id, puzzle_id=puzzle.id).count()
)
if not attempt:
if attempts_count >= 2:
return Response("You have no more attempts left for today!", status=403)
# Create a new attempt
attempt_number = attempts_count + 1
attempt = Attempt(
user_id=user_id,
user_display_name=display_name,
puzzle_id=puzzle.id,
attempt_date=today,
attempt_number=attempt_number,
move_count=0,
rotation_count=0,
is_solved=False,
state_json=puzzle.initial_state_json,
time_started=datetime.datetime.utcnow(),
)
s.add(attempt)
s.commit()
# Prepare the puzzle filename for the download button
puzzle_filename = slugify(puzzle.title) + ".png"
navigation = get_navigation(request)
# Render the puzzle page with the attempt state
template = Template(DAILY_PUZZLE_TEMPLATE)
rendered = template.render(
request=request,
puzzle=puzzle,
attempt_state_json=attempt.state_json,
move_count=attempt.move_count,
rotation_count=attempt.rotation_count,
attempt_is_solved=attempt.is_solved,
puzzle_filename=puzzle_filename,
navigation=navigation,
)
return Response(rendered)
@view_config(route_name="record_action", request_method="POST")
def record_action_view(request):
s = request.dbsession
# Get request data
try:
action_data = request.json_body
except:
return Response(
json.dumps({"status": "error", "message": "Invalid JSON data."}),
content_type="application/json; charset=UTF-8",
)
action = action_data.get("action")
data = action_data.get("data")
# Retrieve user, puzzle, and attempt
user = get_current_user(request)
ensure_guest_name_in_session(request)
guest_name = request.session["guest_name"]
today = datetime.date.today()
puzzle = s.query(Puzzle).filter_by(date=today).first()
if not puzzle:
return Response(
json.dumps({"status": "error", "message": "No puzzle found."}),
content_type="application/json; charset=UTF-8",
)
user_id = user.id if user else None
attempt = (
s.query(Attempt)
.filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
.order_by(Attempt.attempt_number.desc())
.first()
)
if not attempt:
return Response(
json.dumps({"status": "error", "message": "No active attempt found."}),
content_type="application/json; charset=UTF-8",
)
# Update attempt stats and state
state = json.loads(attempt.state_json)
if action == "rotate":
attempt.rotation_count += 1
pieceIndex = int(data.get("pieceIndex"))
rotation = int(data.get("rotation"))
# Update rotation in state
for piece in state:
if int(piece["currentIndex"]) == pieceIndex:
piece["rotation"] = rotation
break
elif action == "swap":
attempt.move_count += 1
pieceAIndex = int(data.get("pieceAIndex"))
pieceBIndex = int(data.get("pieceBIndex"))
# Swap positions in state
idxA = idxB = None
for idx, piece in enumerate(state):
if int(piece["currentIndex"]) == pieceAIndex:
idxA = idx
if int(piece["currentIndex"]) == pieceBIndex:
idxB = idx
if idxA is not None and idxB is not None:
state[idxA]["currentIndex"], state[idxB]["currentIndex"] = (
state[idxB]["currentIndex"],
state[idxA]["currentIndex"],
)
else:
return Response(
json.dumps({"status": "error", "message": "Invalid swap indices."}),
content_type="application/json; charset=UTF-8",
)
else:
return Response(
json.dumps({"status": "error", "message": "Invalid action."}),
content_type="application/json; charset=UTF-8",
)
# Save updated state
attempt.state_json = json.dumps(state)
# Check for win condition
solved = True
for piece in state:
if (
int(piece["currentIndex"]) != int(piece["correctIndex"])
or int(piece["rotation"]) % 360 != 0
):
solved = False
break
if solved:
attempt.is_solved = True
attempt.time_completed = datetime.datetime.utcnow()
s.commit()
return Response(
json.dumps({"status": "win"}),
content_type="application/json; charset=UTF-8",
)
else:
s.commit()
return Response(
json.dumps({"status": "ok"}), content_type="application/json; charset=UTF-8"
)
@view_config(route_name="leaderboard_index")
def leaderboard_index_view(request):
s = request.dbsession
puzzles = s.query(Puzzle).order_by(Puzzle.date.desc()).all()
navigation = get_navigation(request)
template = Template(LEADERBOARD_INDEX_TEMPLATE)
rendered = template.render(request=request, puzzles=puzzles, navigation=navigation)
return Response(rendered)
@view_config(route_name="daily_leaderboard")
def daily_leaderboard_view(request):
s = request.dbsession
puzzle_id = request.matchdict.get("puzzle_id")
sort_method = request.params.get("sort", "time")
puzzle = s.query(Puzzle).filter_by(id=puzzle_id).first()
if not puzzle:
return Response("No puzzle found for this ID.", status=404)
if sort_method == "moves":
# Sort by least total actions (moves + rotations)
attempts = (
s.query(Attempt)
.filter_by(puzzle_id=puzzle.id, is_solved=True)
.order_by(
(Attempt.move_count + Attempt.rotation_count).asc(),
(Attempt.time_completed - Attempt.time_started).asc(),
)
.all()
)
else:
# Default sort by fastest time
attempts = (
s.query(Attempt)
.filter_by(puzzle_id=puzzle.id, is_solved=True)
.order_by(
(Attempt.time_completed - Attempt.time_started).asc(),
(Attempt.move_count + Attempt.rotation_count).asc(),
)
.all()
)
navigation = get_navigation(request)
template = Template(DAILY_LEADERBOARD_TEMPLATE)
rendered = template.render(
request=request, puzzle=puzzle, attempts=attempts, navigation=navigation
)
return Response(rendered)
###############################################################################
# Main
###############################################################################
def main(global_config=None, **settings):
if not settings:
settings = {}
settings["sqlalchemy.url"] = DB_URL
config = Configurator(settings=settings, session_factory=session_factory)
config.include(
"pyramid_jinja2"
) # Included for potential future use with external templates
def dbsession(request):
return Session()
config.add_request_method(dbsession, "dbsession", reify=True)
# Routes
config.add_route("home", "/")
# Auth
config.add_route("login", "/auth/login")
config.add_route("verify", "/auth/verify")
config.add_route("logout", "/auth/logout")
config.add_route("change_username", "/auth/change_username")
config.add_route("profile", "/auth/profile")
# Puzzle
config.add_route("upload", "/upload")
config.add_route("daily_puzzle", "/puzzle/today")
config.add_route("record_action", "/puzzle/record_action")
# Leaderboards
config.add_route("leaderboard_index", "/leaderboards")
config.add_route("daily_leaderboard", "/leaderboard/{puzzle_id}")
config.scan()
return config.make_wsgi_app()
if __name__ == "__main__":
app = main()
print("Serving on http://localhost:6543")
serve(app, host="0.0.0.0", port=6543)