diff --git a/README.md b/README.md index 73753db50..cee084ee8 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,63 @@ Use GitHub Copilot to refactor the code for this game to add more advanced featu - The game should be responsive and work well on both desktop and mobile devices. - UI colors should be visually appealing and accessible. - Completed and correct puzzles should display a congratulatory message with the time taken and hints used and ask for the user's name for Top 10 times. + +## Running the Tests + +Run the test suite with: + +```bash +python -m pytest -q +``` + +## Features Implemented + +- Sudoku puzzle generator with a unique solution +- Difficulty selector (Easy, Medium, Hard) +- Timer +- Hint button +- Check Puzzle button +- Immediate feedback for invalid entries +- Top 10 leaderboard using browser localStorage +- Dark mode +- Responsive design +- Congratulatory message when the puzzle is solved + +## Code Quality + +The legacy Sudoku application was refactored into modular and reusable components. + +- Game generation and validation logic are separated into `sudoku_logic.py`. +- Flask routes are implemented in `app.py`. +- HTML templates are organized in the `templates` folder. +- CSS and JavaScript are organized in the `static` folder. +- Automated tests are stored in the `tests` folder. + +The application uses consistent error handling by returning appropriate JSON error messages for invalid requests or when no active game is available. + +All functionality was verified by running the application and executing the pytest test suite after each major feature was added. + +## Comments and Documentation + +Comments were added to explain important sections of the application, including: + +- Sudoku puzzle generation +- Unique solution validation +- Hint generation +- Solution checking +- Flask routes + +These comments improve readability and make the project easier to understand and maintain. Consistent naming conventions and formatting were followed throughout the project. + +## Screenshots + +The `Screenshots` folder contains GitHub Copilot conversations for the major development milestones, including: + +- `copilot_testing_framework.png` +- `copilot_unique_solution_prompt.png` +- `copilot_top10_scores.png` +- `copilot_grid_styling.png` +- `copilot_timer.png` +- `copilot_darkmode.png` +- `copilot_hint.png` +- `copilot_check_puzzle.png` diff --git a/starter/__pycache__/app.cpython-314.pyc b/starter/__pycache__/app.cpython-314.pyc new file mode 100644 index 000000000..24474f742 Binary files /dev/null and b/starter/__pycache__/app.cpython-314.pyc differ diff --git a/starter/__pycache__/sudoku_logic.cpython-314.pyc b/starter/__pycache__/sudoku_logic.cpython-314.pyc new file mode 100644 index 000000000..5a1cf63ad Binary files /dev/null and b/starter/__pycache__/sudoku_logic.cpython-314.pyc differ diff --git a/starter/app.py b/starter/app.py index 0f526b757..f243c528b 100644 --- a/starter/app.py +++ b/starter/app.py @@ -2,25 +2,28 @@ import sudoku_logic app = Flask(__name__) - -# Keep a simple in-memory store for current puzzle and solution +# Store the current game state, including the puzzle, solution, and difficulty. CURRENT = { 'puzzle': None, - 'solution': None + 'solution': None, + 'difficulty': 'medium', } - +# Render the main Sudoku game page. @app.route('/') def index(): return render_template('index.html') +# Start a new Sudoku game using the selected difficulty @app.route('/new') def new_game(): - clues = int(request.args.get('clues', 35)) - puzzle, solution = sudoku_logic.generate_puzzle(clues) + difficulty = request.args.get('difficulty', 'medium').lower() + puzzle, solution = sudoku_logic.generate_puzzle(difficulty=difficulty) CURRENT['puzzle'] = puzzle CURRENT['solution'] = solution - return jsonify({'puzzle': puzzle}) + CURRENT['difficulty'] = difficulty + return jsonify({'puzzle': puzzle, 'difficulty': difficulty}) +# Compare the player's board with the correct solution @app.route('/check', methods=['POST']) def check_solution(): data = request.json @@ -35,5 +38,27 @@ def check_solution(): incorrect.append([i, j]) return jsonify({'incorrect': incorrect}) +# Return one valid hint for an empty cell +@app.route('/hint', methods=['POST']) +def get_hint(): + data = request.json + board = data.get('board') + solution = CURRENT.get('solution') + current_puzzle = CURRENT.get('puzzle') + + if solution is None or current_puzzle is None: + return jsonify({'error': 'No game in progress'}), 400 + + board_to_update = current_puzzle if board is None else board + for i in range(sudoku_logic.SIZE): + for j in range(sudoku_logic.SIZE): + if board_to_update[i][j] == 0: + value = solution[i][j] + board_to_update[i][j] = value + CURRENT['puzzle'] = board_to_update + return jsonify({'row': i, 'col': j, 'value': value}) + + return jsonify({'error': 'No empty cells left'}), 400 + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/starter/inspect_html.py b/starter/inspect_html.py new file mode 100644 index 000000000..ad809fc0d --- /dev/null +++ b/starter/inspect_html.py @@ -0,0 +1,4 @@ +import requests +html = requests.get('http://127.0.0.1:5000/').text +print('has select', 'id="difficulty"' in html) +print('has script', '/static/main.js' in html) diff --git a/starter/inspect_solver.py b/starter/inspect_solver.py new file mode 100644 index 000000000..27bfc5aad --- /dev/null +++ b/starter/inspect_solver.py @@ -0,0 +1,7 @@ +import sudoku_logic as s +board = [[8,6,4,5,2,9,7,3,1],[3,5,1,7,4,8,9,2,6],[9,7,2,6,3,1,8,4,5],[4,1,3,8,9,7,5,6,2],[6,9,8,4,5,2,1,7,3],[5,2,7,1,6,3,4,9,8],[7,8,6,2,1,4,3,5,9],[2,4,9,3,8,5,6,1,7],[1,3,5,9,7,6,2,8,4]] +board[0][0] = 0 +print('safe 8', s.is_safe(board, 0, 0, 8)) +print('safe 7', s.is_safe(board, 0, 0, 7)) +print('empties', [(r, c) for r in range(9) for c in range(9) if board[r][c] == 0]) +print('solutions', s.count_solutions(board, limit=2)) diff --git a/starter/instruction.md b/starter/instruction.md new file mode 100644 index 000000000..52e2cdc67 --- /dev/null +++ b/starter/instruction.md @@ -0,0 +1,57 @@ +# GitHub Copilot Instructions + +## Project Overview +This project is a Flask-based Sudoku game refactored using GitHub Copilot. + +## Coding Style +- Use modern Python and Flask best practices. +- Keep functions small and reusable. +- Follow consistent naming conventions. +- Add comments for important logic. +- Avoid duplicate code. + +## Project Structure +- Keep game logic in `sudoku_logic.py`. +- Keep Flask routes in `app.py`. +- Keep HTML templates inside `templates/`. +- Keep CSS and JavaScript inside `static/`. +- Store tests inside the `tests/` folder. + +## Testing +- Write or update pytest tests for new functionality. +- Ensure all tests pass before submitting changes. + +## UI Guidelines +- Keep the interface responsive. +- Support both light and dark mode. +- Maintain consistent styling across components. + +## Error Handling +- Return meaningful error messages. +- Handle invalid user input gracefully. + +## Copilot Guidance +When suggesting code: +- Reuse existing project patterns. +- Preserve existing functionality. +- Explain major code changes before applying them. +- Prefer clean, readable, and maintainable code. + +## Refactor Legacy Code to Modern Standards + +### Modular Design +- Break code into reusable, single-responsibility components. +- Separate Sudoku game logic, Flask routes, UI rendering, validation, and leaderboard logic. +- Reuse helper functions instead of duplicating code. +- Keep functions small, focused, and easy to test. + +### Documentation +- Add comments for complex algorithms such as Sudoku generation, unique solution checking, hint generation, and puzzle validation. +- Use meaningful variable and function names. +- Keep formatting and naming consistent throughout the project. + +### Error Handling +- Validate all user inputs before processing. +- Return meaningful JSON error messages from Flask routes. +- Handle missing game state or invalid requests gracefully. +- Avoid crashes by checking for invalid or empty values. \ No newline at end of file diff --git a/starter/pytest.ini b/starter/pytest.ini new file mode 100644 index 000000000..5ee647716 --- /dev/null +++ b/starter/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/starter/requirements.txt b/starter/requirements.txt index 3ab9a28ba..de7fd0181 100644 --- a/starter/requirements.txt +++ b/starter/requirements.txt @@ -1 +1,2 @@ Flask>=2.0 +pytest>=7.0 diff --git a/starter/screenshots/copilot_check_puzzle_prompt.png b/starter/screenshots/copilot_check_puzzle_prompt.png new file mode 100644 index 000000000..a077905f4 Binary files /dev/null and b/starter/screenshots/copilot_check_puzzle_prompt.png differ diff --git a/starter/screenshots/copilot_check_puzzle_response.png b/starter/screenshots/copilot_check_puzzle_response.png new file mode 100644 index 000000000..3c6970cb5 Binary files /dev/null and b/starter/screenshots/copilot_check_puzzle_response.png differ diff --git a/starter/screenshots/copilot_dark_mode.png b/starter/screenshots/copilot_dark_mode.png new file mode 100644 index 000000000..61afca06e Binary files /dev/null and b/starter/screenshots/copilot_dark_mode.png differ diff --git a/starter/screenshots/copilot_difficulty_debug_prompt.png b/starter/screenshots/copilot_difficulty_debug_prompt.png new file mode 100644 index 000000000..0a57c2abd Binary files /dev/null and b/starter/screenshots/copilot_difficulty_debug_prompt.png differ diff --git a/starter/screenshots/copilot_grid_styling.png b/starter/screenshots/copilot_grid_styling.png new file mode 100644 index 000000000..d996c3a21 Binary files /dev/null and b/starter/screenshots/copilot_grid_styling.png differ diff --git a/starter/screenshots/copilot_hint_feature.png b/starter/screenshots/copilot_hint_feature.png new file mode 100644 index 000000000..2781aa0b7 Binary files /dev/null and b/starter/screenshots/copilot_hint_feature.png differ diff --git a/starter/screenshots/copilot_suggestion_evaluation.png b/starter/screenshots/copilot_suggestion_evaluation.png new file mode 100644 index 000000000..e52ad6cbb Binary files /dev/null and b/starter/screenshots/copilot_suggestion_evaluation.png differ diff --git a/starter/screenshots/copilot_suggestion_review.png b/starter/screenshots/copilot_suggestion_review.png new file mode 100644 index 000000000..deecbcd67 Binary files /dev/null and b/starter/screenshots/copilot_suggestion_review.png differ diff --git a/starter/screenshots/copilot_testing_framework_prompt.png b/starter/screenshots/copilot_testing_framework_prompt.png new file mode 100644 index 000000000..bea5a81cf Binary files /dev/null and b/starter/screenshots/copilot_testing_framework_prompt.png differ diff --git a/starter/screenshots/copilot_testing_framework_response.png b/starter/screenshots/copilot_testing_framework_response.png new file mode 100644 index 000000000..cff2b116a Binary files /dev/null and b/starter/screenshots/copilot_testing_framework_response.png differ diff --git a/starter/screenshots/copilot_timer_feature.png b/starter/screenshots/copilot_timer_feature.png new file mode 100644 index 000000000..099e7f042 Binary files /dev/null and b/starter/screenshots/copilot_timer_feature.png differ diff --git a/starter/screenshots/copilot_top10_scores_prompt.png b/starter/screenshots/copilot_top10_scores_prompt.png new file mode 100644 index 000000000..0dc804043 Binary files /dev/null and b/starter/screenshots/copilot_top10_scores_prompt.png differ diff --git a/starter/screenshots/copilot_top10_scores_response.png b/starter/screenshots/copilot_top10_scores_response.png new file mode 100644 index 000000000..e7ab54851 Binary files /dev/null and b/starter/screenshots/copilot_top10_scores_response.png differ diff --git a/starter/screenshots/copilot_unique_solution_prompt.png b/starter/screenshots/copilot_unique_solution_prompt.png new file mode 100644 index 000000000..f84ccf581 Binary files /dev/null and b/starter/screenshots/copilot_unique_solution_prompt.png differ diff --git a/starter/screenshots/copilot_unique_solution_response.png b/starter/screenshots/copilot_unique_solution_response.png new file mode 100644 index 000000000..bbd295852 Binary files /dev/null and b/starter/screenshots/copilot_unique_solution_response.png differ diff --git a/starter/static/main.js b/starter/static/main.js index 2028e1026..1d5822013 100644 --- a/starter/static/main.js +++ b/starter/static/main.js @@ -1,6 +1,76 @@ // Client-side rendering and interaction for the Flask-backed Sudoku const SIZE = 9; let puzzle = []; +let timerInterval = null; +let startTime = null; +let hintsUsed = 0; +const LEADERBOARD_KEY = 'sudoku-leaderboard'; +const THEME_KEY = 'sudoku-theme'; + +function applyTheme(theme) { + document.body.classList.toggle('dark-mode', theme === 'dark'); + const toggle = document.getElementById('theme-toggle'); + if (toggle) { + toggle.textContent = theme === 'dark' ? 'Light Mode' : 'Dark Mode'; + } +} + +function loadTheme() { + const savedTheme = window.localStorage.getItem(THEME_KEY); + return savedTheme || 'light'; +} + +function saveTheme(theme) { + window.localStorage.setItem(THEME_KEY, theme); +} + +function loadLeaderboard() { + const stored = window.localStorage.getItem(LEADERBOARD_KEY); + return stored ? JSON.parse(stored) : []; +} + +function saveLeaderboard(entries) { + window.localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(entries)); +} + +function renderLeaderboard() { + const list = document.getElementById('leaderboard-list'); + const entries = loadLeaderboard().slice(0, 10); + list.innerHTML = ''; + if (entries.length === 0) { + const item = document.createElement('li'); + item.textContent = 'No completed games yet.'; + list.appendChild(item); + return; + } + entries.forEach((entry, index) => { + const item = document.createElement('li'); + item.textContent = `${index + 1}. ${entry.name} — ${entry.difficulty} — ${entry.time} — hints: ${entry.hintsUsed}`; + list.appendChild(item); + }); +} + +function recordScore() { + const name = window.prompt('Enter your name for the leaderboard:', 'Player'); + if (!name) { + return; + } + const entries = loadLeaderboard(); + const elapsed = startTime ? Math.floor((Date.now() - startTime) / 1000) : 0; + entries.push({ + name: name.trim(), + time: formatTime(elapsed), + difficulty: document.getElementById('difficulty').value, + hintsUsed + }); + entries.sort((a, b) => { + const timeA = a.time; + const timeB = b.time; + return timeA.localeCompare(timeB); + }); + saveLeaderboard(entries.slice(0, 10)); + renderLeaderboard(); +} function createBoardElement() { const boardDiv = document.getElementById('sudoku-board'); @@ -47,11 +117,50 @@ function renderPuzzle(puz) { } } +function formatTime(seconds) { + const mins = Math.floor(seconds / 60).toString().padStart(2, '0'); + const secs = (seconds % 60).toString().padStart(2, '0'); + return `${mins}:${secs}`; +} + +function updateTimer() { + const timerEl = document.getElementById('timer'); + if (!startTime) { + timerEl.innerText = 'Time: 00:00'; + return; + } + const elapsed = Math.floor((Date.now() - startTime) / 1000); + timerEl.innerText = `Time: ${formatTime(elapsed)}`; +} + +function startTimer() { + stopTimer(); + startTime = Date.now(); + updateTimer(); + timerInterval = window.setInterval(updateTimer, 1000); +} + +function stopTimer() { + if (timerInterval) { + window.clearInterval(timerInterval); + timerInterval = null; + } +} + +function resetTimer() { + stopTimer(); + startTime = null; + updateTimer(); +} + async function newGame() { - const res = await fetch('/new'); + const difficulty = document.getElementById('difficulty').value; + const res = await fetch(`/new?difficulty=${difficulty}`); const data = await res.json(); renderPuzzle(data.puzzle); document.getElementById('message').innerText = ''; + hintsUsed = 0; + startTimer(); } async function checkSolution() { @@ -88,18 +197,115 @@ async function checkSolution() { } } if (incorrect.size === 0) { + stopTimer(); msg.style.color = '#388e3c'; msg.innerText = 'Congratulations! You solved it!'; + recordScore(); } else { msg.style.color = '#d32f2f'; msg.innerText = 'Some cells are incorrect.'; } } +async function checkPuzzle() { + const boardDiv = document.getElementById('sudoku-board'); + const inputs = boardDiv.getElementsByTagName('input'); + const board = []; + for (let i = 0; i < SIZE; i++) { + board[i] = []; + for (let j = 0; j < SIZE; j++) { + const idx = i * SIZE + j; + const val = inputs[idx].value; + board[i][j] = val ? parseInt(val, 10) : 0; + } + } + const res = await fetch('/check', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({board}) + }); + const data = await res.json(); + const msg = document.getElementById('message'); + if (data.error) { + msg.style.color = '#d32f2f'; + msg.innerText = data.error; + return; + } + const incorrect = new Set(data.incorrect.map(x => x[0]*SIZE + x[1])); + for (let idx = 0; idx < inputs.length; idx++) { + const inp = inputs[idx]; + if (inp.disabled) continue; + inp.className = 'sudoku-cell'; + if (incorrect.has(idx)) { + inp.className = 'sudoku-cell incorrect'; + } + } + if (incorrect.size === 0) { + stopTimer(); + msg.style.color = '#388e3c'; + msg.innerText = 'Puzzle solved correctly!'; + recordScore(); + } else { + msg.style.color = '#d32f2f'; + msg.innerText = 'Some cells are incorrect.'; + } +} + +async function requestHint() { + const boardDiv = document.getElementById('sudoku-board'); + const inputs = boardDiv.getElementsByTagName('input'); + const board = []; + for (let i = 0; i < SIZE; i++) { + board[i] = []; + for (let j = 0; j < SIZE; j++) { + const idx = i * SIZE + j; + const val = inputs[idx].value; + board[i][j] = val ? parseInt(val, 10) : 0; + } + } + + const res = await fetch('/hint', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({board}) + }); + const data = await res.json(); + const msg = document.getElementById('message'); + if (data.error) { + msg.style.color = '#d32f2f'; + msg.innerText = data.error; + return; + } + + puzzle[data.row][data.col] = data.value; + const idx = data.row * SIZE + data.col; + const inp = inputs[idx]; + inp.value = data.value; + inp.disabled = true; + inp.className = 'sudoku-cell prefilled'; + hintsUsed += 1; + msg.style.color = '#388e3c'; + msg.innerText = 'Hint applied.'; +} + +function toggleTheme() { + const currentTheme = document.body.classList.contains('dark-mode') ? 'dark' : 'light'; + const nextTheme = currentTheme === 'dark' ? 'light' : 'dark'; + applyTheme(nextTheme); + saveTheme(nextTheme); +} + // Wire buttons window.addEventListener('load', () => { + const difficultySelect = document.getElementById('difficulty'); document.getElementById('new-game').addEventListener('click', newGame); document.getElementById('check-solution').addEventListener('click', checkSolution); - // initialize + document.getElementById('check-puzzle').addEventListener('click', checkPuzzle); + document.getElementById('hint-button').addEventListener('click', requestHint); + document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + difficultySelect.addEventListener('change', newGame); + applyTheme(loadTheme()); + resetTimer(); + renderLeaderboard(); newGame(); }); \ No newline at end of file diff --git a/starter/static/styles.css b/starter/static/styles.css index 1a6218ff9..f04e5df6f 100644 --- a/starter/static/styles.css +++ b/starter/static/styles.css @@ -1,85 +1,395 @@ body { - font-family: Arial, sans-serif; - background: #f4f4f4; - text-align: center; + font-family: 'Segoe UI', Arial, sans-serif; + background: linear-gradient(135deg, #f4f7ff 0%, #eef4ff 100%); + color: #21314d; margin: 0; - padding: 0; + padding: 24px; + transition: background 0.2s ease, color 0.2s ease; +} + +body.dark-mode { + background: linear-gradient(135deg, #111827 0%, #1f2937 100%); + color: #f5f7fb; +} + +.app-shell { + max-width: 1180px; + margin: 0 auto; +} + +.hero-card, +.controls-card, +.board-card, +.leaderboard-card { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.4); + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); + border-radius: 20px; +} + +body.dark-mode .hero-card, +body.dark-mode .controls-card, +body.dark-mode .board-card, +body.dark-mode .leaderboard-card { + background: rgba(17, 24, 39, 0.82); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); +} + +.hero-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 30px; + margin-bottom: 18px; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 0.75rem; + color: #6d79a8; + margin: 0 0 6px; } h1 { - margin-top: 30px; - color: #333; + margin: 0; + font-size: 2rem; +} + +.controls-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 20px 24px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.control-group, +.status-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +label { + font-weight: 600; +} + +select, +button { + border: none; + border-radius: 999px; + padding: 10px 16px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +select { + background: #eef3ff; + color: #21314d; +} + +body.dark-mode select { + background: #2b364a; + color: #f5f7fb; +} + +button { + background: linear-gradient(135deg, #4f8cff 0%, #3568d4 100%); + color: #fff; + box-shadow: 0 8px 16px rgba(53, 104, 212, 0.2); +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 18px rgba(53, 104, 212, 0.24); +} + +.ghost-button { + background: #f2f6ff; + color: #21314d; + box-shadow: none; +} + +body.dark-mode .ghost-button { + background: #334155; + color: #f5f7fb; +} + +.timer-pill, +.message-pill { + padding: 10px 14px; + border-radius: 999px; + background: #eef3ff; + color: #21314d; + min-width: 90px; + text-align: center; +} + +body.dark-mode .timer-pill, +body.dark-mode .message-pill { + background: #2b364a; + color: #f5f7fb; +} + +.dashboard { + display: grid; + grid-template-columns: 1.4fr 0.8fr; + gap: 18px; + align-items: start; +} + +.board-card { + padding: 24px; + display: flex; + justify-content: center; } #sudoku-board { - display: inline-block; - margin: 30px auto; - border: 4px solid #333; + display: grid; + grid-template-rows: repeat(9, 1fr); + border: 4px solid #21314d; + border-radius: 16px; + overflow: hidden; background: #fff; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); + width: min(100%, 486px); + max-width: 100%; + aspect-ratio: 1 / 1; +} + +body.dark-mode #sudoku-board { + border-color: #eef3ff; + background: #111827; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); } .sudoku-row { - display: flex; + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); + width: 100%; + height: 100%; } .sudoku-cell { - width: 40px; - height: 40px; - border: 1px solid #bbb; + width: 100%; + height: 100%; + box-sizing: border-box; + margin: 0; + padding: 0; + border: 1px solid #c8d2e8; text-align: center; - font-size: 20px; + font-size: clamp(0.9rem, 2.2vw, 1.15rem); outline: none; - background: #fafafa; - transition: background 0.2s; + background: #f9fbff; + color: #22314f; + display: block; + transition: background 0.2s ease, transform 0.2s ease; +} + +body.dark-mode .sudoku-cell { + background: #263143; + border-color: #50607a; + color: #f5f7fb; } .sudoku-cell:focus { - background: #e0f7fa; + background: #e0f2fe; + transform: scale(1.02); +} + +body.dark-mode .sudoku-cell:focus { + background: #1d4ed8; } .sudoku-cell.prefilled { - background: #e0e0e0; - font-weight: bold; - color: #333; + background: #e7ecf7; + font-weight: 700; + color: #21314d; +} + +body.dark-mode .sudoku-cell.prefilled { + background: #4b5563; + color: #f5f7fb; } .sudoku-cell.incorrect { - background: #ffcdd2; + background: #ffd7d7; + color: #8a1f1f; +} + +body.dark-mode .sudoku-cell.incorrect { + background: #7f1d1d; + color: #ffe4e6; } .sudoku-cell:nth-child(3), .sudoku-cell:nth-child(6) { - border-right: 3px solid #333; + border-right: 3px solid #21314d; } .sudoku-row:nth-child(3) .sudoku-cell, .sudoku-row:nth-child(6) .sudoku-cell { - border-bottom: 3px solid #333; + border-bottom: 3px solid #21314d; } -.controls { - margin: 20px auto; +body.dark-mode .sudoku-cell:nth-child(3), +body.dark-mode .sudoku-cell:nth-child(6) { + border-right-color: #eef3ff; } -button { - padding: 8px 18px; - margin: 0 8px; - font-size: 16px; - border: none; - background: #1976d2; - color: #fff; - border-radius: 4px; - cursor: pointer; - transition: background 0.2s; +body.dark-mode .sudoku-row:nth-child(3) .sudoku-cell, +body.dark-mode .sudoku-row:nth-child(6) .sudoku-cell { + border-bottom-color: #eef3ff; } -button:hover { - background: #1565c0; +.sudoku-row:nth-child(1) .sudoku-cell, +.sudoku-row:nth-child(2) .sudoku-cell, +.sudoku-row:nth-child(3) .sudoku-cell { + background-color: #f4f8ff; +} + +.sudoku-row:nth-child(4) .sudoku-cell, +.sudoku-row:nth-child(5) .sudoku-cell, +.sudoku-row:nth-child(6) .sudoku-cell { + background-color: #eef4ff; } -#message { - margin-left: 20px; - font-size: 16px; - color: #d32f2f; +.sudoku-row:nth-child(7) .sudoku-cell, +.sudoku-row:nth-child(8) .sudoku-cell, +.sudoku-row:nth-child(9) .sudoku-cell { + background-color: #f4f8ff; +} + +.sudoku-row:nth-child(1) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(1) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(1) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(2) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(2) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(2) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(3) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(3) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(3) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(7), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(8), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(9), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(7), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(8), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(9), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(7), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(8), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(9), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(3) { + background-color: #e9f0ff; +} + +body.dark-mode .sudoku-row:nth-child(1) .sudoku-cell:nth-child(1), +body.dark-mode .sudoku-row:nth-child(1) .sudoku-cell:nth-child(2), +body.dark-mode .sudoku-row:nth-child(1) .sudoku-cell:nth-child(3), +body.dark-mode .sudoku-row:nth-child(2) .sudoku-cell:nth-child(1), +body.dark-mode .sudoku-row:nth-child(2) .sudoku-cell:nth-child(2), +body.dark-mode .sudoku-row:nth-child(2) .sudoku-cell:nth-child(3), +body.dark-mode .sudoku-row:nth-child(3) .sudoku-cell:nth-child(1), +body.dark-mode .sudoku-row:nth-child(3) .sudoku-cell:nth-child(2), +body.dark-mode .sudoku-row:nth-child(3) .sudoku-cell:nth-child(3), +body.dark-mode .sudoku-row:nth-child(4) .sudoku-cell:nth-child(7), +body.dark-mode .sudoku-row:nth-child(4) .sudoku-cell:nth-child(8), +body.dark-mode .sudoku-row:nth-child(4) .sudoku-cell:nth-child(9), +body.dark-mode .sudoku-row:nth-child(5) .sudoku-cell:nth-child(7), +body.dark-mode .sudoku-row:nth-child(5) .sudoku-cell:nth-child(8), +body.dark-mode .sudoku-row:nth-child(5) .sudoku-cell:nth-child(9), +body.dark-mode .sudoku-row:nth-child(6) .sudoku-cell:nth-child(7), +body.dark-mode .sudoku-row:nth-child(6) .sudoku-cell:nth-child(8), +body.dark-mode .sudoku-row:nth-child(6) .sudoku-cell:nth-child(9), +body.dark-mode .sudoku-row:nth-child(7) .sudoku-cell:nth-child(1), +body.dark-mode .sudoku-row:nth-child(7) .sudoku-cell:nth-child(2), +body.dark-mode .sudoku-row:nth-child(7) .sudoku-cell:nth-child(3), +body.dark-mode .sudoku-row:nth-child(8) .sudoku-cell:nth-child(1), +body.dark-mode .sudoku-row:nth-child(8) .sudoku-cell:nth-child(2), +body.dark-mode .sudoku-row:nth-child(8) .sudoku-cell:nth-child(3), +body.dark-mode .sudoku-row:nth-child(9) .sudoku-cell:nth-child(1), +body.dark-mode .sudoku-row:nth-child(9) .sudoku-cell:nth-child(2), +body.dark-mode .sudoku-row:nth-child(9) .sudoku-cell:nth-child(3) { + background-color: #334155; +} + +.leaderboard-card { + padding: 18px 20px; +} + +.leaderboard { + width: 100%; + padding: 0; + background: transparent; + box-shadow: none; +} + +.leaderboard h2 { + margin-top: 0; + font-size: 1.15rem; +} + +#leaderboard-list { + text-align: left; + padding-left: 18px; + margin: 0; +} + +#leaderboard-list li { + margin-bottom: 8px; +} + +@media (max-width: 900px) { + .dashboard { + grid-template-columns: 1fr; + } + + .leaderboard-card { + order: 2; + } +} + +@media (max-width: 640px) { + body { + padding: 14px; + } + + .hero-card, + .controls-card, + .board-card, + .leaderboard-card { + border-radius: 16px; + } + + .hero-card { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .controls-card { + flex-direction: column; + align-items: flex-start; + } + + .sudoku-cell { + width: 34px; + height: 34px; + font-size: 1rem; + } } diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py index 443b24524..5066fc3b9 100644 --- a/starter/sudoku_logic.py +++ b/starter/sudoku_logic.py @@ -3,19 +3,26 @@ SIZE = 9 EMPTY = 0 +DIFFICULTY_SETTINGS = { + "easy": 45, + "medium": 35, + "hard": 25, +} +# Create a deep copy of the Sudoku board to avoid modifying the original board. def deep_copy(board): return copy.deepcopy(board) +# Create an empty 9x9 Sudoku board initialized with EMPTY values. def create_empty_board(): return [[EMPTY for _ in range(SIZE)] for _ in range(SIZE)] +# Check whether a number can be safely placed in the specified row and column. 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: return False - # Check 3x3 box + start_row = row - row % 3 start_col = col - col % 3 for i in range(3): @@ -24,6 +31,7 @@ def is_safe(board, row, col, num): return False return True +# Fill the Sudoku board using a recursive backtracking algorithm. def fill_board(board): for row in range(SIZE): for col in range(SIZE): @@ -39,19 +47,66 @@ def fill_board(board): return False return True +# Remove cells from the completed board while ensuring the puzzle has a unique solution. def remove_cells(board, clues): - attempts = SIZE * SIZE - clues - while attempts > 0: + cells_to_remove = SIZE * SIZE - clues + attempts = 0 + while cells_to_remove > 0 and attempts < SIZE * SIZE * 2: row = random.randrange(SIZE) col = random.randrange(SIZE) - if board[row][col] != EMPTY: - board[row][col] = EMPTY - attempts -= 1 - -def generate_puzzle(clues=35): - board = create_empty_board() - fill_board(board) - solution = deep_copy(board) - remove_cells(board, clues) - puzzle = deep_copy(board) - return puzzle, solution + attempts += 1 + if board[row][col] == EMPTY: + continue + + original = board[row][col] + board[row][col] = EMPTY + if count_solutions(deep_copy(board), limit=2) == 1: + cells_to_remove -= 1 + else: + board[row][col] = original + +# Generate a Sudoku puzzle based on the selected difficulty level. +def generate_puzzle(difficulty="medium"): + difficulty_name = difficulty.lower() + if difficulty_name not in DIFFICULTY_SETTINGS: + raise ValueError("difficulty must be one of: easy, medium, hard") + + clues = DIFFICULTY_SETTINGS[difficulty_name] + while True: + board = create_empty_board() + fill_board(board) + solution = deep_copy(board) + puzzle = deep_copy(board) + remove_cells(puzzle, clues) + if sum(cell != EMPTY for row in puzzle for cell in row) == clues: + return puzzle, solution + +# Count the number of valid solutions to verify that the puzzle has a unique solution. +def count_solutions(board, limit=2): + board = deep_copy(board) + + def search(): + next_empty = None + for row in range(SIZE): + for col in range(SIZE): + if board[row][col] == EMPTY: + next_empty = (row, col) + break + if next_empty is not None: + break + + if next_empty is None: + return 1 + + row, col = next_empty + solutions = 0 + for candidate in range(1, SIZE + 1): + if is_safe(board, row, col, candidate): + board[row][col] = candidate + solutions += search() + board[row][col] = EMPTY + if solutions >= limit: + return limit + return solutions + + return search() diff --git a/starter/templates/index.html b/starter/templates/index.html index e42ad04da..46864e362 100644 --- a/starter/templates/index.html +++ b/starter/templates/index.html @@ -6,12 +6,47 @@
-Daily challenge
+