diff --git a/app.py b/app.py
index d8f6746..878cd01 100644
--- a/app.py
+++ b/app.py
@@ -47,8 +47,8 @@ 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
+ email = Column(String, unique=True, nullable=True) # guests don't have an email.
+ username = Column(String, unique=True, nullable=False) # 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)
@@ -168,6 +168,29 @@ class Attempt(Base):
return int(self.time_started.timestamp() * 1000)
return 0
+ def mark_as_solved(self, time_completed=None):
+ """Mark the attempt as solved and set time_completed."""
+ self.is_solved = True
+ if self.time_completed is None:
+ self.time_completed = time_completed or datetime.datetime.utcnow()
+
+ @property
+ def total_elapsed_ms(self):
+ """Calculate total elapsed time in milliseconds if solved."""
+ if self.time_completed and self.time_started:
+ delta = self.time_completed - self.time_started
+ return int(delta.total_seconds() * 1000)
+ return None
+
+ @property
+ def current_elapsed_ms(self):
+ """Calculate current elapsed time in milliseconds."""
+ if self.time_started:
+ end_time = self.time_completed or datetime.datetime.utcnow()
+ delta = end_time - self.time_started
+ return int(delta.total_seconds() * 1000)
+ return None
+
DB_URL = "sqlite:///puzzle_game.db"
engine = create_engine(DB_URL, echo=False)
@@ -227,37 +250,94 @@ def admin_required(view_func):
return wrapper
-################################################################################
-# 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()
+ """Return the current user (authenticated or guest) from session."""
+ user_id = request.session.get("user_id")
+ s = request.dbsession
+
+ if user_id:
+ # Try to get the user from the database
+ user = s.query(User).filter_by(id=user_id).first()
+ if user:
+ return user
+ else:
+ # User ID in session does not exist in the database; remove it
+ del request.session["user_id"]
+
+ # No valid user in session; create a guest user
+ # Generate a unique guest username
+ while True:
+ suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
+ guest_username = f"Guest-{suffix}"
+ existing_user = s.query(User).filter_by(username=guest_username).first()
+ if not existing_user:
+ break # Unique username found
+
+ # Create a new guest user
+ guest_user = User(
+ email=guest_username,
+ username=guest_username,
+ is_verified=False,
+ )
+ s.add(guest_user)
+ s.commit()
+
+ # Store the user ID in the session
+ request.session["user_id"] = guest_user.id
+
+ return guest_user
-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_ordered_attempts(s, puzzle_id, sort_method='time'):
+ """Retrieve and order attempts for a given puzzle based on the sort method."""
+ if sort_method == "moves":
+ # Sort by least total actions (moves + rotations), only considering first attempts
+ attempts = (
+ s.query(Attempt)
+ .filter(
+ Attempt.puzzle_id == puzzle_id,
+ Attempt.is_solved == True,
+ Attempt.is_counted == True,
+ Attempt.attempt_number == 1,
+ )
+ .order_by(
+ (Attempt.move_count + Attempt.rotation_count).asc(),
+ (
+ func.julianday(Attempt.time_completed)
+ - func.julianday(Attempt.time_started)
+ ).asc(),
+ )
+ .all()
+ )
+ else:
+ # Default sort by fastest time, only considering first attempts
+ attempts = (
+ s.query(Attempt)
+ .filter(
+ Attempt.puzzle_id == puzzle_id,
+ Attempt.is_solved == True,
+ Attempt.is_counted == True,
+ Attempt.attempt_number == 1,
+ )
+ .order_by(
+ (
+ func.julianday(Attempt.time_completed)
+ - func.julianday(Attempt.time_started)
+ ).asc(),
+ (Attempt.move_count + Attempt.rotation_count).asc(),
+ )
+ .all()
+ )
+ return attempts
################################################################################
# Routes
################################################################################
-
-
@view_config(route_name="home", renderer="home.html.j2")
def home_view(request):
- ensure_guest_name_in_session(request)
- guest_name = request.session["guest_name"]
return {
"request": request,
- "guest_name": guest_name,
}
@@ -276,8 +356,19 @@ def login_post_view(request):
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)
+
+ # No valid user in session; create a new user
+ # Generate a unique starting username.
+ while True:
+ suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
+ new_tmp_username = f"User-{suffix}"
+ existing_user = s.query(User).filter_by(username=guest_username).first()
+ if not existing_user:
+ # Unique username found
+ break
+
+ # Create new user with tmp username for now
+ user = User(email=email, username=new_tmp_username)
# First user becomes root admin
user_count = session.query(func.count(User.id)).scalar()
@@ -301,6 +392,9 @@ def login_post_view(request):
email_body = f"Your verification code is: {code_str}"
send_email(user.email, "Your Verification Code", email_body)
+ # Store the email in the session for verification
+ request.session["login_email"] = email
+
return HTTPFound(location=request.route_url("verify"))
@@ -315,33 +409,32 @@ def verify_post_view(request):
if not code_entered or len(code_entered) != 6:
return Response("Invalid code.", status=400)
+ email = request.session.get("login_email")
+ if not email:
+ return Response("No email found in session. Please start the login process again.", 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 = s.query(User).filter(
+ User.email == email,
+ User.code_expires > datetime.datetime.now(),
+ User.code_hash != None,
+ ).first()
- 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:
+ if not user:
return Response("Code not found or expired.", status=400)
- user_matched.is_verified = True
- user_matched.code_hash = None
- user_matched.code_expires = None
+ if not bcrypt.checkpw(code_entered.encode("utf-8"), user.code_hash.encode("utf-8")):
+ return Response("Invalid code.", status=400)
+
+ user.is_verified = True
+ user.code_hash = None
+ user.code_expires = None
s.commit()
- request.session["user_id"] = user_matched.id
+ # Remove the email from the session
+ del request.session["login_email"]
+
+ request.session["user_id"] = user.id
return HTTPFound(location=request.route_url("home"))
@@ -406,6 +499,8 @@ def profile_get_view(request):
def profile_post_view(request):
if not request.user:
return Response("You must be logged in to update your profile.", status=403)
+ if not request.user.is_verified:
+ return Response("This account is in guest mode, log in to update your profile.", status=403)
s = request.dbsession
enable_gravatar = request.POST.get("enable_gravatar") == "on"
request.user.enable_gravatar = enable_gravatar
@@ -549,12 +644,11 @@ def invite_admin_post_view(request):
@admin_required
def manage_admins_view(request):
s = request.dbsession
- user = get_current_user(request)
admins = s.query(User).filter_by(is_admin=True).all()
return {
"request": request,
"admins": admins,
- "user": user,
+ "user": request.user,
}
@@ -562,7 +656,7 @@ def manage_admins_view(request):
@admin_required
def remove_admin_view(request):
s = request.dbsession
- current_user = get_current_user(request)
+ current_user = request.user
user_id = int(request.matchdict.get("user_id"))
if user_id == current_user.id or user_id == 1:
return Response("Cannot remove root admin or yourself.", status=400)
@@ -729,107 +823,61 @@ def daily_puzzle_view(request):
if not puzzle:
return {"request": request, "puzzle": None}
- # 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
-
- # 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()
- )
+ # Get the current user using request.user
+ user = request.user
+ # Fetch the first attempt (attempt_number == 1) for the user and puzzle
attempt = (
s.query(Attempt)
- .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
- .order_by(Attempt.attempt_number.desc())
+ .filter_by(user_id=user.id, puzzle_id=puzzle.id, attempt_number=1)
.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
-
- # Use initial state or solution state based on whether the puzzle is solved
- state_json = (
- existing_solve.state_json if existing_solve else puzzle.initial_state_json
- )
-
+ # Create the first attempt
attempt = Attempt(
- user_id=user_id,
- user_display_name=display_name,
+ user_id=user.id,
+ user_display_name=user.username,
puzzle_id=puzzle.id,
attempt_date=today,
- attempt_number=attempt_number,
+ attempt_number=1,
move_count=0,
rotation_count=0,
- is_solved=bool(existing_solve),
- state_json=state_json,
+ is_solved=False,
+ state_json=puzzle.initial_state_json,
time_started=datetime.datetime.utcnow(),
- is_counted=is_counted,
+ is_counted=True,
)
s.add(attempt)
s.commit()
- else:
- # If the puzzle is already solved, ensure the attempt reflects that
- if existing_solve:
- attempt.is_solved = True
- attempt.state_json = existing_solve.state_json
- s.commit()
-
# 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(
- (
- func.julianday(Attempt.time_completed)
- - func.julianday(Attempt.time_started)
- ).asc(),
- (Attempt.move_count + Attempt.rotation_count).asc(),
- )
- .all()
- )
+ if attempt.is_solved:
+ sort_method = 'time' # You can change this to 'moves' if desired
+ attempts = get_ordered_attempts(s, puzzle.id, sort_method)
for idx, att in enumerate(attempts):
- if att.user_id == user_id:
+ if att.user_id == user.id:
user_rank = idx + 1
break
- # Calculate server's current time in milliseconds
- server_time = datetime.datetime.utcnow()
- server_time_ms = int(server_time.timestamp() * 1000)
-
- # Calculate initial elapsed time in milliseconds
- attempt_start_time_ms = attempt.time_started_ms
- initial_elapsed_ms = server_time_ms - attempt_start_time_ms
+ # Calculate elapsed time
+ if attempt.is_solved:
+ # Use total elapsed time when solved
+ total_elapsed_ms = attempt.total_elapsed_ms or 0
+ else:
+ # Calculate current elapsed time
+ total_elapsed_ms = attempt.current_elapsed_ms or 0
return {
"request": request,
"puzzle": puzzle,
- "attempt": attempt, # Pass the entire attempt object
+ "attempt": attempt,
"attempt_state": json.loads(attempt.state_json),
- "attempt_is_solved": attempt.is_solved or existing_solve is not None,
+ "attempt_is_solved": attempt.is_solved,
"user_rank": user_rank,
"image_mime_type": puzzle.image_mime_type,
- "attempt_start_time_ms": attempt_start_time_ms,
- "server_time_ms": server_time_ms,
- "initial_elapsed_ms": initial_elapsed_ms,
+ "total_elapsed_ms": total_elapsed_ms,
}
@@ -842,13 +890,9 @@ def puzzle_image_view(request):
return Response("Puzzle not found.", status=404)
# Check if user has solved this puzzle
- user = get_current_user(request)
- ensure_guest_name_in_session(request)
- user_id = user.id if user else None
-
solved_attempt = (
s.query(Attempt)
- .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=True)
+ .filter_by(user_id=request.user.id, puzzle_id=puzzle.id, is_solved=True)
.first()
)
@@ -876,9 +920,7 @@ def record_action_view(request):
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"]
+ user = request.user
size = int(request.matchdict.get("size", 4))
today = datetime.date.today()
@@ -890,12 +932,11 @@ def record_action_view(request):
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
+ display_name = user.username
attempt = (
s.query(Attempt)
- .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
+ .filter_by(user_id=user.id, puzzle_id=puzzle.id, is_solved=False)
.order_by(Attempt.attempt_number.desc())
.first()
)
@@ -960,7 +1001,7 @@ def record_action_view(request):
piece["currentIndex"] == piece["correctIndex"] and piece["rotation"] % 360 == 0
for piece in state
):
- attempt.is_solved = True
+ attempt.mark_as_solved()
attempt.time_completed = datetime.datetime.utcnow()
s.commit()
@@ -978,7 +1019,7 @@ def record_action_view(request):
.all()
)
for idx, att in enumerate(attempts):
- if att.user_id == user_id and att.id == attempt.id:
+ if att.user_id == user.id and att.id == attempt.id:
user_rank = idx + 1
break
else:
@@ -1112,7 +1153,7 @@ def king_of_the_mountain_view(request):
leaderboard.append(
{
"user_id": user.id,
- "username": user.username or user.email,
+ "username": user.username,
"total_puzzles": total_puzzles,
"total_time": total_time,
"total_moves": total_moves,
@@ -1129,16 +1170,6 @@ def king_of_the_mountain_view(request):
}
-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
- user = s.query(User).filter_by(id=user_id, is_verified=True).first()
- return user
-
-
################################################################################
# Main
################################################################################
diff --git a/templates/base.html.j2 b/templates/base.html.j2
index f7966c3..7f99bf4 100644
--- a/templates/base.html.j2
+++ b/templates/base.html.j2
@@ -29,6 +29,7 @@ nav ul {
border: none;
font-size: 1.5em;
cursor: pointer;
+ color: inherit;
}
@media (max-width: 768px) {
@@ -90,6 +91,11 @@ nav ul {
border: none;
font-size: 1.5em;
cursor: pointer;
+ color: inherit;
+}
+
+#mobile-menu-close:hover {
+ color: var(--primary);
}
/* Ensure links are vertical */
@@ -111,7 +117,7 @@ nav ul {
Today's Puzzles
Leaderboards
King of the Mountain
- {% if request.user %}
+ {% if request.user and request.user.is_verified %}
Profile
Logout
{% else %}
@@ -136,7 +142,7 @@ nav ul {
Today's Puzzles
Leaderboards
King of the Mountain
- {% if request.user %}
+ {% if request.user and request.user.is_verified %}
Profile
Logout
{% else %}
diff --git a/templates/daily_puzzle.html.j2 b/templates/daily_puzzle.html.j2
index 0abf7a2..3638d05 100644
--- a/templates/daily_puzzle.html.j2
+++ b/templates/daily_puzzle.html.j2
@@ -8,9 +8,9 @@
{{ puzzle.title }}
Date: {{ puzzle.date }}
- Moves: {{ attempt.move_count }}
+ Moves: {{ attempt.move_count }}
Rotations: {{ attempt.rotation_count }}
- Time Elapsed: 00:00.00
+ Time Elapsed: 0.00 seconds
@@ -21,14 +21,21 @@
- You solved the puzzle!
- {% if user_rank is not none %}
- Your Rank: {{ user_rank }}
- {% else %}
- Your rank is not available.
- {% endif %}
+ You solved the puzzle! 🎉🎉🎉
+
+
+ Date: {{ puzzle.date }}
+ Moves: {{ attempt.move_count }} Rotations: {{ attempt.rotation_count }}
+ Time Elapsed: {{ '%.2f'|format(total_elapsed_ms / 1000) }} seconds
+
+ {% if user_rank is not none %}
+
Your Rank: {{ user_rank }}
+ {% else %}
+
Your rank is not available.
+ {% endif %}
+
@@ -85,20 +92,30 @@
padding: 0;
}
-/* Confetti styles */
-.confetti {
- position: fixed;
- width: 10px;
- height: 10px;
- top: 0;
- animation: confetti-fall 5s linear infinite;
+
+#winModalClose {
+ background: none;
+ border: none;
+ font-size: 1.5em;
+ cursor: pointer;
+ color: inherit;
}
-/* Confetti animation */
-@keyframes confetti-fall {
- 0% { transform: translateY(0); }
- 100% { transform: translateY(100vh); }
+#winModalClose:hover {
+ color: var(--primary);
}
+
+/* Modal header styles */
+#winModal article header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+#winModal article header h2 {
+ margin: 0;
+}
+
@@ -109,10 +126,8 @@ document.addEventListener("DOMContentLoaded", function() {
let isSolved = {{ 'true' if attempt_is_solved else 'false' }};
let moveCount = {{ attempt.move_count }};
let rotationCount = {{ attempt.rotation_count }};
+ let elapsedMs = {{ total_elapsed_ms | default(0) }};
- // Use the initial elapsed time and increment it
- let elapsedMs = {{ initial_elapsed_ms }};
-
// DOM elements
const puzzleContainer = document.getElementById("puzzle-container");
const moveCountDisplay = document.getElementById("moveCount");
@@ -226,7 +241,6 @@ document.addEventListener("DOMContentLoaded", function() {
if (isSolved) return;
const touchTarget = event.target;
touchStartPieceUuid = touchTarget.dataset.pieceUuid;
- // Add a visual cue if desired
}
function touchEnd(event) {
@@ -308,7 +322,10 @@ document.addEventListener("DOMContentLoaded", function() {
.then(response => {
if (response.status === 'win') {
// Handle win condition
- showWinModal(response.rank);
+ isSolved = true;
+ setTimeout(function() {
+ showWinModal(response.rank);
+ }, 100); // Delay to ensure modal displays properly
lockBoard();
} else if (response.status !== 'ok') {
console.error('Error recording action:', response.message);
@@ -332,12 +349,16 @@ document.addEventListener("DOMContentLoaded", function() {
}
function showWinModal(rank) {
- if (rank !== null) {
- const rankDisplay = document.createElement('p');
- rankDisplay.textContent = 'Your Rank: ' + rank;
- const modalContent = winModal.querySelector('article');
- modalContent.insertBefore(rankDisplay, modalContent.querySelector('footer'));
- }
+ // Update stats in modal
+ const statsDiv = winModal.querySelector('#stats');
+ statsDiv.innerHTML = `
+ Date: {{ puzzle.date }}
+
+ Moves: ${moveCount} Rotations: ${rotationCount}
+ Time Elapsed: ${formatTime(elapsedMs)}
+
+ ${rank !== null ? `Your Rank: ${rank}
` : `Your rank is not available.
`}
+ `;
winModal.showModal();
@@ -345,21 +366,11 @@ document.addEventListener("DOMContentLoaded", function() {
event.preventDefault();
winModal.close();
};
-
- // 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 formatTime(ms) {
+ const seconds = ms / 1000;
+ return `${seconds.toFixed(2)} seconds`;
}
// Download button functionality
@@ -381,19 +392,13 @@ document.addEventListener("DOMContentLoaded", function() {
function updateTimer() {
if (isSolved) {
clearInterval(timerInterval); // Stop the timer when the puzzle is solved
+ updateTimerDisplay(); // Ensure the final time is displayed
return;
}
// Increment elapsedMs by 10 milliseconds
elapsedMs += 10;
- const minutes = Math.floor(elapsedMs / 60000);
- const seconds = Math.floor((elapsedMs % 60000) / 1000);
- const centiseconds = Math.floor((elapsedMs % 1000) / 10); // Get centiseconds
- timerDisplay.textContent = `${padZero(minutes)}:${padZero(seconds)}.${padZero(centiseconds)}`;
- }
-
- function padZero(num) {
- return num.toString().padStart(2, '0');
+ updateTimerDisplay();
}
// Update the timer every 10 milliseconds
@@ -401,13 +406,23 @@ document.addEventListener("DOMContentLoaded", function() {
timerInterval = setInterval(updateTimer, 10); // Update every 10 milliseconds
}
+ function updateTimerDisplay() {
+ timerDisplay.textContent = formatTime(elapsedMs);
+ }
+
// Initialize everything
initBoard();
updateScores();
- startTimer();
if (isSolved) {
lockBoard();
+ updateTimerDisplay();
+ // Delay the modal show call to ensure it works across all browsers
+ setTimeout(function() {
+ showWinModal({{ user_rank | tojson | default('null') }});
+ }, 100); // Delay by 100 milliseconds
+ } else {
+ startTimer();
}
});