1648 lines
51 KiB
Python
1648 lines
51 KiB
Python
###############################################################################
|
|
# app.py - Single-file Pyramid puzzle app with enhanced features:
|
|
# - Single leaderboard entry per user per puzzle.
|
|
# - Game stays solved with download button after solving.
|
|
# - King of the Mountain global leaderboard.
|
|
# - User profiles show all solves, scores, and streaks.
|
|
# - Admin can upload multiple puzzles per day (3x3, 4x4, 5x5).
|
|
# - Responsive design for phones and computers.
|
|
# - Enhanced win animation with modal and confetti.
|
|
# - Subsequent plays after first solve do not affect leaderboard.
|
|
###############################################################################
|
|
|
|
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
|
|
from pyramid.session import SignedCookieSessionFactory
|
|
|
|
from sqlalchemy import (
|
|
create_engine,
|
|
Column,
|
|
Integer,
|
|
String,
|
|
Float,
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
ForeignKey,
|
|
desc,
|
|
func,
|
|
)
|
|
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) # Gravatar support
|
|
|
|
|
|
class Puzzle(Base):
|
|
__tablename__ = "puzzles"
|
|
id = Column(Integer, primary_key=True)
|
|
date = Column(Date, nullable=False)
|
|
size = Column(Integer, nullable=False) # 3, 4, or 5
|
|
image_b64 = Column(String, nullable=False)
|
|
initial_state_json = Column(
|
|
String, nullable=False
|
|
) # initial positions and rotations
|
|
title = Column(String, default="Daily Puzzle")
|
|
|
|
__table_args__ = ({"sqlite_autoincrement": True},)
|
|
|
|
|
|
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) # current game state
|
|
is_counted = Column(
|
|
Boolean, default=True
|
|
) # whether attempt counts towards leaderboard
|
|
|
|
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"""
|
|
<nav>
|
|
<ul>
|
|
<li><a href="{{ request.route_url('upload') }}">Upload Puzzles</a></li>
|
|
<li><a href="{{ request.route_url('home') }}">Home</a></li>
|
|
<li><a href="{{ request.route_url('puzzle_menu') }}">Today's Puzzles</a></li>
|
|
<li><a href="{{ request.route_url('leaderboard_index') }}">Leaderboards</a></li>
|
|
<li><a href="{{ request.route_url('king_of_the_mountain') }}">King of the Mountain</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>
|
|
</nav>
|
|
"""
|
|
|
|
################################################################################
|
|
# Inline Templates
|
|
################################################################################
|
|
HOME_TEMPLATE = r"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Daily Puzzles - Home</title>
|
|
<style>
|
|
/* Add necessary styles here */
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{ navigation }}
|
|
<h1>Daily Puzzles - Home</h1>
|
|
{% 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 %}
|
|
<p>Welcome to the Daily Puzzles! Test your skills with our daily challenges.</p>
|
|
<p><a href="{{ request.route_url('puzzle_menu') }}">Play Today's Puzzles</a></p>
|
|
</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>
|
|
</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>
|
|
</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>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
PROFILE_TEMPLATE = r"""
|
|
<html>
|
|
<head><title>Profile</title></head>
|
|
<body>
|
|
{{ navigation }}
|
|
|
|
<h1>Your Profile</h1>
|
|
<p>Email: {{ user.email }}</p>
|
|
<p>Username: {{ user.username or "Not set" }}</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><br><br>
|
|
|
|
<label>
|
|
New Username: <input type="text" name="new_username" placeholder="Enter new username">
|
|
</label><br><br>
|
|
|
|
<button type="submit">Update Profile</button>
|
|
</form>
|
|
|
|
<h2>Stats</h2>
|
|
<p>Total Puzzles Solved: {{ total_puzzles_solved }}</p>
|
|
<p>Current Streak: {{ current_streak }} days</p>
|
|
<p>Max Streak: {{ max_streak }} days</p>
|
|
<h2>Your Solves</h2>
|
|
<table border="1" cellpadding="5" cellspacing="0">
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Size</th>
|
|
<th>Moves</th>
|
|
<th>Rotations</th>
|
|
<th>Time (sec)</th>
|
|
</tr>
|
|
{% for attempt in solved_attempts %}
|
|
<tr>
|
|
<td>{{ attempt.puzzle.date }}</td>
|
|
<td>{{ attempt.puzzle.size }}x{{ attempt.puzzle.size }}</td>
|
|
<td>{{ attempt.move_count }}</td>
|
|
<td>{{ attempt.rotation_count }}</td>
|
|
<td>{{ "%.2f"|format((attempt.time_completed - attempt.time_started).total_seconds()) }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
PUZZLE_MENU_TEMPLATE = r"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Today's Puzzles</title>
|
|
</head>
|
|
<body>
|
|
{{ navigation }}
|
|
<h1>Select a Puzzle Size</h1>
|
|
<ul>
|
|
{% for size in puzzle_sizes %}
|
|
{% if puzzles[size] %}
|
|
<li><a href="{{ request.route_url('daily_puzzle', size=size) }}">{{ size }}x{{ size }} Puzzle</a></li>
|
|
{% else %}
|
|
<li>{{ size }}x{{ size }} Puzzle - Not Available Today</li>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</ul>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
UPLOAD_TEMPLATE = r"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Upload Puzzles</title></head>
|
|
<body>
|
|
{{ navigation }}
|
|
<h1>Upload Puzzles for Today</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>
|
|
{% for size in [3, 4, 5] %}
|
|
<h3>{{ size }}x{{ size }} Puzzle</h3>
|
|
<label for="title_{{ size }}">Puzzle Title:</label>
|
|
<input type="text" name="title_{{ size }}" value="AI Puzzle {{ size }}x{{ size }}"><br><br>
|
|
<label for="image_file_{{ size }}">Choose Image:</label>
|
|
<input type="file" name="image_file_{{ size }}" accept="image/*"><br><br>
|
|
{% endfor %}
|
|
<button type="submit">Upload</button>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
DAILY_PUZZLE_TEMPLATE = r"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{ puzzle.title }} - {{ puzzle.date }}</title>
|
|
<style>
|
|
body { font-family: sans-serif; margin: 0; padding: 0; }
|
|
#boardContainer {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
margin: 20px auto;
|
|
display: grid;
|
|
grid-template-columns: repeat({{ puzzle.size }}, 1fr);
|
|
grid-template-rows: repeat({{ puzzle.size }}, 1fr);
|
|
}
|
|
.piece {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: 1px solid #999;
|
|
box-sizing: border-box;
|
|
background-image: url("data:image/png;base64,{{ puzzle.image_b64 }}");
|
|
background-repeat: no-repeat;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
.empty-cell {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: 1px solid #ccc;
|
|
box-sizing: border-box;
|
|
}
|
|
/* Responsive Design */
|
|
@media (max-width: 600px) {
|
|
#boardContainer {
|
|
max-width: 300px;
|
|
}
|
|
}
|
|
/* Modal and Confetti styles */
|
|
#winModal {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 1000;
|
|
left: 0; top: 0; width: 100%; height: 100%;
|
|
overflow: auto;
|
|
background-color: rgba(0,0,0,0.8);
|
|
}
|
|
#winModalContent {
|
|
position: relative;
|
|
margin: 15% auto;
|
|
padding: 20px;
|
|
width: 80%;
|
|
max-width: 500px;
|
|
background-color: #fff;
|
|
text-align: center;
|
|
border-radius: 10px;
|
|
animation: fadeIn 1s;
|
|
}
|
|
#winModalClose {
|
|
position: absolute;
|
|
top: 10px; right: 20px; font-size: 30px; font-weight: bold;
|
|
color: #aaa; cursor: pointer;
|
|
}
|
|
/* Confetti Styles */
|
|
.confetti {
|
|
position: fixed;
|
|
z-index: 999;
|
|
width: 10px; height: 10px;
|
|
background-color: #f2b;
|
|
animation: confetti-fall 3s linear infinite;
|
|
}
|
|
@keyframes confetti-fall {
|
|
0% { top: -10px; }
|
|
100% { top: 110%; }
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{ navigation }}
|
|
{% if puzzle %}
|
|
<h1>{{ puzzle.title }}</h1>
|
|
<p>Date: {{ puzzle.date }}</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" style="display:none;">Download Image</button>
|
|
|
|
<!-- Win Modal -->
|
|
<div id="winModal">
|
|
<div id="winModalContent">
|
|
<span id="winModalClose">×</span>
|
|
<h2>Congratulations!</h2>
|
|
<p>You solved the puzzle!</p>
|
|
{% if user_rank is not none %}
|
|
<p>Your Rank: {{ user_rank }}</p>
|
|
{% else %}
|
|
<p>Your rank is not available.</p>
|
|
{% endif %}
|
|
<button id="modalDownloadButton">Download Image</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let puzzleData = {{ attempt_state_json | safe }};
|
|
const gridSize = {{ puzzle.size }};
|
|
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");
|
|
const winModal = document.getElementById("winModal");
|
|
const winModalClose = document.getElementById("winModalClose");
|
|
const modalDownloadButton = document.getElementById("modalDownloadButton");
|
|
|
|
function updateScores() {
|
|
moveCountDisplay.textContent = moveCount;
|
|
rotationCountDisplay.textContent = rotationCount;
|
|
}
|
|
|
|
// Initialize the game board
|
|
function initBoard() {
|
|
boardContainer.innerHTML = '';
|
|
|
|
// Adjust board size for responsiveness
|
|
let boardSize = Math.min(window.innerWidth - 40, 400); // subtracting margins/padding
|
|
boardContainer.style.width = boardSize + 'px';
|
|
boardContainer.style.height = boardSize + 'px';
|
|
boardContainer.style.gridTemplateColumns = `repeat(${gridSize}, 1fr)`;
|
|
boardContainer.style.gridTemplateRows = `repeat(${gridSize}, 1fr)`;
|
|
|
|
// 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)`;
|
|
|
|
// Calculate background size based on the container size
|
|
const containerSize = boardContainer.clientWidth; // Assuming square container
|
|
const backgroundSize = containerSize;
|
|
|
|
// Calculate the size of each piece
|
|
const pieceSize = containerSize / gridSize;
|
|
|
|
let cRow = Math.floor(correctIndex / gridSize);
|
|
let cCol = correctIndex % gridSize;
|
|
|
|
div.style.backgroundSize = `${backgroundSize}px ${backgroundSize}px`;
|
|
div.style.backgroundPosition = `-${cCol * pieceSize}px -${cRow * pieceSize}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";
|
|
}
|
|
|
|
// Show Win Modal
|
|
function showWinModal(rank) {
|
|
if (rank !== null) {
|
|
const rankDisplay = document.createElement('p');
|
|
rankDisplay.textContent = 'Your Rank: ' + rank;
|
|
const modalContent = document.getElementById('winModalContent');
|
|
modalContent.insertBefore(rankDisplay, modalDownloadButton);
|
|
}
|
|
|
|
winModal.style.display = 'block';
|
|
|
|
winModalClose.onclick = function() {
|
|
winModal.style.display = 'none';
|
|
};
|
|
|
|
window.onclick = function(event) {
|
|
if (event.target === winModal) {
|
|
winModal.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
// Confetti effect
|
|
for (let i = 0; i < 100; i++) {
|
|
createConfetti();
|
|
}
|
|
}
|
|
|
|
function createConfetti() {
|
|
const confetti = document.createElement('div');
|
|
confetti.className = 'confetti';
|
|
confetti.style.left = Math.random() * 100 + '%';
|
|
confetti.style.backgroundColor = 'hsl(' + Math.random() * 360 + ', 100%, 50%)';
|
|
confetti.style.animationDelay = Math.random() * 3 + 's';
|
|
document.body.appendChild(confetti);
|
|
setTimeout(() => { confetti.remove(); }, 3000);
|
|
}
|
|
|
|
function sendActionToServer(action, data) {
|
|
fetch("{{ request.route_url('record_action', size=puzzle.size) }}", {
|
|
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
|
|
showWinModal(response.rank);
|
|
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
|
|
function downloadImage() {
|
|
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);
|
|
}
|
|
|
|
downloadButton.addEventListener('click', downloadImage);
|
|
modalDownloadButton.addEventListener('click', downloadImage);
|
|
</script>
|
|
{% else %}
|
|
<h1>No puzzle available!</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>Size</th>
|
|
<th>Title</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
{% for p in puzzles %}
|
|
<tr>
|
|
<td>{{ p.date }}</td>
|
|
<td>{{ p.size }}x{{ p.size }}</td>
|
|
<td>{{ p.title }}</td>
|
|
<td><a href="{{ request.route_url('daily_leaderboard', puzzle_id=p.id) }}">View Leaderboard</a></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
</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>Rank</th>
|
|
<th>Username/Guest</th>
|
|
<th>Moves</th>
|
|
<th>Rotations</th>
|
|
<th>Total Actions</th>
|
|
<th>Time (sec)</th>
|
|
</tr>
|
|
{% for row in attempts %}
|
|
<tr>
|
|
<td>{{ loop.index }}</td>
|
|
<td><a href="{{ request.route_url('view_profile', user_id=row.user_id) }}">{{ row.user_display_name }}</a></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>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
KING_OF_THE_MOUNTAIN_TEMPLATE = r"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>King of the Mountain</title>
|
|
<style>
|
|
/* Add your styles here */
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
th, td {
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
text-align: center;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{ navigation }}
|
|
<h1>King of the Mountain - Global Leaderboard</h1>
|
|
<table>
|
|
<tr>
|
|
<th>Rank</th>
|
|
<th>Username</th>
|
|
<th>Total Puzzles Solved</th>
|
|
<th>Total Time (sec)</th>
|
|
<th>Total Moves</th>
|
|
<th>Total Rotations</th>
|
|
<th>Total Actions</th>
|
|
</tr>
|
|
{% for player in global_leaderboard %}
|
|
<tr>
|
|
<td>{{ loop.index }}</td>
|
|
<td><a href="{{ request.route_url('view_profile', user_id=player.user_id) }}">{{ player.username }}</a></td>
|
|
<td>{{ player.total_puzzles }}</td>
|
|
<td>{{ "%.2f"|format(player.total_time) }}</td>
|
|
<td>{{ player.total_moves }}</td>
|
|
<td>{{ player.total_rotations }}</td>
|
|
<td>{{ player.total_moves + player.total_rotations }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
################################################################################
|
|
# Helper / Auth Functions
|
|
################################################################################
|
|
def get_current_user(request):
|
|
"""Return the currently logged-in user (if any) from session."""
|
|
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 HTTPFound(location=request.route_url("home"))
|
|
|
|
|
|
@view_config(route_name="logout")
|
|
def logout_view(request):
|
|
request.session.invalidate()
|
|
return HTTPFound(location=request.route_url("home"))
|
|
|
|
|
|
@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 HTTPFound(location=request.route_url("profile"))
|
|
|
|
|
|
################################################################################
|
|
# 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 ""
|
|
s = request.dbsession
|
|
|
|
# Fetch user stats
|
|
solved_attempts = (
|
|
s.query(Attempt)
|
|
.join(Puzzle)
|
|
.filter(
|
|
Attempt.user_id == user.id,
|
|
Attempt.is_solved == True,
|
|
Attempt.is_counted == True,
|
|
)
|
|
.order_by(Attempt.time_completed.desc())
|
|
.all()
|
|
)
|
|
|
|
total_puzzles_solved = len(solved_attempts)
|
|
|
|
# Calculate streaks
|
|
solved_dates = sorted({a.puzzle.date for a in solved_attempts})
|
|
current_streak = 0
|
|
max_streak = 0
|
|
last_date = None
|
|
for date in solved_dates:
|
|
if last_date and (date - last_date).days == 1:
|
|
current_streak += 1
|
|
else:
|
|
current_streak = 1
|
|
if current_streak > max_streak:
|
|
max_streak = current_streak
|
|
last_date = date
|
|
|
|
template = Template(PROFILE_TEMPLATE)
|
|
rendered = template.render(
|
|
request=request,
|
|
user=user,
|
|
navigation=navigation,
|
|
gravatar_url=gravatar_url,
|
|
total_puzzles_solved=total_puzzles_solved,
|
|
current_streak=current_streak,
|
|
max_streak=max_streak,
|
|
solved_attempts=solved_attempts,
|
|
)
|
|
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
|
|
|
|
# Handle username update if provided
|
|
new_username = request.POST.get("new_username", "").strip()
|
|
if new_username:
|
|
# Check if the new username is already taken
|
|
existing = s.query(User).filter(User.username == new_username).first()
|
|
if existing and existing.id != user.id:
|
|
return Response("Username is already in use.", status=400)
|
|
user.username = new_username
|
|
|
|
s.commit()
|
|
return HTTPFound(location=request.route_url("profile"))
|
|
|
|
|
|
@view_config(route_name="view_profile")
|
|
def view_profile(request):
|
|
s = request.dbsession
|
|
user_id = request.matchdict.get("user_id")
|
|
user = s.query(User).filter_by(id=user_id).first()
|
|
if not user:
|
|
return Response("User not found.", status=404)
|
|
|
|
navigation = get_navigation(request)
|
|
gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else ""
|
|
|
|
# Fetch user stats
|
|
solved_attempts = (
|
|
s.query(Attempt)
|
|
.join(Puzzle)
|
|
.filter(
|
|
Attempt.user_id == user.id,
|
|
Attempt.is_solved == True,
|
|
Attempt.is_counted == True,
|
|
)
|
|
.order_by(Attempt.time_completed.desc())
|
|
.all()
|
|
)
|
|
|
|
total_puzzles_solved = len(solved_attempts)
|
|
|
|
# Calculate streaks
|
|
solved_dates = sorted({a.puzzle.date for a in solved_attempts})
|
|
current_streak = 0
|
|
max_streak = 0
|
|
last_date = None
|
|
for date in solved_dates:
|
|
if last_date and (date - last_date).days == 1:
|
|
current_streak += 1
|
|
else:
|
|
current_streak = 1
|
|
if current_streak > max_streak:
|
|
max_streak = current_streak
|
|
last_date = date
|
|
|
|
template = Template(PROFILE_TEMPLATE)
|
|
rendered = template.render(
|
|
request=request,
|
|
user=user,
|
|
navigation=navigation,
|
|
gravatar_url=gravatar_url,
|
|
total_puzzles_solved=total_puzzles_solved,
|
|
current_streak=current_streak,
|
|
max_streak=max_streak,
|
|
solved_attempts=solved_attempts,
|
|
)
|
|
return Response(rendered)
|
|
|
|
|
|
################################################################################
|
|
# Puzzle Menu
|
|
################################################################################
|
|
@view_config(route_name="puzzle_menu")
|
|
def puzzle_menu_view(request):
|
|
navigation = get_navigation(request)
|
|
s = request.dbsession
|
|
today = datetime.date.today()
|
|
puzzles = {}
|
|
puzzle_sizes = [3, 4, 5]
|
|
for size in puzzle_sizes:
|
|
puzzle = s.query(Puzzle).filter_by(date=today, size=size).first()
|
|
puzzles[size] = puzzle
|
|
|
|
template = Template(PUZZLE_MENU_TEMPLATE)
|
|
rendered = template.render(
|
|
request=request,
|
|
navigation=navigation,
|
|
puzzles=puzzles,
|
|
puzzle_sizes=puzzle_sizes,
|
|
)
|
|
return Response(rendered)
|
|
|
|
|
|
################################################################################
|
|
# Puzzle Upload
|
|
################################################################################
|
|
@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")
|
|
puzzle_sizes = [3, 4, 5]
|
|
puzzles_uploaded = 0
|
|
|
|
if not puzzle_date_str:
|
|
return Response("Missing date.", 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)
|
|
|
|
s = request.dbsession
|
|
|
|
for size in puzzle_sizes:
|
|
title = request.POST.get(f"title_{size}") or f"AI Puzzle {size}x{size}"
|
|
image_file = request.POST.get(f"image_file_{size}")
|
|
|
|
if image_file is None or not getattr(image_file, "filename", "").strip():
|
|
continue # Skip if no image uploaded for this size
|
|
|
|
# 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(
|
|
f"Unsupported file type for {size}x{size} puzzle. Allowed types: png, jpg, jpeg, gif.",
|
|
status=400,
|
|
)
|
|
|
|
raw_bytes = image_file.file.read()
|
|
max_size = 5 * 1024 * 1024 # 5 MB
|
|
if len(raw_bytes) > max_size:
|
|
return Response(
|
|
f"File size exceeds the 5MB limit for {size}x{size} puzzle.", status=400
|
|
)
|
|
|
|
encoded_str = base64.b64encode(raw_bytes).decode("utf-8")
|
|
|
|
# Generate initial puzzle state (random configuration)
|
|
gridSize = size
|
|
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)
|
|
|
|
existing = s.query(Puzzle).filter_by(date=puzzle_date, size=size).first()
|
|
if existing:
|
|
return Response(
|
|
f"Puzzle of size {size}x{size} already exists for {puzzle_date}.",
|
|
status=400,
|
|
)
|
|
|
|
puzzle = Puzzle(
|
|
date=puzzle_date,
|
|
size=size,
|
|
image_b64=encoded_str,
|
|
title=title,
|
|
initial_state_json=initial_state_json,
|
|
)
|
|
s.add(puzzle)
|
|
puzzles_uploaded += 1
|
|
|
|
s.commit()
|
|
if puzzles_uploaded == 0:
|
|
return Response("No puzzles were uploaded.", status=400)
|
|
|
|
return HTTPFound(location=request.route_url("home"))
|
|
|
|
|
|
################################################################################
|
|
# Daily Puzzle
|
|
################################################################################
|
|
@view_config(route_name="daily_puzzle")
|
|
def daily_puzzle_view(request):
|
|
s = request.dbsession
|
|
size = int(request.matchdict.get("size", 4))
|
|
today = datetime.date.today()
|
|
puzzle = s.query(Puzzle).filter_by(date=today, size=size).first()
|
|
|
|
navigation = get_navigation(request)
|
|
|
|
if not puzzle:
|
|
rendered = "<h1>No puzzle available!</h1>"
|
|
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
|
|
|
|
s = request.dbsession
|
|
|
|
# Check if user has already solved this puzzle
|
|
existing_solve = (
|
|
s.query(Attempt)
|
|
.filter_by(
|
|
user_id=user_id, puzzle_id=puzzle.id, is_solved=True, is_counted=True
|
|
)
|
|
.first()
|
|
)
|
|
|
|
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:
|
|
# Create a new attempt
|
|
attempt_number = attempts_count + 1
|
|
|
|
# If the user already solved this puzzle, mark new attempts as not counted
|
|
is_counted = False if existing_solve else True
|
|
|
|
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(),
|
|
is_counted=is_counted,
|
|
)
|
|
s.add(attempt)
|
|
s.commit()
|
|
|
|
# Prepare the puzzle filename for the download button
|
|
puzzle_filename = slugify(puzzle.title) + ".png"
|
|
|
|
# Get user's rank if they have solved it
|
|
user_rank = None
|
|
if existing_solve:
|
|
attempts = (
|
|
s.query(Attempt)
|
|
.filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True)
|
|
.order_by(
|
|
(Attempt.time_completed - Attempt.time_started).asc(),
|
|
(Attempt.move_count + Attempt.rotation_count).asc(),
|
|
)
|
|
.all()
|
|
)
|
|
for idx, att in enumerate(attempts):
|
|
if att.user_id == user_id:
|
|
user_rank = idx + 1
|
|
break
|
|
|
|
# Render the puzzle page with the attempt state
|
|
puzzle_css = f"""
|
|
#boardContainer {{
|
|
width: 100%;
|
|
max-width: 400px;
|
|
margin: 20px auto;
|
|
display: grid;
|
|
}}
|
|
.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: cover;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}}
|
|
.empty-cell {{
|
|
width: 100%;
|
|
height: 100%;
|
|
border: 1px solid #ccc;
|
|
box-sizing: border-box;
|
|
}}
|
|
"""
|
|
|
|
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 or existing_solve is not None,
|
|
puzzle_filename=puzzle_filename,
|
|
navigation=navigation,
|
|
user_rank=user_rank,
|
|
puzzle_css=puzzle_css,
|
|
)
|
|
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"]
|
|
|
|
size = int(request.matchdict.get("size", 4))
|
|
today = datetime.date.today()
|
|
puzzle = s.query(Puzzle).filter_by(date=today, size=size).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
|
|
display_name = user.username if (user and user.username) else guest_name
|
|
|
|
# Check if user has already solved this puzzle
|
|
existing_solve = (
|
|
s.query(Attempt)
|
|
.filter_by(
|
|
user_id=user_id, puzzle_id=puzzle.id, is_solved=True, is_counted=True
|
|
)
|
|
.first()
|
|
)
|
|
|
|
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()
|
|
|
|
# Get user's rank
|
|
attempts = (
|
|
s.query(Attempt)
|
|
.filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True)
|
|
.order_by(
|
|
(Attempt.time_completed - Attempt.time_started).asc(),
|
|
(Attempt.move_count + Attempt.rotation_count).asc(),
|
|
)
|
|
.all()
|
|
)
|
|
for idx, att in enumerate(attempts):
|
|
if att.user_id == user_id and att.id == attempt.id:
|
|
user_rank = idx + 1
|
|
break
|
|
else:
|
|
user_rank = "N/A"
|
|
|
|
return Response(
|
|
json.dumps({"status": "win", "rank": user_rank}),
|
|
content_type="application/json; charset=UTF-8",
|
|
)
|
|
else:
|
|
s.commit()
|
|
return Response(
|
|
json.dumps({"status": "ok"}), content_type="application/json; charset=UTF-8"
|
|
)
|
|
|
|
|
|
################################################################################
|
|
# Leaderboards
|
|
################################################################################
|
|
@view_config(route_name="leaderboard_index")
|
|
def leaderboard_index_view(request):
|
|
s = request.dbsession
|
|
puzzles = s.query(Puzzle).order_by(Puzzle.date.desc(), Puzzle.size.asc()).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, is_counted=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, is_counted=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)
|
|
|
|
|
|
@view_config(route_name="king_of_the_mountain")
|
|
def king_of_the_mountain_view(request):
|
|
s = request.dbsession
|
|
|
|
# Aggregate scores
|
|
users = s.query(User).all()
|
|
leaderboard = []
|
|
for user in users:
|
|
total_puzzles = (
|
|
s.query(func.count(Attempt.id))
|
|
.filter(
|
|
Attempt.user_id == user.id,
|
|
Attempt.is_solved == True,
|
|
Attempt.is_counted == True,
|
|
)
|
|
.scalar()
|
|
)
|
|
|
|
if total_puzzles == 0:
|
|
continue # Skip users with no solves
|
|
|
|
total_time = (
|
|
s.query(
|
|
func.sum(
|
|
func.julianday(Attempt.time_completed)
|
|
- func.julianday(Attempt.time_started)
|
|
)
|
|
)
|
|
.filter(
|
|
Attempt.user_id == user.id,
|
|
Attempt.is_solved == True,
|
|
Attempt.is_counted == True,
|
|
)
|
|
.scalar()
|
|
* 86400
|
|
) # Convert days to seconds
|
|
|
|
total_moves = (
|
|
s.query(func.sum(Attempt.move_count))
|
|
.filter(
|
|
Attempt.user_id == user.id,
|
|
Attempt.is_solved == True,
|
|
Attempt.is_counted == True,
|
|
)
|
|
.scalar()
|
|
)
|
|
|
|
total_rotations = (
|
|
s.query(func.sum(Attempt.rotation_count))
|
|
.filter(
|
|
Attempt.user_id == user.id,
|
|
Attempt.is_solved == True,
|
|
Attempt.is_counted == True,
|
|
)
|
|
.scalar()
|
|
)
|
|
|
|
leaderboard.append(
|
|
{
|
|
"user_id": user.id,
|
|
"username": user.username or user.email,
|
|
"total_puzzles": total_puzzles,
|
|
"total_time": total_time,
|
|
"total_moves": total_moves,
|
|
"total_rotations": total_rotations,
|
|
}
|
|
)
|
|
|
|
# Sort leaderboard
|
|
leaderboard.sort(key=lambda x: (-x["total_puzzles"], x["total_time"]))
|
|
|
|
navigation = get_navigation(request)
|
|
template = Template(KING_OF_THE_MOUNTAIN_TEMPLATE)
|
|
rendered = template.render(
|
|
request=request, global_leaderboard=leaderboard, 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")
|
|
config.add_route("view_profile", "/user/{user_id}")
|
|
|
|
# Puzzle
|
|
config.add_route("upload", "/upload")
|
|
config.add_route("puzzle_menu", "/puzzle/menu")
|
|
config.add_route("daily_puzzle", "/puzzle/{size}")
|
|
config.add_route("record_action", "/puzzle/{size}/record_action")
|
|
|
|
# Leaderboards
|
|
config.add_route("leaderboard_index", "/leaderboards")
|
|
config.add_route("daily_leaderboard", "/leaderboard/{puzzle_id}")
|
|
config.add_route("king_of_the_mountain", "/king_of_the_mountain")
|
|
|
|
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)
|