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 @@

    Congratulations!

    - +
    -

    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(); } });