diff --git a/starter/__pycache__/app.cpython-313.pyc b/starter/__pycache__/app.cpython-313.pyc new file mode 100644 index 000000000..943417bf8 Binary files /dev/null and b/starter/__pycache__/app.cpython-313.pyc differ diff --git a/starter/__pycache__/sudoku_logic.cpython-313.pyc b/starter/__pycache__/sudoku_logic.cpython-313.pyc new file mode 100644 index 000000000..35dc77caf Binary files /dev/null and b/starter/__pycache__/sudoku_logic.cpython-313.pyc differ diff --git a/starter/app.py b/starter/app.py index 0f526b757..78f4da688 100644 --- a/starter/app.py +++ b/starter/app.py @@ -1,39 +1,94 @@ -from flask import Flask, render_template, jsonify, request +"""Flask application for the Sudoku starter project.""" + +from typing import Any, Dict, List, Optional + +from flask import Flask, jsonify, render_template, request + import sudoku_logic app = Flask(__name__) -# Keep a simple in-memory store for current puzzle and solution -CURRENT = { - 'puzzle': None, - 'solution': None +# Keep a simple in-memory store for the current puzzle and solution. +CURRENT: Dict[str, Optional[List[List[int]]]] = { + "puzzle": None, + "solution": None, } -@app.route('/') -def index(): - return render_template('index.html') -@app.route('/new') -def new_game(): - clues = int(request.args.get('clues', 35)) +@app.route("/") +def index() -> str: + """Render the main Sudoku page.""" + return render_template("index.html") + + +@app.route("/new") +def new_game() -> Any: + """Generate a new Sudoku puzzle and store it as the current game.""" + clues = int(request.args.get("clues", 35)) puzzle, solution = sudoku_logic.generate_puzzle(clues) - CURRENT['puzzle'] = puzzle - CURRENT['solution'] = solution - return jsonify({'puzzle': puzzle}) - -@app.route('/check', methods=['POST']) -def check_solution(): - data = request.json - board = data.get('board') - solution = CURRENT.get('solution') + CURRENT["puzzle"] = puzzle + CURRENT["solution"] = solution + return jsonify({"puzzle": puzzle}) + + +@app.route("/check", methods=["POST"]) +def check_solution() -> Any: + """Return the coordinates of incorrect values compared to the solution.""" + data = request.get_json() + board = data.get("board") + solution = CURRENT.get("solution") + + if solution is None: + return jsonify({"error": "No game in progress"}), 400 + + incorrect: List[List[int]] = [] + for row_index in range(sudoku_logic.SIZE): + for col_index in range(sudoku_logic.SIZE): + if board[row_index][col_index] != solution[row_index][col_index]: + incorrect.append([row_index, col_index]) + + return jsonify({"incorrect": incorrect}) + + +@app.route("/hint", methods=["POST"]) +def get_hint() -> Any: + """Fill the first empty cell with the correct solution value.""" + puzzle = CURRENT.get("puzzle") + solution = CURRENT.get("solution") + + if puzzle is None or solution is None: + return jsonify({"error": "No game in progress"}), 400 + + for row_index in range(sudoku_logic.SIZE): + for col_index in range(sudoku_logic.SIZE): + if puzzle[row_index][col_index] == sudoku_logic.EMPTY: + value = solution[row_index][col_index] + puzzle[row_index][col_index] = value + CURRENT["puzzle"] = puzzle + return jsonify({ + "row": row_index, + "col": col_index, + "value": value, + }) + + return jsonify({"message": "Puzzle already complete"}) + + +@app.route("/validate", methods=["POST"]) +def validate_move() -> Any: + """Check whether a submitted move matches the current solution.""" + data = request.get_json() + row = data.get("row") + col = data.get("col") + value = data.get("value") + solution = CURRENT.get("solution") + if solution is None: - return jsonify({'error': 'No game in progress'}), 400 - incorrect = [] - for i in range(sudoku_logic.SIZE): - for j in range(sudoku_logic.SIZE): - if board[i][j] != solution[i][j]: - incorrect.append([i, j]) - return jsonify({'incorrect': incorrect}) - -if __name__ == '__main__': - app.run(debug=True) \ No newline at end of file + return jsonify({"error": "No game in progress"}), 400 + + correct = solution[row][col] == value + return jsonify({"correct": correct}) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/starter/requirements.txt b/starter/requirements.txt index 3ab9a28ba..cf3289aa0 100644 --- a/starter/requirements.txt +++ b/starter/requirements.txt @@ -1 +1,2 @@ Flask>=2.0 +pytest>=8.0 diff --git a/starter/static/main.js b/starter/static/main.js index 2028e1026..d1571c7e3 100644 --- a/starter/static/main.js +++ b/starter/static/main.js @@ -1,6 +1,131 @@ // Client-side rendering and interaction for the Flask-backed Sudoku const SIZE = 9; +const DIFFICULTY_CLUES = { + easy: 40, + medium: 32, + hard: 26 +}; let puzzle = []; +let timerInterval; +let elapsedSeconds = 0; + +function applyTheme(theme) { + document.body.classList.toggle('dark-mode', theme === 'dark'); + const themeButton = document.getElementById('theme-toggle'); + if (themeButton) { + themeButton.textContent = theme === 'dark' ? '🌞 Light Mode' : '🌙 Dark Mode'; + } +} + +function toggleTheme() { + const isDarkMode = document.body.classList.contains('dark-mode'); + const nextTheme = isDarkMode ? 'light' : 'dark'; + localStorage.setItem('sudoku-theme', nextTheme); + applyTheme(nextTheme); +} + +function startTimer() { + if (timerInterval) { + return; + } + timerInterval = setInterval(() => { + elapsedSeconds += 1; + updateTimer(); + }, 1000); +} + +function stopTimer() { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } +} + +function resetTimer() { + stopTimer(); + elapsedSeconds = 0; + updateTimer(); +} + +function updateTimer() { + const minutes = String(Math.floor(elapsedSeconds / 60)).padStart(2, '0'); + const seconds = String(elapsedSeconds % 60).padStart(2, '0'); + const timer = document.getElementById('timer'); + if (timer) { + timer.textContent = `${minutes}:${seconds}`; + } +} + +function loadLeaderboard() { + const stored = localStorage.getItem('sudoku-leaderboard'); + return stored ? JSON.parse(stored) : []; +} + +function saveLeaderboard(entries) { + localStorage.setItem('sudoku-leaderboard', JSON.stringify(entries)); +} + +function renderLeaderboard() { + const tbody = document.querySelector('#leaderboard tbody'); + if (!tbody) { + return; + } + + const entries = loadLeaderboard(); + tbody.innerHTML = ''; + + if (entries.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 4; + cell.textContent = 'No scores yet'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + entries.forEach((entry, index) => { + const row = document.createElement('tr'); + const rankCell = document.createElement('td'); + rankCell.textContent = index + 1; + row.appendChild(rankCell); + + const nameCell = document.createElement('td'); + nameCell.textContent = entry.name; + row.appendChild(nameCell); + + const difficultyCell = document.createElement('td'); + difficultyCell.textContent = entry.difficulty; + row.appendChild(difficultyCell); + + const timeCell = document.createElement('td'); + timeCell.textContent = entry.time; + row.appendChild(timeCell); + + tbody.appendChild(row); + }); +} + +async function validateCellInput(event) { + const input = event.target; + const value = input.value; + + if (value === '') { + input.className = 'sudoku-cell'; + return; + } + + const row = parseInt(input.dataset.row, 10); + const col = parseInt(input.dataset.col, 10); + const res = await fetch('/validate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({row, col, value: parseInt(value, 10)}) + }); + const data = await res.json(); + + input.className = data.correct ? 'sudoku-cell' : 'sudoku-cell incorrect'; +} function createBoardElement() { const boardDiv = document.getElementById('sudoku-board'); @@ -18,6 +143,9 @@ function createBoardElement() { input.addEventListener('input', (e) => { const val = e.target.value.replace(/[^1-9]/g, ''); e.target.value = val; + if (val !== '') { + validateCellInput(e); + } }); rowDiv.appendChild(input); } @@ -48,10 +176,15 @@ function renderPuzzle(puz) { } async function newGame() { - const res = await fetch('/new'); + const difficultySelect = document.getElementById("difficulty"); + const difficulty = difficultySelect.value; + const clues = DIFFICULTY_CLUES[difficulty]; + const res = await fetch(`/new?clues=${clues}`); const data = await res.json(); renderPuzzle(data.puzzle); document.getElementById('message').innerText = ''; + resetTimer(); + startTimer(); } async function checkSolution() { @@ -88,6 +221,22 @@ async function checkSolution() { } } if (incorrect.size === 0) { + stopTimer(); + const playerName = window.prompt('Enter your name for the leaderboard:') || 'Anonymous'; + const difficultySelect = document.getElementById('difficulty'); + const difficulty = difficultySelect.value; + const minutes = String(Math.floor(elapsedSeconds / 60)).padStart(2, '0'); + const seconds = String(elapsedSeconds % 60).padStart(2, '0'); + const time = `${minutes}:${seconds}`; + const entries = loadLeaderboard(); + entries.push({name: playerName, difficulty, time}); + entries.sort((a, b) => { + const aTime = a.time.split(':').reduce((total, part) => total * 60 + parseInt(part, 10), 0); + const bTime = b.time.split(':').reduce((total, part) => total * 60 + parseInt(part, 10), 0); + return aTime - bTime; + }); + saveLeaderboard(entries.slice(0, 10)); + renderLeaderboard(); msg.style.color = '#388e3c'; msg.innerText = 'Congratulations! You solved it!'; } else { @@ -96,10 +245,36 @@ async function checkSolution() { } } +async function hintGame() { + const res = await fetch('/hint', {method: 'POST'}); + const data = await res.json(); + const msg = document.getElementById('message'); + + if (data.message) { + msg.style.color = '#d32f2f'; + msg.innerText = data.message; + return; + } + + const idx = data.row * SIZE + data.col; + const input = document.querySelector(`.sudoku-cell[data-row="${data.row}"][data-col="${data.col}"]`); + input.value = data.value; + input.disabled = true; + input.className = 'sudoku-cell prefilled'; + msg.style.color = '#388e3c'; + msg.innerText = 'Hint used.'; +} + // Wire buttons window.addEventListener('load', () => { + const savedTheme = localStorage.getItem('sudoku-theme') || 'light'; + applyTheme(savedTheme); + document.getElementById('new-game').addEventListener('click', newGame); + document.getElementById('hint-button').addEventListener('click', hintGame); document.getElementById('check-solution').addEventListener('click', checkSolution); + document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + renderLeaderboard(); // initialize newGame(); }); \ No newline at end of file diff --git a/starter/static/styles.css b/starter/static/styles.css index 1a6218ff9..98e47b958 100644 --- a/starter/static/styles.css +++ b/starter/static/styles.css @@ -1,22 +1,46 @@ body { - font-family: Arial, sans-serif; - background: #f4f4f4; - text-align: center; + font-family: 'Segoe UI', Arial, sans-serif; + background: linear-gradient(135deg, #f6f8ff 0%, #eef4ff 100%); + color: #1f2937; margin: 0; - padding: 0; + padding: 24px 16px 40px; + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + box-sizing: border-box; +} + +body.dark-mode { + background: linear-gradient(135deg, #0f172a 0%, #111827 100%); + color: #f5f5f5; } h1 { - margin-top: 30px; - color: #333; + margin: 20px 0 12px; + color: #1f2937; + font-size: 2rem; + letter-spacing: 0.02em; +} + +body.dark-mode h1 { + color: #f5f5f5; } #sudoku-board { display: inline-block; - margin: 30px auto; - border: 4px solid #333; - background: #fff; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin: 20px auto 24px; + padding: 14px; + border: 4px solid #334155; + border-radius: 18px; + background: #ffffff; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.14); +} + +body.dark-mode #sudoku-board { + border-color: #64748b; + background: #1e293b; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); } .sudoku-row { @@ -26,26 +50,50 @@ h1 { .sudoku-cell { width: 40px; height: 40px; - border: 1px solid #bbb; + border: 1px solid #cbd5e1; text-align: center; font-size: 20px; + font-weight: 600; outline: none; - background: #fafafa; - transition: background 0.2s; + background: #ffffff; + transition: background 0.2s, transform 0.15s ease; +} + +body.dark-mode .sudoku-cell { + background: #334155; + color: #f8fafc; + border-color: #64748b; } .sudoku-cell:focus { background: #e0f7fa; + outline: 3px solid #2563eb; + outline-offset: -2px; +} + +body.dark-mode .sudoku-cell:focus { + background: #475569; + outline-color: #60a5fa; } .sudoku-cell.prefilled { - background: #e0e0e0; - font-weight: bold; - color: #333; + background: #e2e8f0; + font-weight: 700; + color: #0f172a; +} + +body.dark-mode .sudoku-cell.prefilled { + background: #475569; + color: #f8fafc; } .sudoku-cell.incorrect { - background: #ffcdd2; + background: #fecaca; +} + +body.dark-mode .sudoku-cell.incorrect { + background: #7f1d1d; + color: #fff; } .sudoku-cell:nth-child(3), @@ -59,27 +107,181 @@ h1 { } .controls { - margin: 20px auto; + margin: 20px auto 8px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; } button { - padding: 8px 18px; - margin: 0 8px; - font-size: 16px; + padding: 10px 18px; + margin: 0; + font-size: 15px; + font-weight: 600; border: none; - background: #1976d2; + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: #fff; - border-radius: 4px; + border-radius: 999px; cursor: pointer; - transition: background 0.2s; + transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease; + box-shadow: 0 6px 14px rgba(37, 99, 235, 0.18); +} + +body.dark-mode button { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: #f8fafc; + box-shadow: 0 6px 14px rgba(59, 130, 246, 0.25); } button:hover { - background: #1565c0; + transform: translateY(-1px); + box-shadow: 0 8px 16px rgba(37, 99, 235, 0.24); +} + +button:active { + transform: translateY(1px) scale(0.98); +} + +button:focus-visible { + outline: 3px solid #2563eb; + outline-offset: 2px; +} + +body.dark-mode button:focus-visible { + outline-color: #93c5fd; +} + +#timer-container { + text-align: center; + margin-bottom: 10px; +} + +#timer { + font-size: 22px; + font-weight: 700; + letter-spacing: 0.04em; } #message { - margin-left: 20px; + margin-left: 8px; font-size: 16px; + font-weight: 600; color: #d32f2f; } + +body.dark-mode #message { + color: #ff8a80; +} + +#leaderboard { + width: min(100%, 760px); + margin: 24px auto 40px; + padding: 20px; + background: rgba(255,255,255,0.95); + border: 1px solid #e2e8f0; + border-radius: 16px; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); +} + +body.dark-mode #leaderboard { + background: rgba(30, 41, 59, 0.95); + border-color: #475569; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3); +} + +#leaderboard h2 { + margin-top: 0; + margin-bottom: 12px; + font-size: 20px; + font-weight: 700; +} + +#leaderboard table { + width: 100%; + border-collapse: collapse; + border-radius: 8px; + overflow: hidden; +} + +#leaderboard th, +#leaderboard td { + padding: 10px 12px; + border: 1px solid #ddd; + text-align: center; +} + +body.dark-mode #leaderboard th, +body.dark-mode #leaderboard td { + border-color: #555; +} + +#leaderboard th { + background: #1976d2; + color: #fff; +} + +body.dark-mode #leaderboard th { + background: #2f6fed; +} + +#leaderboard tbody tr:nth-child(even) { + background: #f7f7f7; +} + +body.dark-mode #leaderboard tbody tr:nth-child(even) { + background: #2a2a2a; +} + +#leaderboard tbody tr:hover { + background: #e3f2fd; +} + +body.dark-mode #leaderboard tbody tr:hover { + background: #333; +} + +@media (max-width: 768px) { + body { + padding: 16px 12px 32px; + } + + h1 { + font-size: 1.6rem; + } + + #sudoku-board { + padding: 10px; + } + + .sudoku-cell { + width: 32px; + height: 32px; + font-size: 16px; + } + + .controls { + flex-direction: column; + align-items: center; + } + + button { + width: 100%; + max-width: 220px; + } + + #message { + margin-left: 0; + margin-top: 6px; + } + + #leaderboard { + padding: 12px; + } + + #leaderboard th, + #leaderboard td { + padding: 8px 6px; + font-size: 14px; + } +} diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py index 443b24524..5c3e0ad71 100644 --- a/starter/sudoku_logic.py +++ b/starter/sudoku_logic.py @@ -1,21 +1,30 @@ +"""Sudoku puzzle generation utilities.""" + import copy import random +from typing import List, Tuple SIZE = 9 EMPTY = 0 +Board = List[List[int]] + -def deep_copy(board): +def deep_copy(board: Board) -> Board: + """Return a deep copy of the given Sudoku board.""" return copy.deepcopy(board) -def create_empty_board(): + +def create_empty_board() -> Board: + """Create an empty 9x9 Sudoku board filled with zeros.""" return [[EMPTY for _ in range(SIZE)] for _ in range(SIZE)] -def is_safe(board, row, col, num): - # Check row and column - for x in range(SIZE): - if board[row][x] == num or board[x][col] == num: + +def is_safe(board: Board, row: int, col: int, num: int) -> bool: + """Return True when placing ``num`` at ``(row, col)`` is valid.""" + for index in range(SIZE): + if board[row][index] == num or board[index][col] == num: return False - # Check 3x3 box + start_row = row - row % 3 start_col = col - col % 3 for i in range(3): @@ -24,7 +33,9 @@ def is_safe(board, row, col, num): return False return True -def fill_board(board): + +def fill_board(board: Board) -> bool: + """Fill the board recursively using a backtracking algorithm.""" for row in range(SIZE): for col in range(SIZE): if board[row][col] == EMPTY: @@ -39,19 +50,53 @@ def fill_board(board): return False return True -def remove_cells(board, clues): + +def count_solutions(board: Board, limit: int = 2) -> int: + """Count the number of solutions for a Sudoku board up to ``limit``.""" + board_copy = deep_copy(board) + + for row in range(SIZE): + for col in range(SIZE): + if board_copy[row][col] == EMPTY: + possible = list(range(1, SIZE + 1)) + random.shuffle(possible) + for candidate in possible: + if is_safe(board_copy, row, col, candidate): + board_copy[row][col] = candidate + solutions = count_solutions(board_copy, limit) + board_copy[row][col] = EMPTY + if solutions >= limit: + return limit + return 0 + + return 1 + + +def _has_unique_solution(board: Board) -> bool: + """Return True when the board has exactly one solution.""" + return count_solutions(board, limit=2) == 1 + + +def remove_cells(board: Board, clues: int) -> None: + """Remove values from the board until the clue count is reached.""" attempts = SIZE * SIZE - clues + while attempts > 0: row = random.randrange(SIZE) col = random.randrange(SIZE) + if board[row][col] != EMPTY: board[row][col] = EMPTY attempts -= 1 -def generate_puzzle(clues=35): +def generate_puzzle(clues: int = 35) -> Tuple[Board, Board]: board = create_empty_board() fill_board(board) + solution = deep_copy(board) + remove_cells(board, clues) + puzzle = deep_copy(board) - return puzzle, solution + + return puzzle, solution \ No newline at end of file diff --git a/starter/templates/index.html b/starter/templates/index.html index e42ad04da..0f735b128 100644 --- a/starter/templates/index.html +++ b/starter/templates/index.html @@ -7,9 +7,34 @@

Sudoku Game

+ +
+ 00:00 +
+
+

Top 10 Leaderboard

+ + + + + + + + + + +
RankPlayerDifficultyTime
+
+ + +
diff --git a/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc b/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 000000000..816a2203f Binary files /dev/null and b/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc differ diff --git a/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 000000000..961e96082 Binary files /dev/null and b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc differ diff --git a/starter/tests/test_app.py b/starter/tests/test_app.py new file mode 100644 index 000000000..32c3b9147 --- /dev/null +++ b/starter/tests/test_app.py @@ -0,0 +1,34 @@ +from pathlib import Path +import sys + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +import app as app_module + + +@pytest.fixture +def client(): + app_module.app.config.update(TESTING=True) + with app_module.app.test_client() as client: + yield client + + +def test_new_endpoint_returns_puzzle(client): + response = client.get('/new') + assert response.status_code == 200 + data = response.get_json() + assert 'puzzle' in data + assert len(data['puzzle']) == 9 + assert all(len(row) == 9 for row in data['puzzle']) + + +def test_check_endpoint_returns_incorrect_positions(client): + client.get('/new') + response = client.post('/check', json={'board': [[0] * 9 for _ in range(9)]}) + assert response.status_code == 200 + data = response.get_json() + assert 'incorrect' in data diff --git a/starter/tests/test_sudoku_logic.py b/starter/tests/test_sudoku_logic.py new file mode 100644 index 000000000..587660b27 --- /dev/null +++ b/starter/tests/test_sudoku_logic.py @@ -0,0 +1,40 @@ +from pathlib import Path +import sys + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +import sudoku_logic + + +def test_create_empty_board_returns_9x9_zero_board(): + board = sudoku_logic.create_empty_board() + assert board == [[0] * sudoku_logic.SIZE for _ in range(sudoku_logic.SIZE)] + + +def test_is_safe_rejects_conflicts_in_row_column_and_box(): + board = sudoku_logic.create_empty_board() + board[0][0] = 1 + board[0][1] = 2 + assert sudoku_logic.is_safe(board, 0, 2, 3) is True + assert sudoku_logic.is_safe(board, 0, 1, 1) is False + assert sudoku_logic.is_safe(board, 1, 0, 1) is False + assert sudoku_logic.is_safe(board, 2, 2, 1) is False + + +def test_fill_board_fills_complete_board(): + board = sudoku_logic.create_empty_board() + assert sudoku_logic.fill_board(board) is True + assert all(cell != 0 for row in board for cell in row) + + +def test_generate_puzzle_returns_puzzle_and_solution(): + puzzle, solution = sudoku_logic.generate_puzzle(35) + assert len(puzzle) == sudoku_logic.SIZE + assert len(solution) == sudoku_logic.SIZE + assert all(len(row) == sudoku_logic.SIZE for row in puzzle) + assert all(len(row) == sudoku_logic.SIZE for row in solution) + assert puzzle != solution