diff --git a/.gitignore b/.gitignore index 2fcd240b2..e91d09279 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,14 @@ -# Ignore system files -.DS_Store -Thumbs.db +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ -# Ignore Python virtual environment +# Virtual environment .venv/ + +# VS Code +.vscode/ + +# OS files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md index 73753db50..257c5049a 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,131 @@ 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. + + +# Sudoku Game (Flask) + +A web-based Sudoku game built using Python Flask. The project was refactored into a modular architecture using GitHub Copilot while preserving the original functionality. + +## Features + +- Generate random Sudoku puzzles +- Three difficulty levels (Easy, Medium, Hard) +- Sudoku solver +- Hint system +- Check solution +- Game timer +- Top 10 leaderboard +- Dark mode +- Responsive UI +- Unique-solution puzzle generation +- Automated testing with Pytest + +## Project Structure + +``` +starter/ +│── app.py +│── board.py +│── validator.py +│── solver.py +│── generator.py +│── sudoku_logic.py +│── static/ +│── templates/ +│── tests/ +``` + +## Installation + +Clone the repository. + +```bash +git clone https://github.com/vuppalapati09/github-copilot-python.git +``` + +Move into the project. + +```bash +cd github-copilot-python/starter +``` + +Create a virtual environment. + +```bash +python -m venv .venv +``` + +Activate it. + +Windows PowerShell: + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +Install dependencies. + +```bash +pip install -r requirements.txt +``` + +## Run the Application + +```bash +python app.py +``` + +Open: + +``` +http://127.0.0.1:5000 +``` + +## Run Tests + +```bash +pytest -q +``` + +All tests should pass successfully. + +## Refactoring Summary + +The application was refactored into separate modules: + +- board.py +- validator.py +- solver.py +- generator.py + +The original functionality was preserved while improving readability and maintainability. + +## Screenshots + +### Final Application + +![Final UI](starter/screenshots/final_ui.png) + +### Copilot Refactoring + +![Copilot Refactor](starter/screenshots/copilot_refactor.png) + +### Testing Framework + +![Testing](starter/screenshots/copilot_testing_framework.png) + +## Technologies Used + +- Python +- Flask +- HTML +- CSS +- JavaScript +- Pytest +- Git +- GitHub Copilot + +## Author + +Vuppalapati Surya prakash \ No newline at end of file diff --git a/starter/.github/copilot-instructions.md b/starter/.github/copilot-instructions.md new file mode 100644 index 000000000..e69de29bb diff --git a/starter/__pycache__/board.cpython-314.pyc b/starter/__pycache__/board.cpython-314.pyc new file mode 100644 index 000000000..9b02ec442 Binary files /dev/null and b/starter/__pycache__/board.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..05224a168 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..fd5793876 100644 --- a/starter/app.py +++ b/starter/app.py @@ -9,13 +9,30 @@ 'solution': None } + +def get_clues_for_difficulty(difficulty): + difficulty_map = { + 'easy': 45, + 'medium': 35, + 'hard': 25, + } + if difficulty is None: + return difficulty_map['medium'] + normalized = difficulty.lower() + return difficulty_map.get(normalized, difficulty_map['medium']) + + @app.route('/') def index(): return render_template('index.html') @app.route('/new') def new_game(): - clues = int(request.args.get('clues', 35)) + clue_arg = request.args.get('clues') + if clue_arg is not None: + clues = int(clue_arg) + else: + clues = get_clues_for_difficulty(request.args.get('difficulty')) puzzle, solution = sudoku_logic.generate_puzzle(clues) CURRENT['puzzle'] = puzzle CURRENT['solution'] = solution @@ -33,7 +50,25 @@ def check_solution(): for j in range(sudoku_logic.SIZE): if board[i][j] != solution[i][j]: incorrect.append([i, j]) - return jsonify({'incorrect': incorrect}) + completed = len(incorrect) == 0 + return jsonify({'incorrect': incorrect, 'completed': completed}) + + +@app.route('/hint', methods=['POST']) +def provide_hint(): + data = request.json + board = data.get('board') + solution = CURRENT.get('solution') + if solution is None: + return jsonify({'error': 'No game in progress'}), 400 + + for i in range(sudoku_logic.SIZE): + for j in range(sudoku_logic.SIZE): + if board[i][j] == 0: + return jsonify({'row': i, 'col': j, 'value': solution[i][j]}) + + 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/board.py b/starter/board.py new file mode 100644 index 000000000..d5d2238cd --- /dev/null +++ b/starter/board.py @@ -0,0 +1,12 @@ +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/generator.py b/starter/generator.py new file mode 100644 index 000000000..901da3689 --- /dev/null +++ b/starter/generator.py @@ -0,0 +1,64 @@ +from board import EMPTY, SIZE, create_empty_board, deep_copy +from solver import fill_board +from validator import is_safe + + +def remove_cells(board, clues): + import random + + 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 count_solutions(board, limit=2): + board_copy = deep_copy(board) + solutions = 0 + + def search(state): + nonlocal solutions + + if solutions >= limit: + return + + next_empty = None + for row in range(SIZE): + for col in range(SIZE): + if state[row][col] == EMPTY: + next_empty = (row, col) + break + if next_empty is not None: + break + + if next_empty is None: + solutions += 1 + return + + row, col = next_empty + for candidate in range(1, SIZE + 1): + if not is_safe(state, row, col, candidate): + continue + state[row][col] = candidate + search(state) + if solutions >= limit: + state[row][col] = EMPTY + return + state[row][col] = EMPTY + + search(board_copy) + return solutions + + +def generate_puzzle(clues=35): + while True: + board = create_empty_board() + fill_board(board) + solution = deep_copy(board) + remove_cells(board, clues) + puzzle = deep_copy(board) + if count_solutions(puzzle, limit=2) == 1: + return puzzle, solution diff --git a/starter/instruction.md b/starter/instruction.md new file mode 100644 index 000000000..31f731861 --- /dev/null +++ b/starter/instruction.md @@ -0,0 +1,31 @@ +# GitHub Copilot Instructions + +## Project Goal + +Refactor the Flask Sudoku application into a clean, modular, and maintainable project while preserving existing functionality. + +## Python Guidelines + +- Follow PEP 8. +- Use descriptive variable and function names. +- Keep functions small and reusable. +- Avoid duplicate code. +- Add type hints where appropriate. + +## Flask Guidelines + +- Keep routes thin. +- Move business logic outside app.py. +- Separate UI from game logic. + +## Testing + +- Run pytest after every major change. +- Preserve all existing functionality. +- Do not introduce breaking changes. + +## Refactoring + +- Perform small incremental refactoring. +- Preserve backward compatibility. +- Keep public function names unchanged. \ No newline at end of file diff --git a/starter/pytest.ini b/starter/pytest.ini new file mode 100644 index 000000000..a635c5c03 --- /dev/null +++ b/starter/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . 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/screenshots/copilot_completion.png b/starter/screenshots/copilot_completion.png new file mode 100644 index 000000000..e30393ecd Binary files /dev/null and b/starter/screenshots/copilot_completion.png differ diff --git a/starter/screenshots/copilot_dark_mode.png b/starter/screenshots/copilot_dark_mode.png new file mode 100644 index 000000000..cc3271cc5 Binary files /dev/null and b/starter/screenshots/copilot_dark_mode.png differ diff --git a/starter/screenshots/copilot_difficulty.png b/starter/screenshots/copilot_difficulty.png new file mode 100644 index 000000000..970b49acc Binary files /dev/null and b/starter/screenshots/copilot_difficulty.png differ diff --git a/starter/screenshots/copilot_generator_refactor.png b/starter/screenshots/copilot_generator_refactor.png new file mode 100644 index 000000000..44b0fc363 Binary files /dev/null and b/starter/screenshots/copilot_generator_refactor.png differ diff --git a/starter/screenshots/copilot_grid_colors.png b/starter/screenshots/copilot_grid_colors.png new file mode 100644 index 000000000..a93e2889b Binary files /dev/null and b/starter/screenshots/copilot_grid_colors.png differ diff --git a/starter/screenshots/copilot_hint.png b/starter/screenshots/copilot_hint.png new file mode 100644 index 000000000..cdea53602 Binary files /dev/null and b/starter/screenshots/copilot_hint.png differ diff --git a/starter/screenshots/copilot_leaderboard.png b/starter/screenshots/copilot_leaderboard.png new file mode 100644 index 000000000..4793aeab4 Binary files /dev/null and b/starter/screenshots/copilot_leaderboard.png differ diff --git a/starter/screenshots/copilot_refactor.png b/starter/screenshots/copilot_refactor.png new file mode 100644 index 000000000..7a97273d6 Binary files /dev/null and b/starter/screenshots/copilot_refactor.png differ diff --git a/starter/screenshots/copilot_rejection.png b/starter/screenshots/copilot_rejection.png new file mode 100644 index 000000000..b45b51520 Binary files /dev/null and b/starter/screenshots/copilot_rejection.png differ diff --git a/starter/screenshots/copilot_rejection_before.png b/starter/screenshots/copilot_rejection_before.png new file mode 100644 index 000000000..87377573e Binary files /dev/null and b/starter/screenshots/copilot_rejection_before.png differ diff --git a/starter/screenshots/copilot_solver_refactor.png b/starter/screenshots/copilot_solver_refactor.png new file mode 100644 index 000000000..ad558d185 Binary files /dev/null and b/starter/screenshots/copilot_solver_refactor.png differ diff --git a/starter/screenshots/copilot_testing_framework.png b/starter/screenshots/copilot_testing_framework.png new file mode 100644 index 000000000..3918cb400 Binary files /dev/null and b/starter/screenshots/copilot_testing_framework.png differ diff --git a/starter/screenshots/copilot_timer.png b/starter/screenshots/copilot_timer.png new file mode 100644 index 000000000..09f0ac8b5 Binary files /dev/null and b/starter/screenshots/copilot_timer.png differ diff --git a/starter/screenshots/copilot_unique_solution.png b/starter/screenshots/copilot_unique_solution.png new file mode 100644 index 000000000..62f3d1eb2 Binary files /dev/null and b/starter/screenshots/copilot_unique_solution.png differ diff --git a/starter/screenshots/copilot_validator_refactor.png b/starter/screenshots/copilot_validator_refactor.png new file mode 100644 index 000000000..53a619070 Binary files /dev/null and b/starter/screenshots/copilot_validator_refactor.png differ diff --git a/starter/screenshots/final_ui.png b/starter/screenshots/final_ui.png new file mode 100644 index 000000000..112ef6d7e Binary files /dev/null and b/starter/screenshots/final_ui.png differ diff --git a/starter/screenshots/initial_tests.png b/starter/screenshots/initial_tests.png new file mode 100644 index 000000000..abd1a9cd1 Binary files /dev/null and b/starter/screenshots/initial_tests.png differ diff --git a/starter/solver.py b/starter/solver.py new file mode 100644 index 000000000..f9909eec9 --- /dev/null +++ b/starter/solver.py @@ -0,0 +1,20 @@ +import random + +from board import EMPTY, SIZE +from validator import is_safe + + +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 diff --git a/starter/static/main.js b/starter/static/main.js index 2028e1026..7e53fa3c5 100644 --- a/starter/static/main.js +++ b/starter/static/main.js @@ -1,6 +1,141 @@ // Client-side rendering and interaction for the Flask-backed Sudoku const SIZE = 9; +const LEADERBOARD_STORAGE_KEY = 'sudoku-leaderboard'; +const THEME_STORAGE_KEY = 'sudoku-theme'; +const MAX_LEADERBOARD_ENTRIES = 10; let puzzle = []; +let timerInterval = null; +let elapsedSeconds = 0; +let currentDifficulty = 'medium'; +let hintsUsed = 0; + +function formatTime(totalSeconds) { + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0'); + const seconds = String(totalSeconds % 60).padStart(2, '0'); + return `${minutes}:${seconds}`; +} + +function updateTimerDisplay() { + const timerEl = document.getElementById('timer'); + if (timerEl) { + timerEl.innerText = formatTime(elapsedSeconds); + } +} + +function startTimer() { + clearInterval(timerInterval); + elapsedSeconds = 0; + updateTimerDisplay(); + timerInterval = window.setInterval(() => { + elapsedSeconds += 1; + updateTimerDisplay(); + }, 1000); +} + +function stopTimer() { + clearInterval(timerInterval); + timerInterval = null; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function getLeaderboardEntries() { + try { + const storedValue = window.localStorage.getItem(LEADERBOARD_STORAGE_KEY); + if (!storedValue) { + return []; + } + return JSON.parse(storedValue); + } catch (error) { + return []; + } +} + +function saveLeaderboardEntries(entries) { + window.localStorage.setItem(LEADERBOARD_STORAGE_KEY, JSON.stringify(entries)); +} + +function renderLeaderboard() { +// Reviewed Copilot's refactoring suggestion. +// Rejected it after evaluation because the current +// implementation had already been tested and verified. +// Keeping the existing implementation avoids introducing +// unnecessary changes while preserving correct behavior. + const listEl = document.getElementById('leaderboard-list'); + if (!listEl) { + return; + } + + const entries = getLeaderboardEntries(); + const leaderboardMarkup = ` + + + Rank + Name + Time + Difficulty + Hints + + + + ${entries.length === 0 + ? 'No completed games yet.' + : entries.map((entry, index) => ` + + ${index + 1} + ${escapeHtml(entry.name)} + ${formatTime(entry.time)} + ${escapeHtml(entry.difficulty)} + ${entry.hintsUsed} + + `).join('')} + + `; + + listEl.innerHTML = leaderboardMarkup; +} + +function addCompletedGameToLeaderboard() { + const name = window.prompt('Enter your name for the leaderboard:', 'Player'); + if (name === null) { + return; + } + + const difficultySelect = document.getElementById('difficulty-select'); + const difficulty = difficultySelect ? difficultySelect.value : currentDifficulty; + const trimmedName = name.trim() || 'Anonymous'; + const entry = { + name: trimmedName, + time: elapsedSeconds, + difficulty, + hintsUsed, + completedAt: Date.now() + }; + + const entries = getLeaderboardEntries(); + entries.push(entry); + entries.sort((a, b) => a.time - b.time || b.completedAt - a.completedAt); + saveLeaderboardEntries(entries.slice(0, MAX_LEADERBOARD_ENTRIES)); + renderLeaderboard(); +} + +function getCellClassName(row, col, extraClass = '') { + const blockRow = Math.floor(row / 3); + const blockCol = Math.floor(col / 3); + const blockClass = (blockRow + blockCol) % 2 === 0 ? 'block-even' : 'block-odd'; + const classes = ['sudoku-cell', blockClass]; + if (extraClass) { + classes.push(extraClass); + } + return classes.join(' '); +} function createBoardElement() { const boardDiv = document.getElementById('sudoku-board'); @@ -12,12 +147,13 @@ function createBoardElement() { const input = document.createElement('input'); input.type = 'text'; input.maxLength = 1; - input.className = 'sudoku-cell'; + input.className = getCellClassName(i, j); 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; + updateValidationHighlights(); }); rowDiv.appendChild(input); } @@ -25,6 +161,107 @@ function createBoardElement() { } } +function getBoardValues() { + const boardDiv = document.getElementById('sudoku-board'); + if (!boardDiv) { + return []; + } + + 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; + } + } + return board; +} + +function getConflictingCellIndices(board) { + const conflicts = new Set(); + + const markConflicts = (positions) => { + if (positions.length > 1) { + positions.forEach((index) => conflicts.add(index)); + } + }; + + for (let row = 0; row < SIZE; row++) { + const valueToPositions = new Map(); + for (let col = 0; col < SIZE; col++) { + const value = board[row][col]; + if (!value) { + continue; + } + const positions = valueToPositions.get(value) || []; + positions.push(row * SIZE + col); + valueToPositions.set(value, positions); + } + valueToPositions.forEach(markConflicts); + } + + for (let col = 0; col < SIZE; col++) { + const valueToPositions = new Map(); + for (let row = 0; row < SIZE; row++) { + const value = board[row][col]; + if (!value) { + continue; + } + const positions = valueToPositions.get(value) || []; + positions.push(row * SIZE + col); + valueToPositions.set(value, positions); + } + valueToPositions.forEach(markConflicts); + } + + for (let boxRow = 0; boxRow < SIZE; boxRow += 3) { + for (let boxCol = 0; boxCol < SIZE; boxCol += 3) { + const valueToPositions = new Map(); + for (let row = boxRow; row < boxRow + 3; row++) { + for (let col = boxCol; col < boxCol + 3; col++) { + const value = board[row][col]; + if (!value) { + continue; + } + const positions = valueToPositions.get(value) || []; + positions.push(row * SIZE + col); + valueToPositions.set(value, positions); + } + } + valueToPositions.forEach(markConflicts); + } + } + + return conflicts; +} + +function updateValidationHighlights() { + const boardDiv = document.getElementById('sudoku-board'); + if (!boardDiv) { + return; + } + + const inputs = boardDiv.getElementsByTagName('input'); + const board = getBoardValues(); + const conflictingIndices = getConflictingCellIndices(board); + + for (let idx = 0; idx < inputs.length; idx++) { + const inp = inputs[idx]; + const row = parseInt(inp.dataset.row, 10); + const col = parseInt(inp.dataset.col, 10); + + if (inp.disabled) { + inp.className = getCellClassName(row, col, 'prefilled'); + continue; + } + + inp.className = getCellClassName(row, col, conflictingIndices.has(idx) ? 'incorrect' : ''); + } +} + function renderPuzzle(puz) { puzzle = puz; createBoardElement(); @@ -38,34 +275,33 @@ function renderPuzzle(puz) { if (val !== 0) { inp.value = val; inp.disabled = true; - inp.className += ' prefilled'; + inp.className = getCellClassName(i, j, 'prefilled'); } else { inp.value = ''; inp.disabled = false; + inp.className = getCellClassName(i, j); } } } + updateValidationHighlights(); } async function newGame() { - const res = await fetch('/new'); + const difficultySelect = document.getElementById('difficulty-select'); + const difficulty = difficultySelect ? difficultySelect.value : 'medium'; + currentDifficulty = difficulty; + hintsUsed = 0; + const res = await fetch(`/new?difficulty=${encodeURIComponent(difficulty)}`); const data = await res.json(); renderPuzzle(data.puzzle); document.getElementById('message').innerText = ''; + startTimer(); } 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; - } - } + const board = getBoardValues(); const res = await fetch('/check', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -82,24 +318,108 @@ async function checkSolution() { for (let idx = 0; idx < inputs.length; idx++) { const inp = inputs[idx]; if (inp.disabled) continue; - inp.className = 'sudoku-cell'; + const row = parseInt(inp.dataset.row, 10); + const col = parseInt(inp.dataset.col, 10); + inp.className = getCellClassName(row, col); if (incorrect.has(idx)) { - inp.className = 'sudoku-cell incorrect'; + inp.className = getCellClassName(row, col, 'incorrect'); } } - if (incorrect.size === 0) { + if (data.completed) { + stopTimer(); msg.style.color = '#388e3c'; msg.innerText = 'Congratulations! You solved it!'; + addCompletedGameToLeaderboard(); } else { msg.style.color = '#d32f2f'; msg.innerText = 'Some cells are incorrect.'; } } +async function applyHint() { + const boardDiv = document.getElementById('sudoku-board'); + const inputs = boardDiv.getElementsByTagName('input'); + const board = getBoardValues(); + + 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; + } + + const idx = data.row * SIZE + data.col; + const inp = inputs[idx]; + if (!inp || inp.disabled) { + msg.style.color = '#d32f2f'; + msg.innerText = 'No additional hint available.'; + return; + } + + inp.value = data.value; + inp.disabled = true; + const row = parseInt(inp.dataset.row, 10); + const col = parseInt(inp.dataset.col, 10); + inp.className = getCellClassName(row, col, 'prefilled'); + hintsUsed += 1; + updateValidationHighlights(); + msg.style.color = '#388e3c'; + msg.innerText = `Hint used (${hintsUsed}).`; +} + +// THEME HANDLING +function applyTheme(theme) { + const isDark = theme === 'dark'; + try { + if (isDark) { + document.body.classList.add('dark'); + } else { + document.body.classList.remove('dark'); + } + const toggle = document.getElementById('theme-toggle'); + if (toggle) toggle.checked = isDark; + window.localStorage.setItem(THEME_STORAGE_KEY, isDark ? 'dark' : 'light'); + } catch (err) { + // ignore + } +} + +function initTheme() { + try { + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + if (stored === 'dark' || stored === 'light') { + applyTheme(stored); + return; + } + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + applyTheme(prefersDark ? 'dark' : 'light'); + } catch (err) { + // ignore + } +} + +function setupThemeToggle() { + const toggle = document.getElementById('theme-toggle'); + if (!toggle) return; + toggle.addEventListener('change', (e) => { + applyTheme(e.target.checked ? 'dark' : 'light'); + }); +} + // Wire buttons window.addEventListener('load', () => { + // Initialize theme before rendering to avoid flash + initTheme(); + setupThemeToggle(); document.getElementById('new-game').addEventListener('click', newGame); document.getElementById('check-solution').addEventListener('click', checkSolution); - // initialize + document.getElementById('hint-solution').addEventListener('click', applyHint); + renderLeaderboard(); newGame(); }); \ No newline at end of file diff --git a/starter/static/styles.css b/starter/static/styles.css index 1a6218ff9..533ac1168 100644 --- a/starter/static/styles.css +++ b/starter/static/styles.css @@ -1,85 +1,325 @@ +:root { + --bg: #f4f4f4; + --text: #333; + --card-bg: #fff; + --primary: #1976d2; + --primary-hover: #1565c0; + --muted: #555; + --danger: #d32f2f; + --success: #388e3c; + --board-border: #333; + --cell-border: #bbb; + --cell-bg: #fafafa; + --cell-alt-bg: #f1f5f9; + --cell-focus: #e0f7fa; + --prefilled-bg: #e0e0e0; + --prefilled-text: #333; + --incorrect-bg: #ffcdd2; + --card-shadow: rgba(0, 0, 0, 0.12); + --leaderboard-shadow: rgba(0, 0, 0, 0.08); + --surface: rgba(255, 255, 255, 0.9); + --surface-border: #d9d9d9; + --control-bg: rgba(255, 255, 255, 0.8); + --control-border: #cfd8dc; + --focus-ring: rgba(25, 118, 210, 0.3); + --message-bg: rgba(211, 47, 47, 0.1); + --message-border: rgba(211, 47, 47, 0.2); +} + +.dark { + --bg: #121212; + --text: #eaeaea; + --card-bg: #1e1e1e; + --primary: #90caf9; + --primary-hover: #64b5f6; + --muted: #cfcfcf; + --danger: #ef9a9a; + --success: #81c784; + --board-border: #e0e0e0; + --cell-border: #444; + --cell-bg: #2a2a2a; + --cell-alt-bg: #2f2f2f; + --cell-focus: #37474f; + --prefilled-bg: #424242; + --prefilled-text: #fff; + --incorrect-bg: #ff8a80; + --card-shadow: rgba(0, 0, 0, 0.4); + --leaderboard-shadow: rgba(0, 0, 0, 0.35); + --surface: rgba(30, 30, 30, 0.95); + --surface-border: #3f3f3f; + --control-bg: rgba(33, 33, 33, 0.8); + --control-border: #4f4f4f; + --focus-ring: rgba(144, 202, 249, 0.35); + --message-bg: rgba(239, 154, 154, 0.12); + --message-border: rgba(239, 154, 154, 0.2); +} + +* { + box-sizing: border-box; +} + body { font-family: Arial, sans-serif; - background: #f4f4f4; - text-align: center; + background: var(--bg); + color: var(--text); margin: 0; + min-height: 100vh; padding: 0; } +.app-shell { + width: min(100%, 860px); + margin: 0 auto; + padding: 24px 16px 40px; + text-align: center; +} + h1 { - margin-top: 30px; - color: #333; + margin: 0 0 12px; + color: var(--text); + font-size: clamp(1.8rem, 3vw, 2.5rem); +} + +.controls { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 10px; + margin: 12px auto 0; + padding: 10px 12px; + border: 1px solid var(--control-border); + border-radius: 999px; + background: var(--control-bg); + box-shadow: 0 2px 8px var(--card-shadow); + max-width: 680px; +} + +.controls label, +.controls select, +.controls button, +.theme-switch { + font-size: 0.95rem; +} + +select { + border: 1px solid var(--control-border); + border-radius: 999px; + background: var(--card-bg); + color: var(--text); + padding: 8px 12px; +} + +button { + padding: 9px 16px; + border: none; + background: var(--primary); + color: #fff; + border-radius: 999px; + cursor: pointer; + transition: background 0.2s, transform 0.2s, box-shadow 0.2s; + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14); +} + +button:hover, +button:focus-visible { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); +} + +button:focus-visible, +select:focus-visible, +.sudoku-cell:focus-visible, +#theme-toggle:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.theme-switch { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text); +} + +#theme-toggle { + accent-color: var(--primary); +} + +#timer { + font-size: clamp(1.1rem, 2.2vw, 1.45rem); + font-weight: 700; + color: var(--text); + margin-top: 12px; +} + +.board-shell { + display: flex; + justify-content: center; + padding: 10px 0 6px; } #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); + display: grid; + grid-template-rows: repeat(9, minmax(0, 1fr)); + width: min(92vw, 560px); + aspect-ratio: 1 / 1; + margin: 12px auto 0; + border: 4px solid var(--board-border); + border-radius: 14px; + background: var(--card-bg); + box-shadow: 0 10px 24px var(--card-shadow); + overflow: hidden; } .sudoku-row { - display: flex; + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); + width: 100%; } .sudoku-cell { - width: 40px; - height: 40px; - border: 1px solid #bbb; + width: 100%; + aspect-ratio: 1 / 1; + border: 1px solid var(--cell-border); text-align: center; - font-size: 20px; + font-size: clamp(1rem, 2.5vw, 1.35rem); + font-weight: 600; outline: none; - background: #fafafa; - transition: background 0.2s; + background: var(--cell-bg); + color: var(--text); + padding: 0; + transition: background 0.2s, color 0.2s, transform 0.2s; +} + +.sudoku-cell.block-even { + background: var(--cell-alt-bg); +} + +.sudoku-cell.block-odd { + background: var(--cell-bg); } .sudoku-cell:focus { - background: #e0f7fa; + background: var(--cell-focus); } .sudoku-cell.prefilled { - background: #e0e0e0; - font-weight: bold; - color: #333; + background: var(--prefilled-bg); + font-weight: 700; + color: var(--prefilled-text); } .sudoku-cell.incorrect { - background: #ffcdd2; + background: var(--incorrect-bg); + color: var(--danger); } -.sudoku-cell:nth-child(3), -.sudoku-cell:nth-child(6) { - border-right: 3px solid #333; +.sudoku-row .sudoku-cell:nth-child(3n):not(:nth-child(9n)) { + border-right: 3px solid var(--board-border); } -.sudoku-row:nth-child(3) .sudoku-cell, -.sudoku-row:nth-child(6) .sudoku-cell { - border-bottom: 3px solid #333; +.sudoku-row:nth-child(3n):not(:nth-child(9n)) .sudoku-cell { + border-bottom: 3px solid var(--board-border); } -.controls { - margin: 20px auto; +#message { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--message-border); + background: var(--message-bg); + color: var(--danger); + font-size: 0.95rem; + font-weight: 600; } -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; +.leaderboard { + max-width: 560px; + margin: 20px auto 0; + background: var(--surface); + border: 1px solid var(--surface-border); + border-radius: 12px; + box-shadow: 0 6px 16px var(--leaderboard-shadow); + padding: 16px 20px; } -button:hover { - background: #1565c0; +.leaderboard h2 { + margin: 0 0 12px; + font-size: 1.1rem; + color: var(--text); } -#message { - margin-left: 20px; - font-size: 16px; - color: #d32f2f; +#leaderboard-list { + width: 100%; + border-collapse: collapse; + margin: 0; +} + +#leaderboard-list th, +#leaderboard-list td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid var(--surface-border); +} + +#leaderboard-list th { + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +#leaderboard-list tr:last-child td { + border-bottom: none; +} + +#leaderboard-list td:first-child { + font-weight: 700; + color: var(--primary); +} + +.leaderboard-empty-row td { + color: var(--muted); + font-style: italic; + text-align: center; +} + +@media (max-width: 640px) { + .app-shell { + padding: 16px 12px 28px; + } + + .controls { + border-radius: 18px; + padding: 12px; + } + + .controls > * { + width: 100%; + justify-content: center; + } + + .theme-switch { + justify-content: center; + } + + #sudoku-board { + width: min(100%, calc(100vw - 24px)); + border-width: 3px; + } + + .leaderboard { + padding: 14px; + } + + #leaderboard-list th, + #leaderboard-list td { + padding: 8px 6px; + } } diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py index 443b24524..8cdc6c997 100644 --- a/starter/sudoku_logic.py +++ b/starter/sudoku_logic.py @@ -1,57 +1,15 @@ -import copy -import random - -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 +from board import EMPTY, SIZE, create_empty_board, deep_copy +from generator import generate_puzzle, remove_cells +from solver import fill_board +from validator import is_safe + +__all__ = [ + "EMPTY", + "SIZE", + "create_empty_board", + "deep_copy", + "fill_board", + "generate_puzzle", + "is_safe", + "remove_cells", +] diff --git a/starter/templates/index.html b/starter/templates/index.html index e42ad04da..b226b2e23 100644 --- a/starter/templates/index.html +++ b/starter/templates/index.html @@ -2,17 +2,39 @@ + Sudoku Game -

Sudoku Game

-
-
- - - -
+
+

Sudoku Game

+
+ + + +
+
00:00
+
+
+
+
+ + + + +
+
+

Top 10 Leaderboard

+
+
+
\ No newline at end of file diff --git a/starter/tests/__init__.py b/starter/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/starter/tests/test_sudoku_logic.py b/starter/tests/test_sudoku_logic.py new file mode 100644 index 000000000..f072c7a28 --- /dev/null +++ b/starter/tests/test_sudoku_logic.py @@ -0,0 +1,146 @@ +import pytest + +from app import CURRENT, app as flask_app +from generator import count_solutions +from sudoku_logic import ( + EMPTY, + SIZE, + create_empty_board, + deep_copy, + fill_board, + generate_puzzle, + is_safe, +) + + +def is_valid_sudoku_board(board): + for row in board: + if sorted(row) != list(range(1, SIZE + 1)): + return False + + for col in range(SIZE): + column = [board[row][col] for row in range(SIZE)] + if sorted(column) != list(range(1, SIZE + 1)): + return False + + for box_row in range(0, SIZE, 3): + for box_col in range(0, SIZE, 3): + values = [] + for row in range(box_row, box_row + 3): + for col in range(box_col, box_col + 3): + values.append(board[row][col]) + if sorted(values) != list(range(1, SIZE + 1)): + return False + + return True + + +def test_create_empty_board_has_expected_shape(): + board = create_empty_board() + + assert len(board) == SIZE + assert all(len(row) == SIZE for row in board) + assert all(cell == EMPTY for row in board for cell in row) + + +def test_deep_copy_returns_independent_copy(): + board = [[1, 2, 3], [4, 5, 6]] + + copied_board = deep_copy(board) + copied_board[0][0] = 99 + + assert board[0][0] == 1 + assert copied_board[0][0] == 99 + + +def test_is_safe_rejects_conflicts_in_row_column_and_box(): + board = create_empty_board() + board[0][0] = 5 + + assert is_safe(board, 0, 1, 5) is False + assert is_safe(board, 1, 0, 5) is False + assert is_safe(board, 1, 1, 5) is False + assert is_safe(board, 0, 1, 4) is True + + +def test_fill_board_returns_a_complete_valid_solution(): + board = create_empty_board() + + assert fill_board(board) is True + assert is_valid_sudoku_board(board) + + +def test_count_solutions_detects_multiple_solutions_for_an_empty_board(): + board = create_empty_board() + + assert count_solutions(board, limit=2) == 2 + + +def test_generate_puzzle_returns_a_puzzle_and_solution(): + puzzle, solution = generate_puzzle(clues=35) + + assert len(puzzle) == SIZE + assert len(solution) == SIZE + assert all(len(row) == SIZE for row in puzzle) + assert all(len(row) == SIZE for row in solution) + assert is_valid_sudoku_board(solution) + assert count_solutions(puzzle, limit=2) == 1 + + for row in range(SIZE): + for col in range(SIZE): + if puzzle[row][col] == EMPTY: + continue + assert puzzle[row][col] == solution[row][col] + + +def test_new_game_route_uses_difficulty_to_select_clues(): + client = flask_app.test_client() + response = client.get('/new?difficulty=hard') + + assert response.status_code == 200 + puzzle = response.get_json()['puzzle'] + clues = sum(1 for row in puzzle for cell in row if cell != EMPTY) + + assert clues == 25 + + +def test_index_page_renders_timer_container(): + client = flask_app.test_client() + response = client.get('/') + + assert response.status_code == 200 + html = response.get_data(as_text=True) + + assert 'id="timer"' in html + + +def test_hint_route_returns_one_correct_cell_for_an_empty_spot(): + client = flask_app.test_client() + response = client.get('/new?difficulty=easy') + + assert response.status_code == 200 + puzzle = response.get_json()['puzzle'] + + hint_response = client.post('/hint', json={'board': puzzle}) + + assert hint_response.status_code == 200 + payload = hint_response.get_json() + assert payload['row'] in range(SIZE) + assert payload['col'] in range(SIZE) + assert puzzle[payload['row']][payload['col']] == EMPTY + assert payload['value'] != EMPTY + + +def test_check_route_reports_completion_when_board_matches_solution(): + client = flask_app.test_client() + response = client.get('/new?difficulty=easy') + + assert response.status_code == 200 + solution = CURRENT['solution'] + + check_response = client.post('/check', json={'board': deep_copy(solution)}) + + assert check_response.status_code == 200 + payload = check_response.get_json() + assert payload['completed'] is True + assert payload['incorrect'] == [] diff --git a/starter/validator.py b/starter/validator.py new file mode 100644 index 000000000..d30cfc2ad --- /dev/null +++ b/starter/validator.py @@ -0,0 +1,16 @@ +from board import 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