diff --git a/README.md b/README.md index 73753db50..e23e3bee3 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,83 @@ 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. + +# Refactor a Sudoku Game with GitHub Copilot + +A modern Flask-based Sudoku game refactored from legacy Python code with GitHub Copilot. + +The project adds Sudoku generation and validation, difficulty levels, hints, puzzle checking, a timer, a persistent Top 10 leaderboard, dark mode, responsive styling, and accessibility improvements. +## Features + +### Sudoku Game +- Generates valid Sudoku puzzles. +- Ensures each generated puzzle has exactly one unique solution. +- Supports Easy, Medium, and Hard difficulty levels. +- Difficulty levels change the number of prefilled cells. +- Prefilled cells are locked and cannot be edited. + +### Validation +- Provides immediate feedback for invalid entries. +- Check Puzzle button highlights incorrect entries. +- Detects when the puzzle has been correctly completed. +- Displays a congratulatory completion message. + +### Hint System +- Hint button fills one correct empty cell. +- Hint-filled cells are visually distinguished. +- Hint-filled cells are locked. +- Tracks the number of hints used. + +### Timer +- Starts when a new puzzle begins. +- Tracks elapsed solving time. +- Stops when the puzzle is completed. + +### Top 10 Leaderboard +- Stores completed scores in browser localStorage. +- Records player name. +- Records completion time. +- Records difficulty level. +- Records number of hints used. +- Sorts scores by fastest completion time. +- Keeps only the top 10 scores. +- Scores persist between browser sessions. + +### User Interface +- Light and dark mode. +- Responsive desktop and mobile layout. +- Alternating styles for the 3x3 Sudoku regions. +- Accessible and readable controls. +- Keyboard-friendly interface and visible focus states. +## Testing + +Run the following command from the starter directory: + +pytest + +## How to Play + +1. Select a difficulty level: Easy, Medium, or Hard. +2. Start a new puzzle. +3. Fill the empty Sudoku cells. +4. Prefilled cells cannot be edited. +5. Use the Check Puzzle button to identify incorrect entries. +6. Use Hint when assistance is needed. +7. The timer tracks the solving time. +8. Complete the puzzle correctly to finish the game. +9. Enter your name when prompted after completing a puzzle. +10. Completed scores are stored in the Top 10 leaderboard. +## Accessibility and Responsive Design + +The application was reviewed for: + +- Keyboard navigation +- Visible focus states +- Accessible button and control labels +- Readable text +- Color contrast +- Error and success feedback +- Dark mode readability +- Responsive desktop and mobile layouts + +The Sudoku grid and controls are designed to remain usable across different screen sizes and themes. \ No newline at end of file diff --git a/instruction.md b/instruction.md new file mode 100644 index 000000000..7f003bc9d --- /dev/null +++ b/instruction.md @@ -0,0 +1,164 @@ +# GitHub Copilot Instructions — Flask Sudoku Project + +## Project Overview + +This project is a Python Flask Sudoku game that is being refactored from legacy code into a modern, modular, maintainable application. + +The application should provide: +- Sudoku puzzle generation +- Unique-solution validation +- Easy, Medium, and Hard difficulty levels +- Locked prefilled cells +- Immediate input validation +- Check Puzzle functionality +- Hint functionality +- Puzzle completion detection +- Timer +- Top 10 leaderboard +- Browser localStorage persistence +- Light and dark modes +- Responsive desktop and mobile layouts +- Accessible user interface + +## General Coding Standards + +- Use modern Python practices. +- Keep code readable, maintainable, and modular. +- Use clear and descriptive variable, function, and class names. +- Keep functions focused on a single responsibility. +- Avoid unnecessary duplication. +- Avoid unnecessary dependencies. +- Preserve existing functionality when refactoring. +- Do not modify unrelated files or features. +- Prefer simple and understandable solutions over unnecessarily complex implementations. +- Handle errors gracefully. +- Provide clear user-facing error and status messages. +- Add comments only where they improve understanding of non-obvious logic. + +## Application Architecture + +Keep responsibilities separated. + +Prefer the following separation: + +- Flask routes handle HTTP requests and responses. +- Sudoku generation handles puzzle creation. +- Sudoku solving handles solution finding and solution counting. +- Validation handles Sudoku rule validation and user input checking. +- Frontend HTML handles page structure. +- CSS handles styling, themes, responsiveness, and visual states. +- JavaScript handles client-side interaction and dynamic UI behavior. +- Leaderboard functionality handles localStorage persistence and score management. + +Avoid putting all application logic into one large Flask file. + +## Sudoku Requirements + +- Every generated Sudoku puzzle must be valid. +- Every generated puzzle must have exactly one solution. +- The solution must be verified before the puzzle is presented to the player. +- Easy, Medium, and Hard must have different numbers of prefilled cells. +- Easy should provide more clues than Medium. +- Medium should provide more clues than Hard. +- Prefilled cells must be locked and must not be editable. +- Hint-filled cells must also become locked. +- User-entered values must be validated. +- Incorrect entries must receive clear visual feedback. +- Completed puzzles must be detected correctly. + +## Game Features + +### Difficulty + +Support: +- Easy +- Medium +- Hard + +Changing difficulty should start an appropriate new puzzle. + +### Hint + +- Fill one correct empty cell. +- Never overwrite a user's existing value. +- Visually distinguish the hinted cell. +- Lock the hinted cell. +- Track the number of hints used. + +### Check Puzzle + +- Check the current board against the correct solution. +- Highlight incorrect entries. +- Do not incorrectly mark valid entries. +- Use event delegation where required by the project. + +### Timer + +- Start the timer when a new puzzle begins. +- Display elapsed time clearly. +- Stop the timer when the puzzle is correctly completed. +- Reset the timer for a new puzzle. + +### Leaderboard + +Store the Top 10 scores in browser localStorage. + +Each score should contain: +- Player name +- Completion time +- Difficulty +- Number of hints used + +Sort scores by fastest completion time and keep only the best 10 scores. + +Handle missing or corrupted localStorage data gracefully. + +## Frontend and Accessibility + +- Use semantic HTML where appropriate. +- Ensure controls have clear labels. +- Ensure buttons are keyboard accessible. +- Provide visible focus states. +- Maintain readable text and controls. +- Maintain sufficient color contrast. +- Do not rely only on color to communicate important information. +- Provide clear feedback for errors and successful actions. +- Ensure dark mode remains readable and accessible. +- Ensure the layout works on desktop, tablet, and mobile. +- Avoid horizontal scrolling where possible. +- Ensure the Sudoku grid does not shift when styles or states change. +- Use alternating visual styles for the 3x3 Sudoku regions. + +## Testing + +- Use pytest for Python tests where appropriate. +- Run tests after every major change. +- Do not remove or weaken tests simply to make them pass. +- Add tests for important Sudoku logic and new functionality where practical. +- Preserve existing behavior during refactoring. + +## GitHub Copilot Usage + +Before implementing a major change: +1. Analyze the existing code. +2. Propose an approach. +3. Explain which files will change. +4. Wait for approval before making significant changes. + +When reviewing Copilot suggestions: +- Do not blindly accept generated code. +- Check whether the approach is necessary and maintainable. +- Reject unnecessary dependencies. +- Reject unnecessarily complex implementations. +- Prefer the simplest solution that satisfies the requirements. + +Do not rebuild the entire application when only a focused change is required. + +## Change Management + +- Make small, focused changes. +- Avoid unrelated modifications. +- Preserve completed features when adding new functionality. +- Run tests after changes. +- Verify the application manually after major changes. +- If a change causes a regression, identify and fix the root cause instead of removing functionality. \ No newline at end of file diff --git a/screenshots/Screenshot 2026-08-27 121605.png b/screenshots/Screenshot 2026-08-27 121605.png new file mode 100644 index 000000000..2b224e72d Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 121605.png differ diff --git a/screenshots/Screenshot 2026-08-27 124457.png b/screenshots/Screenshot 2026-08-27 124457.png new file mode 100644 index 000000000..fa64da2f2 Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 124457.png differ diff --git a/screenshots/Screenshot 2026-08-27 124631.png b/screenshots/Screenshot 2026-08-27 124631.png new file mode 100644 index 000000000..6d8f39220 Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 124631.png differ diff --git a/screenshots/Screenshot 2026-08-27 131754.png b/screenshots/Screenshot 2026-08-27 131754.png new file mode 100644 index 000000000..4dc619b64 Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 131754.png differ diff --git a/screenshots/Screenshot 2026-08-27 131821.png b/screenshots/Screenshot 2026-08-27 131821.png new file mode 100644 index 000000000..d53fe1361 Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 131821.png differ diff --git a/screenshots/Screenshot 2026-08-27 141225.png b/screenshots/Screenshot 2026-08-27 141225.png new file mode 100644 index 000000000..8ec1a4b3d Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 141225.png differ diff --git a/screenshots/Screenshot 2026-08-27 141252.png b/screenshots/Screenshot 2026-08-27 141252.png new file mode 100644 index 000000000..a5557a8ea Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 141252.png differ diff --git a/screenshots/Screenshot 2026-08-27 141905.png b/screenshots/Screenshot 2026-08-27 141905.png new file mode 100644 index 000000000..affea013b Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 141905.png differ diff --git a/screenshots/Screenshot 2026-08-27 142544.png b/screenshots/Screenshot 2026-08-27 142544.png new file mode 100644 index 000000000..efe02491a Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 142544.png differ diff --git a/screenshots/Screenshot 2026-08-27 143003.png b/screenshots/Screenshot 2026-08-27 143003.png new file mode 100644 index 000000000..aef1a5f9d Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 143003.png differ diff --git a/screenshots/Screenshot 2026-08-27 192017.png b/screenshots/Screenshot 2026-08-27 192017.png new file mode 100644 index 000000000..8a990babe Binary files /dev/null and b/screenshots/Screenshot 2026-08-27 192017.png differ diff --git a/starter/__pycache__/app.cpython-314.pyc b/starter/__pycache__/app.cpython-314.pyc new file mode 100644 index 000000000..dc7513213 Binary files /dev/null and b/starter/__pycache__/app.cpython-314.pyc differ diff --git a/starter/__pycache__/board.cpython-314.pyc b/starter/__pycache__/board.cpython-314.pyc new file mode 100644 index 000000000..8680df488 Binary files /dev/null and b/starter/__pycache__/board.cpython-314.pyc differ diff --git a/starter/__pycache__/game.cpython-314.pyc b/starter/__pycache__/game.cpython-314.pyc new file mode 100644 index 000000000..54afa579f Binary files /dev/null and b/starter/__pycache__/game.cpython-314.pyc differ diff --git a/starter/__pycache__/generator.cpython-314.pyc b/starter/__pycache__/generator.cpython-314.pyc new file mode 100644 index 000000000..04842d705 Binary files /dev/null and b/starter/__pycache__/generator.cpython-314.pyc differ diff --git a/starter/__pycache__/routes.cpython-314.pyc b/starter/__pycache__/routes.cpython-314.pyc new file mode 100644 index 000000000..33e0cb205 Binary files /dev/null and b/starter/__pycache__/routes.cpython-314.pyc differ diff --git a/starter/__pycache__/solver.cpython-314.pyc b/starter/__pycache__/solver.cpython-314.pyc new file mode 100644 index 000000000..58b07d271 Binary files /dev/null and b/starter/__pycache__/solver.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..2c43f3225 Binary files /dev/null and b/starter/__pycache__/sudoku_logic.cpython-314.pyc differ diff --git a/starter/__pycache__/validation.cpython-314.pyc b/starter/__pycache__/validation.cpython-314.pyc new file mode 100644 index 000000000..df646596d Binary files /dev/null and b/starter/__pycache__/validation.cpython-314.pyc differ diff --git a/starter/app.py b/starter/app.py index 0f526b757..bdb02634c 100644 --- a/starter/app.py +++ b/starter/app.py @@ -1,4 +1,6 @@ -from flask import Flask, render_template, jsonify, request +from flask import Flask + +from routes import create_routes import sudoku_logic app = Flask(__name__) @@ -6,34 +8,11 @@ # Keep a simple in-memory store for current puzzle and solution CURRENT = { 'puzzle': None, - 'solution': None + 'solution': None, + 'hints_used': 0, } -@app.route('/') -def index(): - return render_template('index.html') - -@app.route('/new') -def new_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') - 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}) +app.register_blueprint(create_routes(CURRENT, sudoku_logic)) if __name__ == '__main__': - app.run(debug=True) \ No newline at end of file + app.run(host='127.0.0.1', port=5000, debug=False) \ No newline at end of file diff --git a/starter/board.py b/starter/board.py new file mode 100644 index 000000000..a373baca9 --- /dev/null +++ b/starter/board.py @@ -0,0 +1,13 @@ +import copy + + +SIZE = 9 +EMPTY = 0 + + +def deep_copy(board): + return copy.deepcopy(board) + + +def create_empty_board(): + return [[EMPTY for _ in range(SIZE)] for _ in range(SIZE)] diff --git a/starter/game.py b/starter/game.py new file mode 100644 index 000000000..daa465469 --- /dev/null +++ b/starter/game.py @@ -0,0 +1,62 @@ +import time + +from validation import find_incorrect_cells + + +_GAME_TIMES = {} + + +def start_game(current, clues, generate_puzzle): + puzzle, solution = generate_puzzle(clues) + current['puzzle'] = puzzle + current['solution'] = solution + current['hints_used'] = 0 + _GAME_TIMES[id(current)] = { + 'started_at': time.monotonic(), + 'elapsed_seconds': None, + } + return puzzle + + +def complete_game(current): + timing = _GAME_TIMES.get(id(current)) + if timing is None: + return None + if timing['elapsed_seconds'] is None: + timing['elapsed_seconds'] = int(time.monotonic() - timing['started_at']) + return timing['elapsed_seconds'] + + +def check_board(current, board): + solution = current.get('solution') + if solution is None: + return None + + incorrect = find_incorrect_cells(board, solution) + puzzle = current.get('puzzle') + if puzzle is not None: + for row in range(len(puzzle)): + for col in range(len(puzzle[row])): + if puzzle[row][col] != 0 and board[row][col] != puzzle[row][col]: + if [row, col] not in incorrect: + incorrect.append([row, col]) + return incorrect + + +def get_hint(current, board): + puzzle = current.get('puzzle') + solution = current.get('solution') + if puzzle is None or solution is None: + return None + + for row in range(len(puzzle)): + for col in range(len(puzzle[row])): + if puzzle[row][col] == 0 and board[row][col] == 0: + current['hints_used'] = current.get('hints_used', 0) + 1 + return { + 'row': row, + 'col': col, + 'value': solution[row][col], + 'hints_used': current['hints_used'], + } + return None diff --git a/starter/generator.py b/starter/generator.py new file mode 100644 index 000000000..6340b9997 --- /dev/null +++ b/starter/generator.py @@ -0,0 +1,61 @@ +import random + +from board import EMPTY, SIZE, create_empty_board, deep_copy +from solver import count_solutions, fill_board + +DIFFICULTY_CLUES = { + 'easy': 45, + 'medium': 35, + 'hard': 25, +} + + +def remove_cells(board, clues): + if not 0 <= clues <= SIZE * SIZE: + raise ValueError('clues must be between 0 and 81') + + positions = [(row, col) for row in range(SIZE) for col in range(SIZE)] + random.shuffle(positions) + + for row, col in positions: + if sum(cell != EMPTY for line in board for cell in line) <= clues: + break + + value = board[row][col] + if value == EMPTY: + continue + + board[row][col] = EMPTY + if count_solutions(board) != 1: + board[row][col] = value + + if sum(cell != EMPTY for line in board for cell in line) != clues: + raise RuntimeError('could not reach the requested clue count') + + +def generate_puzzle(clues=35, max_attempts=100, difficulty=None): + if difficulty is not None: + try: + clues = DIFFICULTY_CLUES[difficulty.lower()] + except (AttributeError, KeyError): + raise ValueError('difficulty must be easy, medium, or hard') + + if not 0 <= clues <= SIZE * SIZE: + raise ValueError('clues must be between 0 and 81') + if max_attempts < 1: + raise ValueError('max_attempts must be positive') + + for _ in range(max_attempts): + board = create_empty_board() + fill_board(board) + solution = deep_copy(board) + + try: + remove_cells(board, clues) + except RuntimeError: + continue + + if count_solutions(board) == 1: + return deep_copy(board), solution + + raise RuntimeError('could not generate a uniquely solvable puzzle') 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/routes.py b/starter/routes.py new file mode 100644 index 000000000..5bc9c8a8e --- /dev/null +++ b/starter/routes.py @@ -0,0 +1,52 @@ +from flask import Blueprint, jsonify, render_template, request + +import game + + +def create_routes(current, sudoku_logic): + routes = Blueprint('routes', __name__) + + @routes.get('/') + def index(): + return render_template('index.html') + + @routes.get('/new') + def new_game(): + difficulty = request.args.get('difficulty') + if difficulty is not None: + difficulty = difficulty.lower() + if difficulty not in sudoku_logic.DIFFICULTY_CLUES: + return jsonify({'error': 'Difficulty must be easy, medium, or hard'}), 400 + clues = sudoku_logic.DIFFICULTY_CLUES[difficulty] + else: + clues = int(request.args.get('clues', 35)) + puzzle = game.start_game(current, clues, sudoku_logic.generate_puzzle) + return jsonify({'puzzle': puzzle}) + + @routes.post('/check') + def check_solution(): + data = request.json + board = data.get('board') + incorrect = game.check_board(current, board) + if incorrect is None: + return jsonify({'error': 'No game in progress'}), 400 + response = {'incorrect': incorrect} + if current.get('puzzle') is not None and not incorrect and all( + value != 0 for row in board for value in row + ): + elapsed_seconds = game.complete_game(current) + if elapsed_seconds is not None: + response['elapsed_seconds'] = elapsed_seconds + return jsonify(response) + + @routes.post('/hint') + def hint(): + data = request.json or {} + hint_cell = game.get_hint(current, data.get('board', [])) + if hint_cell is None: + if current.get('solution') is None: + return jsonify({'error': 'No game in progress'}), 400 + return jsonify({'error': 'No empty cells remain'}), 400 + return jsonify(hint_cell) + + return routes diff --git a/starter/solver.py b/starter/solver.py new file mode 100644 index 000000000..8856900c9 --- /dev/null +++ b/starter/solver.py @@ -0,0 +1,108 @@ +import random + +from board import EMPTY, SIZE + + +def _candidates(board, row, col): + return [ + candidate + for candidate in range(1, SIZE + 1) + if is_safe(board, row, col, candidate) + ] + + +def _is_valid_partial_board(board): + for row in range(SIZE): + values = [value for value in board[row] if value != EMPTY] + if any(value < 1 or value > SIZE for value in values): + return False + if len(values) != len(set(values)): + return False + + for col in range(SIZE): + values = [board[row][col] for row in range(SIZE) if board[row][col] != EMPTY] + if len(values) != len(set(values)): + return False + + for start_row in range(0, SIZE, 3): + for start_col in range(0, SIZE, 3): + values = [ + board[row][col] + for row in range(start_row, start_row + 3) + for col in range(start_col, start_col + 3) + if board[row][col] != EMPTY + ] + if len(values) != len(set(values)): + return False + + return True + + +def count_solutions(board, limit=2): + if limit < 1 or not _is_valid_partial_board(board): + return 0 + + count = 0 + + def search(): + nonlocal count + + if count >= limit: + return + + best_cell = None + best_candidates = None + for row in range(SIZE): + for col in range(SIZE): + if board[row][col] == EMPTY: + candidates = _candidates(board, row, col) + if not candidates: + return + if best_candidates is None or len(candidates) < len(best_candidates): + best_cell = (row, col) + best_candidates = candidates + + if best_cell is None: + count += 1 + return + + row, col = best_cell + for candidate in best_candidates: + board[row][col] = candidate + search() + board[row][col] = EMPTY + if count >= limit: + return + + search() + return count + + +def is_safe(board, row, col, num): + for index in range(SIZE): + if board[row][index] == num or board[index][col] == num: + return False + + start_row = row - row % 3 + start_col = col - col % 3 + for box_row in range(3): + for box_col in range(3): + if board[start_row + box_row][start_col + box_col] == num: + return False + return True + + +def fill_board(board): + for row in range(SIZE): + for col in range(SIZE): + if board[row][col] == EMPTY: + candidates = list(range(1, SIZE + 1)) + random.shuffle(candidates) + for candidate in candidates: + if is_safe(board, row, col, candidate): + board[row][col] = candidate + if fill_board(board): + return True + board[row][col] = EMPTY + return False + return True diff --git a/starter/static/main.js b/starter/static/main.js index 2028e1026..3511cc999 100644 --- a/starter/static/main.js +++ b/starter/static/main.js @@ -1,30 +1,221 @@ // Client-side rendering and interaction for the Flask-backed Sudoku const SIZE = 9; +const LEADERBOARD_STORAGE_KEY = 'sudokuLeaderboard'; +const THEME_STORAGE_KEY = 'sudokuTheme'; +const MAX_LEADERBOARD_ENTRIES = 10; let puzzle = []; +let gameStartedAt = 0; +let timerInterval = null; +let gameCompleted = false; + +function applyTheme(theme) { + const isDark = theme === 'dark'; + document.documentElement.dataset.theme = isDark ? 'dark' : 'light'; + const toggle = document.getElementById('theme-toggle'); + toggle.setAttribute('aria-pressed', String(isDark)); + toggle.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode'); + toggle.querySelector('.theme-toggle-label').innerText = isDark ? 'Light mode' : 'Dark mode'; +} + +function toggleTheme() { + const nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + applyTheme(nextTheme); + try { + localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + } catch (error) { + // Theme preference is optional when storage is unavailable. + } +} + +function getLeaderboard() { + try { + const stored = JSON.parse(localStorage.getItem(LEADERBOARD_STORAGE_KEY) || '[]'); + if (!Array.isArray(stored)) return []; + return stored.filter((entry) => ( + entry && typeof entry.name === 'string' && + Number.isFinite(entry.time) && entry.time >= 0 && + typeof entry.difficulty === 'string' && + Number.isInteger(entry.hints) && entry.hints >= 0 + )).sort((first, second) => first.time - second.time).slice(0, MAX_LEADERBOARD_ENTRIES); + } catch (error) { + return []; + } +} + +function renderLeaderboard() { + const entriesElement = document.getElementById('leaderboard-entries'); + const emptyElement = document.getElementById('leaderboard-empty'); + entriesElement.innerHTML = ''; + const entries = getLeaderboard(); + emptyElement.hidden = entries.length > 0; + entries.forEach((entry, index) => { + const row = document.createElement('tr'); + [index + 1, entry.name, formatTime(entry.time), entry.difficulty, entry.hints].forEach((value) => { + const cell = document.createElement('td'); + cell.textContent = value; + row.appendChild(cell); + }); + entriesElement.appendChild(row); + }); +} + +function addLeaderboardScore(time, difficulty, hints) { + const name = window.prompt('Enter your name for the leaderboard:'); + const score = { + name: name && name.trim() ? name.trim().slice(0, 30) : 'Anonymous', + time, + difficulty, + hints, + }; + const scores = [...getLeaderboard(), score] + .sort((first, second) => first.time - second.time) + .slice(0, MAX_LEADERBOARD_ENTRIES); + try { + localStorage.setItem(LEADERBOARD_STORAGE_KEY, JSON.stringify(scores)); + } catch (error) { + // Private browsing and disabled storage should not interrupt completion. + } + renderLeaderboard(); +} function createBoardElement() { const boardDiv = document.getElementById('sudoku-board'); boardDiv.innerHTML = ''; - for (let i = 0; i < SIZE; i++) { + for (let row = 0; row < SIZE; row++) { const rowDiv = document.createElement('div'); rowDiv.className = 'sudoku-row'; - for (let j = 0; j < SIZE; j++) { - const input = document.createElement('input'); - input.type = 'text'; - input.maxLength = 1; - input.className = 'sudoku-cell'; - input.dataset.row = i; - input.dataset.col = j; - input.addEventListener('input', (e) => { - const val = e.target.value.replace(/[^1-9]/g, ''); - e.target.value = val; - }); - rowDiv.appendChild(input); + for (let col = 0; col < SIZE; col++) { + const cell = document.createElement('input'); + cell.type = 'text'; + cell.maxLength = 1; + const boxRow = Math.floor(row / 3); + const boxCol = Math.floor(col / 3); + const isShaded = (boxRow + boxCol) % 2 === 0; + cell.className = 'sudoku-cell'; + cell.classList.add(isShaded ? 'box-shade' : 'box-plain'); + cell.dataset.row = row; + cell.dataset.col = col; + rowDiv.appendChild(cell); } boardDiv.appendChild(rowDiv); } } +function readBoard() { + const inputs = document.querySelectorAll('#sudoku-board input'); + const board = Array.from({length: SIZE}, () => Array(SIZE).fill(0)); + inputs.forEach((input) => { + const value = input.value; + board[Number(input.dataset.row)][Number(input.dataset.col)] = value ? parseInt(value, 10) : 0; + }); + return board; +} + +function hasRuleConflict(board, row, col) { + const value = board[row][col]; + if (!value) return false; + + for (let index = 0; index < SIZE; index++) { + if (index !== col && board[row][index] === value) return true; + if (index !== row && board[index][col] === value) return true; + } + + const boxRow = Math.floor(row / 3) * 3; + const boxCol = Math.floor(col / 3) * 3; + for (let boxRowIndex = boxRow; boxRowIndex < boxRow + 3; boxRowIndex++) { + for (let boxColIndex = boxCol; boxColIndex < boxCol + 3; boxColIndex++) { + if ((boxRowIndex !== row || boxColIndex !== col) && board[boxRowIndex][boxColIndex] === value) { + return true; + } + } + } + return false; +} + +function setCellFeedback(input, feedback) { + input.classList.remove('incorrect', 'conflict'); + if (feedback) input.classList.add(feedback); +} + +function setMessage(text, color = '#d32f2f') { + const message = document.getElementById('message'); + message.innerText = text; + message.style.color = color === '#388e3c' ? 'var(--success)' : 'var(--error)'; +} + +function formatTime(totalSeconds) { + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); + const seconds = (totalSeconds % 60).toString().padStart(2, '0'); + return `${minutes}:${seconds}`; +} + +function updateTimer() { + const elapsedSeconds = Math.floor((Date.now() - gameStartedAt) / 1000); + document.getElementById('timer').innerText = `Time: ${formatTime(elapsedSeconds)}`; +} + +function startTimer() { + clearInterval(timerInterval); + gameStartedAt = Date.now(); + updateTimer(); + timerInterval = setInterval(updateTimer, 1000); +} + +function isSolved(board, incorrect) { + const allCellsFilled = board.every((row) => row.every((value) => value !== 0)); + return allCellsFilled && incorrect.length === 0; +} + +function isBoardFilled(board) { + return board.every((row) => row.every((value) => value !== 0)); +} + +function showCompletion(board, incorrect, completedSeconds = null) { + if (gameCompleted || !isSolved(board, incorrect)) return; + + gameCompleted = true; + clearInterval(timerInterval); + const elapsedSeconds = completedSeconds ?? Math.floor((Date.now() - gameStartedAt) / 1000); + document.getElementById('timer').innerText = `Time: ${formatTime(elapsedSeconds)}`; + const hintsUsed = document.getElementById('hints-used').innerText.split(': ')[1]; + addLeaderboardScore(elapsedSeconds, document.getElementById('difficulty').value, Number(hintsUsed) || 0); + document.querySelectorAll('#sudoku-board input').forEach((input) => { + if (!input.classList.contains('prefilled')) input.disabled = true; + }); + setMessage(`Congratulations! You solved it in ${formatTime(elapsedSeconds)} with ${hintsUsed} hints.`, '#388e3c'); +} + +async function validateCell(input) { + const row = Number(input.dataset.row); + const col = Number(input.dataset.col); + const board = readBoard(); + if (hasRuleConflict(board, row, col)) { + setCellFeedback(input, 'conflict'); + setMessage('This value conflicts with another number in its row, column, or box.'); + return; + } + + setCellFeedback(input, ''); + if (!board[row][col]) return; + + const response = await fetch('/check', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({board}) + }); + const data = await response.json(); + if (data.error) { + setMessage(data.error); + return; + } + const isIncorrect = data.incorrect.some((position) => position[0] === row && position[1] === col); + setCellFeedback(input, isIncorrect ? 'incorrect' : ''); + setMessage(isIncorrect ? 'That value does not match the solution.' : ''); + if (JSON.stringify(readBoard()) === JSON.stringify(board)) { + showCompletion(board, data.incorrect, data.elapsed_seconds); + } +} + function renderPuzzle(puz) { puzzle = puz; createBoardElement(); @@ -48,24 +239,41 @@ function renderPuzzle(puz) { } 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 = ''; + gameCompleted = false; + startTimer(); + document.getElementById('hints-used').innerText = 'Hints used: 0'; + setMessage(''); } -async function checkSolution() { - 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; - } +async function useHint() { + const response = await fetch('/hint', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({board: readBoard()}) + }); + const data = await response.json(); + if (data.error) { + setMessage(data.error); + return; } + + const inputs = document.querySelectorAll('#sudoku-board input'); + const input = inputs[data.row * SIZE + data.col]; + input.value = data.value; + input.disabled = true; + input.classList.add('hinted'); + document.getElementById('hints-used').innerText = `Hints used: ${data.hints_used}`; + setMessage('A correct value was added.', '#388e3c'); + if (isBoardFilled(readBoard())) checkSolution(); +} + +async function checkSolution() { + const inputs = document.querySelectorAll('#sudoku-board input'); + const board = readBoard(); const res = await fetch('/check', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -74,7 +282,7 @@ async function checkSolution() { const data = await res.json(); const msg = document.getElementById('message'); if (data.error) { - msg.style.color = '#d32f2f'; + msg.style.color = 'var(--error)'; msg.innerText = data.error; return; } @@ -82,24 +290,48 @@ async function checkSolution() { for (let idx = 0; idx < inputs.length; idx++) { const inp = inputs[idx]; if (inp.disabled) continue; - inp.className = 'sudoku-cell'; + inp.classList.remove('incorrect', 'conflict'); if (incorrect.has(idx)) { - inp.className = 'sudoku-cell incorrect'; + inp.classList.add('incorrect'); } } - if (incorrect.size === 0) { - msg.style.color = '#388e3c'; - msg.innerText = 'Congratulations! You solved it!'; + if (isSolved(board, data.incorrect)) { + showCompletion(board, data.incorrect, data.elapsed_seconds); + } else if (!isBoardFilled(board) && incorrect.size === 0) { + msg.style.color = 'var(--error)'; + msg.innerText = 'Fill in all cells to complete the puzzle.'; } else { - msg.style.color = '#d32f2f'; + msg.style.color = 'var(--error)'; msg.innerText = 'Some cells are incorrect.'; } } -// Wire buttons +function handleBoardInput(event) { + if (event.target.matches('input.sudoku-cell:not(:disabled)')) { + event.target.value = event.target.value.replace(/[^1-9]/g, ''); + validateCell(event.target); + } +} + +function handleControlClick(event) { + const action = event.target.closest('[data-action]')?.dataset.action; + if (action === 'new-game') newGame(); + if (action === 'check-puzzle') checkSolution(); + if (action === 'hint') useHint(); +} + window.addEventListener('load', () => { - document.getElementById('new-game').addEventListener('click', newGame); - document.getElementById('check-solution').addEventListener('click', checkSolution); - // initialize + let savedTheme = 'light'; + try { + savedTheme = localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light'; + } catch (error) { + // Use the light theme when storage is unavailable. + } + applyTheme(savedTheme); + document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + document.getElementById('sudoku-board').addEventListener('input', handleBoardInput); + document.querySelector('.controls').addEventListener('click', handleControlClick); + document.getElementById('difficulty').addEventListener('change', newGame); + renderLeaderboard(); newGame(); }); \ No newline at end of file diff --git a/starter/static/styles.css b/starter/static/styles.css index 1a6218ff9..e0cd24561 100644 --- a/starter/static/styles.css +++ b/starter/static/styles.css @@ -1,73 +1,234 @@ +:root { + color-scheme: light; + --page-background: #f4f4f4; + --surface: #fff; + --text: #333; + --muted-text: #555; + --subtle-text: #666; + --border: #ddd; + --grid-border: #bbb; + --strong-border: #333; + --cell-background: #fafafa; + --box-shade-background: #eeeeee; + --box-plain-background: #ede7f6; + --cell-focus: #e0f7fa; + --prefilled-background: #e0e0e0; + --hinted-background: #d1c4e9; + --hinted-text: #4527a0; + --incorrect-background: #ffcdd2; + --conflict-background: #ffe0b2; + --conflict-border: #ef6c00; + --button-background: #1976d2; + --button-hover: #1565c0; + --error: #b42318; + --success: #388e3c; + --shadow: rgba(0, 0, 0, 0.1); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --page-background: #171a1f; + --surface: #242a32; + --text: #f1f5f9; + --muted-text: #d1d5db; + --subtle-text: #b8c1cc; + --border: #46505c; + --grid-border: #697482; + --strong-border: #e5e7eb; + --cell-background: #20262e; + --box-shade-background: #3f4852; + --box-plain-background: #4c3b68; + --cell-focus: #164e63; + --prefilled-background: #3b4652; + --hinted-background: #51406d; + --hinted-text: #e9d5ff; + --incorrect-background: #7f1d2d; + --conflict-background: #78350f; + --conflict-border: #fb923c; + --button-background: #2383e2; + --button-hover: #4095e8; + --error: #ff8f8f; + --success: #86efac; + --shadow: rgba(0, 0, 0, 0.35); +} + body { font-family: Arial, sans-serif; - background: #f4f4f4; + background: var(--page-background); + color: var(--text); text-align: center; margin: 0; - padding: 0; + padding: 0 12px 32px; + box-sizing: border-box; +} + +.page-header { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 16px 20px; + margin: 24px auto 20px; +} + +.page-header h1 { + margin: 0; +} + +#leaderboard { + width: min(760px, calc(100% - 32px)); + margin: 28px auto 0; + padding: 20px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + box-sizing: border-box; +} + +#leaderboard h2 { + margin: 0 0 14px; + color: var(--text); +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +th, +td { + padding: 9px 8px; + border-bottom: 1px solid var(--border); + text-align: left; + overflow-wrap: anywhere; +} + +th:first-child, +td:first-child { + width: 8%; +} + +th:nth-child(2), +td:nth-child(2) { + width: 32%; +} + +th:nth-child(3), +td:nth-child(3) { + width: 18%; +} + +th:nth-child(4), +td:nth-child(4) { + width: 28%; +} + +th:last-child, +td:last-child { + width: 14%; +} + +th { + color: var(--muted-text); + font-size: 13px; + text-transform: uppercase; +} + +#leaderboard-empty { + margin: 12px 0 0; + color: var(--subtle-text); } h1 { - margin-top: 30px; - color: #333; + color: var(--text); } #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); + width: min(396px, 100%); + margin: 24px auto; + border: 4px solid var(--strong-border); + background: var(--surface); + box-shadow: 0 2px 8px var(--shadow); + box-sizing: border-box; } .sudoku-row { - display: flex; + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); } .sudoku-cell { - width: 40px; - height: 40px; - border: 1px solid #bbb; + width: 100%; + min-width: 0; + aspect-ratio: 1; + box-sizing: border-box; + border: 1px solid var(--grid-border); text-align: center; font-size: 20px; outline: none; - background: #fafafa; + background: var(--cell-background); transition: background 0.2s; } +.sudoku-cell.box-shade { + background: var(--box-shade-background); +} + +.sudoku-cell.box-plain { + background: var(--box-plain-background); +} + .sudoku-cell:focus { - background: #e0f7fa; + background: var(--cell-focus); } .sudoku-cell.prefilled { - background: #e0e0e0; font-weight: bold; - color: #333; + color: var(--text); +} + +.sudoku-cell.hinted { + background: var(--hinted-background); + color: var(--hinted-text); + font-weight: bold; } .sudoku-cell.incorrect { - background: #ffcdd2; + background: var(--incorrect-background); +} + +.sudoku-cell.conflict { + background: var(--conflict-background); + border-color: var(--conflict-border); } .sudoku-cell:nth-child(3), .sudoku-cell:nth-child(6) { - border-right: 3px solid #333; + border-right: 3px solid var(--strong-border); } .sudoku-row:nth-child(3) .sudoku-cell, .sudoku-row:nth-child(6) .sudoku-cell { - border-bottom: 3px solid #333; + border-bottom: 3px solid var(--strong-border); } .controls { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px; + max-width: 760px; margin: 20px auto; } button { padding: 8px 18px; - margin: 0 8px; + margin: 0; font-size: 16px; border: none; - background: #1976d2; + background: var(--button-background); color: #fff; border-radius: 4px; cursor: pointer; @@ -75,11 +236,126 @@ button { } button:hover { - background: #1565c0; + background: var(--button-hover); +} + +button:focus-visible, +select:focus-visible, +.sudoku-cell:focus-visible { + outline: 3px solid #f59e0b; + outline-offset: 2px; +} + +.theme-toggle { + margin: 0; + min-width: 132px; } #message { - margin-left: 20px; + flex: 1 1 100%; + min-height: 1.25em; + font-size: 16px; + color: var(--error); +} + +#timer { + margin-left: 0; font-size: 16px; - color: #d32f2f; + color: var(--text); +} + +#hints-used { + margin-left: 0; + font-size: 16px; + color: var(--hinted-text); +} + +select { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + padding: 7px 28px 7px 8px; + border-radius: 4px; +} + +@media (max-width: 560px) { + .page-header { + gap: 10px 14px; + margin-top: 18px; + } + + .page-header h1 { + font-size: 24px; + } + + .theme-toggle { + min-width: 42px; + padding: 8px 10px; + } + + .theme-toggle-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + #leaderboard { + width: 100%; + padding: 12px 10px; + margin-top: 24px; + } + + th, + td { + padding: 8px 4px; + font-size: 13px; + } + + .controls { + gap: 10px 8px; + margin-top: 16px; + } + + .controls button { + flex: 1 1 calc(50% - 8px); + min-width: 120px; + padding-left: 10px; + padding-right: 10px; + } + + #timer, + #hints-used { + flex: 1 1 auto; + font-size: 14px; + } + + #message { + font-size: 14px; + } +} + +@media (max-width: 360px) { + body { + padding-left: 8px; + padding-right: 8px; + } + + .page-header h1 { + font-size: 22px; + } + + #leaderboard { + padding-left: 6px; + padding-right: 6px; + } + + th, + td { + font-size: 12px; + padding-left: 3px; + padding-right: 3px; + } } diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py index 443b24524..2a5c91161 100644 --- a/starter/sudoku_logic.py +++ b/starter/sudoku_logic.py @@ -1,57 +1,16 @@ -import copy -import random +from board import EMPTY, SIZE, create_empty_board, deep_copy +from generator import DIFFICULTY_CLUES, generate_puzzle, remove_cells +from solver import count_solutions, fill_board, is_safe -SIZE = 9 -EMPTY = 0 - -def deep_copy(board): - return copy.deepcopy(board) - -def create_empty_board(): - 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: - return False - # Check 3x3 box - start_row = row - row % 3 - start_col = col - col % 3 - for i in range(3): - for j in range(3): - if board[start_row + i][start_col + j] == num: - return False - return True - -def fill_board(board): - for row in range(SIZE): - for col in range(SIZE): - if board[row][col] == EMPTY: - possible = list(range(1, SIZE + 1)) - random.shuffle(possible) - for candidate in possible: - if is_safe(board, row, col, candidate): - board[row][col] = candidate - if fill_board(board): - return True - board[row][col] = EMPTY - return False - return True - -def remove_cells(board, clues): - 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): - board = create_empty_board() - fill_board(board) - solution = deep_copy(board) - remove_cells(board, clues) - puzzle = deep_copy(board) - return puzzle, solution +__all__ = [ + 'EMPTY', + 'SIZE', + 'create_empty_board', + 'deep_copy', + 'DIFFICULTY_CLUES', + 'count_solutions', + 'fill_board', + 'generate_puzzle', + 'is_safe', + 'remove_cells', +] diff --git a/starter/templates/index.html b/starter/templates/index.html index e42ad04da..80db20311 100644 --- a/starter/templates/index.html +++ b/starter/templates/index.html @@ -6,13 +6,44 @@
-| # | +Player | +Time | +Difficulty | +Hints | +
|---|
No scores yet.
+