diff --git a/README.md b/README.md index 73753db5..eff6a9b7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,15 @@ python app.py 7. Open http://127.0.0.1:5000 in your browser. + + +## Running Tests +To run the test suite, ensure your virtual environment is activated and run: +```bash +pytest + + + ## Project Instructions Use GitHub Copilot to refactor the code for this game to add more advanced features. The goal is to create a more modern and maintainable codebase and add additional functionality to the final product. You can use any combination of code completion and chat features, like Ask, Edit, or Agent modes. diff --git a/Screenshots/grid_styling_prompt.png b/Screenshots/grid_styling_prompt.png new file mode 100644 index 00000000..00367df3 Binary files /dev/null and b/Screenshots/grid_styling_prompt.png differ diff --git a/Screenshots/hint button.png b/Screenshots/hint button.png new file mode 100644 index 00000000..9b6bea1a Binary files /dev/null and b/Screenshots/hint button.png differ diff --git a/Screenshots/initial_tests.png b/Screenshots/initial_tests.png new file mode 100644 index 00000000..ba73b922 Binary files /dev/null and b/Screenshots/initial_tests.png differ diff --git a/Screenshots/otherresponseafterrejection.png b/Screenshots/otherresponseafterrejection.png new file mode 100644 index 00000000..61dcf8f3 Binary files /dev/null and b/Screenshots/otherresponseafterrejection.png differ diff --git a/Screenshots/rejected prompt.png b/Screenshots/rejected prompt.png new file mode 100644 index 00000000..85999186 Binary files /dev/null and b/Screenshots/rejected prompt.png differ diff --git a/Screenshots/scoreboard_prompt.png b/Screenshots/scoreboard_prompt.png new file mode 100644 index 00000000..60f7ac50 Binary files /dev/null and b/Screenshots/scoreboard_prompt.png differ diff --git a/Screenshots/timer.png b/Screenshots/timer.png new file mode 100644 index 00000000..bd3e2cb9 Binary files /dev/null and b/Screenshots/timer.png differ diff --git a/Screenshots/toggle.png b/Screenshots/toggle.png new file mode 100644 index 00000000..f351513b Binary files /dev/null and b/Screenshots/toggle.png differ diff --git a/Screenshots/unique_solution_prompt.png b/Screenshots/unique_solution_prompt.png new file mode 100644 index 00000000..a741901d Binary files /dev/null and b/Screenshots/unique_solution_prompt.png differ diff --git a/instruction.md b/instruction.md new file mode 100644 index 00000000..c7014fa8 --- /dev/null +++ b/instruction.md @@ -0,0 +1,167 @@ +# GitHub Copilot Instructions for Sudoku Application + +## Project Overview +This repository contains a modern 9×9 Sudoku web application built with Flask for the Python backend and vanilla JavaScript/CSS for the frontend. The application should feel accessible, responsive, and easy to extend while keeping the core architecture clean and modular. + +The application should: +- generate Sudoku puzzles on the backend in `starter/sudoku_logic.py` +- serve the game UI from `starter/templates/index.html` +- manage game state and validation through `starter/static/main.js` +- style the board and interaction states in `starter/static/styles.css` +- keep the backend lightweight with Flask and minimal dependencies + +## Code Standards and Architecture + +### Refactor Legacy Code to Modern Standards +- Use a modular architecture: + - keep backend routes and API handling in `starter/app.py` + - keep Sudoku generation and solving logic in `starter/sudoku_logic.py` + - keep rendering and browser interactions in `starter/static/main.js` + - keep styling in `starter/static/styles.css` +- Prefer small, single-purpose functions and avoid large monolithic blocks. +- Keep Python functions testable and avoid side effects where possible. +- In JavaScript, organize logic into reusable helper functions. +- Use comments to explain: + - non-trivial business logic + - puzzle generation and solving strategies + - API input/output expectations +- **Error Handling**: Implement consistent error handling patterns + - Use try/catch blocks for async operations + - Validate user inputs at boundaries + - Provide meaningful error messages to users + - Log errors appropriately for debugging + +### Error Handling +- Validate all user input at boundary points. +- Use `try/catch` for async operations in JavaScript. +- Return clean JSON error responses from Flask, and use HTTP 400 for bad client requests. +- Show user-facing messages for invalid board submission, network errors, and unexpected backend failures. +- Do not crash the app on malformed input. + +### Build & Run Requirements +- The application must install cleanly with `pip install -r requirements.txt`. +- It must run via `python app.py` or `flask run` without startup errors. +- Browser developer tools should show no console errors for normal usage. + +## User Interface Requirements + +### Responsive and Accessible Design +- Use plain CSS only; do not add framework dependencies. +- Ensure the Sudoku board is responsive and mobile-friendly. +- Keep the grid centered and proportional on all screen sizes. +- Use `em` or responsive units for scalable typography. +- Use minimum touch target sizes of about 44×44px for buttons on mobile. +- Avoid layout shifts while the board loads or updates. + +### 3×3 Grid Styling +- Visually distinguish 3×3 sub-grids with alternating background shades or stronger borders. +- Keep the grid easy to scan and readable. +- Prefilled cells should look different from editable cells. + +### Dark Mode Support +- Implement a light/dark theme toggle. +- Persist the user theme preference in `localStorage`. +- Ensure text and interactive controls maintain at least WCAG AA contrast. + +### Keyboard & Accessibility +- Use semantic HTML and accessible ARIA roles as needed. +- All interactive UI elements must be keyboard accessible. +- Implement arrow-key navigation between cells and Enter to confirm input when appropriate. +- Provide visible focus indicators. +- For screen readers, label cells with row/column context and state, such as "Row 1, Column 2, empty" or "Row 1, Column 2, prefilled 5". +- Provide announcements or status text for errors, hints, and completion. +- Do not rely on color alone; use text or icons for invalid state and completion feedback. + +## Core Sudoku Logic + +### Puzzle Generation +- Generate puzzles with exactly one valid solution. +- Create a fully solved 9×9 board first, then remove cells while preserving uniqueness. +- Use backtracking and/or constraint propagation for both generation and uniqueness checking. +- Keep generation randomized so repeated plays are not the same. +- Control difficulty by the number of filled cells: + - Easy: 40-45 prefilled cells + - Medium: 30-35 prefilled cells + - Hard: 25-28 prefilled cells +- Prefilled cells must be immutable in the UI. + +### Unique Solution Checking +- When removing a cell, verify that the board still has only one solution. +- Implement a solver that counts up to 2 possible completions and stops early once multiple solutions are found. +- Reject removals that cause a second valid solution. +- Maintain the solution only on the server side. + +### Validation and Feedback +- Validate the board in real time as the user enters numbers: + - row constraint + - column constraint + - 3×3 sub-grid constraint +- Highlight conflicting cells with a clear visual style. +- If a user submits the board, return detailed validation results rather than just success/failure. +- Detect completion when all cells are filled and valid. +- Show a success modal or message with completion statistics. + +## Interactive Features + +### Core Game Interactions +- Implement a working Hint feature: + - reveal one correct number in an empty cell + - mark the hinted cell as locked/prefilled + - count hints separately for scoring +- Implement a Check button that validates the current board against the solution and reports incorrect cells. +- Provide user-friendly feedback for each action. +- Ensure the board remains playable while validation is happening. + +### Timer +- Start timing when a new puzzle loads or when the first cell is edited. +- Display elapsed time in MM:SS. +- Stop the timer on puzzle completion. +- Optionally pause the timer when the user navigates away or switches tabs. + +## Advanced Features + +### Number Tracking Visualization +- Display the usage count of each digit (1-9) on the board. +- Show which numbers are complete and how many remain. +- Allow users to tap/click a number to highlight all board instances. +- Use text/icons so the feature remains accessible. + +### Note Mode +- Provide a toggle or shortcut to enter note mode. +- In note mode, typed digits should add pencil marks to the selected cell. +- Allow multiple candidate notes in one cell. +- Clear notes when the user enters a final number. +- Display notes in smaller or superscript text. +- Indicate note mode visibly in the UI. + +## Testing and Quality +- Add unit tests for Sudoku generation and validation in `tests/`. +- Test that generated boards are valid and puzzles follow Sudoku rules. +- Test that uniqueness checking rejects ambiguous boards. +- Add tests for Flask route behavior and JSON API responses. +- Keep tests deterministic and easy to run. +- Use descriptive test case names and document expected behavior. + +## Error Handling Practices +- Validate all inputs before use. +- Ensure the backend returns clear JSON errors for invalid requests. +- Handle network failures gracefully on the frontend. +- Display friendly error messages in the UI. +- Avoid showing raw exception details to the user. +- Use consistent message styling for success, warning, and error states. + +## Future Features and Improvements +- Add difficulty selection to the UI and persist the selected difficulty. +- Add a local leaderboard stored in `localStorage`. +- Add a theme toggle and save preference persistently. +- Add puzzle stats like best time, shortest completion, and hint usage. +- Add an undo/redo feature for cell entry. +- Add keyboard shortcuts for note mode, check, hint, and new game. + +## Copilot Suggestion Guidance +- Keep changes aligned with the existing Flask + vanilla JS architecture. +- Avoid introducing heavy frontend frameworks or unnecessary dependencies. +- Prefer simple, maintainable solutions. +- Suggest backend improvements in `starter/app.py` and `starter/sudoku_logic.py`. +- Suggest frontend improvements in `starter/static/main.js`, `starter/static/styles.css`, and `starter/templates/index.html`. +- Focus on accessibility, responsive behavior, and game correctness. diff --git a/starter/__pycache__/app.cpython-312.pyc b/starter/__pycache__/app.cpython-312.pyc new file mode 100644 index 00000000..2a136906 Binary files /dev/null and b/starter/__pycache__/app.cpython-312.pyc differ diff --git a/starter/__pycache__/sudoku_logic.cpython-312.pyc b/starter/__pycache__/sudoku_logic.cpython-312.pyc new file mode 100644 index 00000000..a9d87591 Binary files /dev/null and b/starter/__pycache__/sudoku_logic.cpython-312.pyc differ diff --git a/starter/app.py b/starter/app.py index 0f526b75..20a9df71 100644 --- a/starter/app.py +++ b/starter/app.py @@ -9,31 +9,86 @@ 'solution': None } + +def json_error(message, status=400): + response = jsonify({'error': message}) + response.status_code = status + return response + + +def validate_board(board): + if not isinstance(board, list) or len(board) != sudoku_logic.SIZE: + return False + + for row in board: + if not isinstance(row, list) or len(row) != sudoku_logic.SIZE: + return False + for value in row: + if not isinstance(value, int) or value < 0 or value > sudoku_logic.SIZE: + return False + return True + + @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}) + try: + clues = request.args.get('clues') + difficulty = request.args.get('difficulty', 'easy') + + if clues is not None: + try: + clues = int(clues) + if clues < 0 or clues > sudoku_logic.SIZE * sudoku_logic.SIZE: + raise ValueError + except ValueError: + return json_error('Invalid clues parameter. Must be an integer between 0 and 81.', 400) + + if difficulty not in sudoku_logic.DIFFICULTY_SETTINGS: + return json_error('Invalid difficulty. Must be easy, medium, or hard.', 400) + + puzzle, solution = sudoku_logic.generate_puzzle(clues=clues, difficulty=difficulty) + CURRENT['puzzle'] = puzzle + CURRENT['solution'] = solution + return jsonify({'puzzle': puzzle, 'solution': solution, 'difficulty': difficulty}) + except Exception: + app.logger.exception('Unexpected error while generating a new game') + return json_error('Unable to generate a new game.', 500) + @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}) + try: + if not request.is_json: + return json_error('Request must be JSON.', 400) + + data = request.get_json(silent=True) + if data is None: + return json_error('Malformed JSON request body.', 400) + + board = data.get('board') + if board is None: + return json_error('Missing board data in request.', 400) + + if not validate_board(board): + return json_error('Board must be a 9x9 grid of integers between 0 and 9.', 400) + + solution = CURRENT.get('solution') + if solution is None: + return json_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}) + except Exception: + app.logger.exception('Unexpected error while checking the board') + return json_error('Unable to validate the board.', 500) if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/starter/static/main.js b/starter/static/main.js index 2028e102..b40aa535 100644 --- a/starter/static/main.js +++ b/starter/static/main.js @@ -1,6 +1,62 @@ // Client-side rendering and interaction for the Flask-backed Sudoku const SIZE = 9; +const SCOREBOARD_KEY = 'sudoku-scoreboard'; +const THEME_KEY = 'sudoku-theme'; let puzzle = []; +let currentDifficulty = 'easy'; +let timerInterval = null; +let elapsedSeconds = 0; +let gameCompleted = false; +let solution = []; +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 showMessage(text, type = 'info') { + const msg = document.getElementById('message'); + if (!msg) { + return; + } + + msg.textContent = text; + if (type === 'error') { + msg.style.color = '#d32f2f'; + } else if (type === 'success') { + msg.style.color = '#388e3c'; + } else { + msg.style.color = '#333'; + } +} + +function updateTimerDisplay() { + const timerElement = document.getElementById('timer'); + if (timerElement) { + timerElement.textContent = `Time: ${formatTime(elapsedSeconds)}`; + } +} + +function stopTimer() { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } +} + +function startTimer() { + stopTimer(); + elapsedSeconds = 0; + hintsUsed = 0; + updateTimerDisplay(); + gameCompleted = false; + timerInterval = setInterval(() => { + elapsedSeconds += 1; + updateTimerDisplay(); + }, 1000); +} function createBoardElement() { const boardDiv = document.getElementById('sudoku-board'); @@ -18,6 +74,7 @@ function createBoardElement() { input.addEventListener('input', (e) => { const val = e.target.value.replace(/[^1-9]/g, ''); e.target.value = val; + updateLiveCellHighlighting(); }); rowDiv.appendChild(input); } @@ -25,8 +82,78 @@ function createBoardElement() { } } -function renderPuzzle(puz) { +function getPlayerName() { + const playerNameInput = document.getElementById('player-name'); + const name = playerNameInput ? playerNameInput.value.trim() : ''; + return name || 'Anonymous'; +} + +function loadScores() { + try { + const raw = window.localStorage.getItem(SCOREBOARD_KEY); + return raw ? JSON.parse(raw) : []; + } catch (error) { + return []; + } +} + +function saveScore(timeSeconds, difficulty) { + const entry = { + name: getPlayerName(), + timeSeconds, + difficulty, + hintsUsed, + completedAt: new Date().toISOString() + }; + + const scores = loadScores() + .concat(entry) + .sort((a, b) => a.timeSeconds - b.timeSeconds) + .slice(0, 10); + + window.localStorage.setItem(SCOREBOARD_KEY, JSON.stringify(scores)); + renderScoreboard(scores); +} + +function renderScoreboard(scores = loadScores()) { + const scoreboardList = document.getElementById('scoreboard-list'); + if (!scoreboardList) { + return; + } + + scoreboardList.innerHTML = ''; + if (!scores.length) { + const emptyItem = document.createElement('li'); + emptyItem.textContent = 'No completed games yet.'; + scoreboardList.appendChild(emptyItem); + return; + } + + scores.forEach((score, index) => { + const item = document.createElement('li'); + const hints = score.hintsUsed != null ? score.hintsUsed : 0; + item.textContent = `${index + 1}. ${score.name} - ${formatTime(score.timeSeconds)} (${score.difficulty}) - Hints: ${hints}`; + scoreboardList.appendChild(item); + }); +} + +function applyTheme(theme) { + document.body.setAttribute('data-theme', theme); + const toggleButton = document.getElementById('theme-toggle'); + if (toggleButton) { + toggleButton.textContent = theme === 'dark' ? 'Toggle Light Mode' : 'Toggle Dark Mode'; + } + window.localStorage.setItem(THEME_KEY, theme); +} + +function initializeTheme() { + const savedTheme = window.localStorage.getItem(THEME_KEY) || 'light'; + applyTheme(savedTheme); +} + +function renderPuzzle(puz, solvedBoard = null) { puzzle = puz; + solution = solvedBoard || []; createBoardElement(); const boardDiv = document.getElementById('sudoku-board'); const inputs = boardDiv.getElementsByTagName('input'); @@ -48,13 +175,28 @@ function renderPuzzle(puz) { } async function newGame() { - const res = await fetch('/new'); - const data = await res.json(); - renderPuzzle(data.puzzle); - document.getElementById('message').innerText = ''; + startTimer(); + try { + const res = await fetch(`/new?difficulty=${encodeURIComponent(currentDifficulty)}`); + if (!res.ok) { + const data = await res.json(); + showMessage(data.error || 'Unable to start a new game.', 'error'); + return; + } + const data = await res.json(); + if (data.difficulty) { + currentDifficulty = data.difficulty; + document.getElementById('difficulty').value = currentDifficulty; + } + renderPuzzle(data.puzzle, data.solution); + showMessage('New game started. Good luck!', 'info'); + } catch (error) { + console.error('New game failed:', error); + showMessage('Unable to start a new game. Check your connection.', 'error'); + } } -async function checkSolution() { +function getBoardFromInputs() { const boardDiv = document.getElementById('sudoku-board'); const inputs = boardDiv.getElementsByTagName('input'); const board = []; @@ -66,40 +208,171 @@ async function checkSolution() { 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 board; +} + +function highlightIncorrectCells(incorrectIndexes) { + const boardDiv = document.getElementById('sudoku-board'); + const inputs = boardDiv.getElementsByTagName('input'); + for (let idx = 0; idx < inputs.length; idx++) { + const inp = inputs[idx]; + inp.className = 'sudoku-cell'; + + if (inp.disabled) { + inp.classList.add('prefilled'); + continue; + } + + const row = Number(inp.dataset.row); + const col = Number(inp.dataset.col); + const cellValue = inp.value; + const expectedValue = solution?.[row]?.[col]; + + const isEmpty = cellValue === ''; + const isWrong = cellValue !== '' && expectedValue !== undefined && cellValue !== String(expectedValue); + + if (incorrectIndexes.has(idx) || isEmpty || isWrong) { + inp.classList.add('incorrect'); + } + } +} + +function updateLiveCellHighlighting() { + const boardDiv = document.getElementById('sudoku-board'); + const inputs = boardDiv.getElementsByTagName('input'); + const conflictIndexes = new Set(); + + for (let idx = 0; idx < inputs.length; idx++) { + const inp = inputs[idx]; + if (inp.disabled) { + continue; + } + + const row = Number(inp.dataset.row); + const col = Number(inp.dataset.col); + const value = inp.value; + if (value === '') { + continue; + } + + for (let checkCol = 0; checkCol < SIZE; checkCol++) { + const otherIdx = row * SIZE + checkCol; + if (otherIdx !== idx && inputs[otherIdx].value === value) { + conflictIndexes.add(idx); + conflictIndexes.add(otherIdx); + } + } + + for (let checkRow = 0; checkRow < SIZE; checkRow++) { + const otherIdx = checkRow * SIZE + col; + if (otherIdx !== idx && inputs[otherIdx].value === value) { + conflictIndexes.add(idx); + conflictIndexes.add(otherIdx); + } + } + + const startRow = Math.floor(row / 3) * 3; + const startCol = Math.floor(col / 3) * 3; + for (let r = startRow; r < startRow + 3; r++) { + for (let c = startCol; c < startCol + 3; c++) { + const otherIdx = r * SIZE + c; + if (otherIdx !== idx && inputs[otherIdx].value === value) { + conflictIndexes.add(idx); + conflictIndexes.add(otherIdx); + } + } + } + } + + for (let idx = 0; idx < inputs.length; idx++) { + const inp = inputs[idx]; + if (inp.disabled) { + continue; + } + + if (conflictIndexes.has(idx)) { + inp.classList.add('incorrect'); + } else { + inp.classList.remove('incorrect'); + } + } +} + +function applyHint() { + if (!solution || solution.length !== SIZE) { return; } - const incorrect = new Set(data.incorrect.map(x => x[0]*SIZE + x[1])); + + const boardDiv = document.getElementById('sudoku-board'); + const inputs = boardDiv.getElementsByTagName('input'); 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'; + const row = Number(inp.dataset.row); + const col = Number(inp.dataset.col); + if (inp.value === '') { + const correctValue = solution[row][col]; + inp.value = correctValue; + inp.disabled = true; + inp.className = 'sudoku-cell prefilled'; + hintsUsed += 1; + break; } } - if (incorrect.size === 0) { - msg.style.color = '#388e3c'; - msg.innerText = 'Congratulations! You solved it!'; - } else { - msg.style.color = '#d32f2f'; - msg.innerText = 'Some cells are incorrect.'; +} + +async function checkSolution() { + const board = getBoardFromInputs(); + const msg = document.getElementById('message'); + + try { + const res = await fetch('/check', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({board}) + }); + + if (!res.ok) { + const data = await res.json(); + showMessage(data.error || 'Unable to check the board.', 'error'); + return; + } + + const data = await res.json(); + const incorrect = new Set(data.incorrect.map(x => x[0]*SIZE + x[1])); + highlightIncorrectCells(incorrect); + + if (incorrect.size === 0) { + gameCompleted = true; + stopTimer(); + saveScore(elapsedSeconds, currentDifficulty); + showMessage(`Congratulations! You solved it in ${formatTime(elapsedSeconds)}.`, 'success'); + } else { + showMessage('Some cells are incorrect. Please fix the highlighted fields.', 'error'); + } + } catch (error) { + console.error('Check solution failed:', error); + showMessage('Network error while checking the board. Please try again.', 'error'); } } // Wire buttons window.addEventListener('load', () => { + const difficultySelect = document.getElementById('difficulty'); + const themeToggle = document.getElementById('theme-toggle'); document.getElementById('new-game').addEventListener('click', newGame); + document.getElementById('hint').addEventListener('click', applyHint); document.getElementById('check-solution').addEventListener('click', checkSolution); + difficultySelect.addEventListener('change', (event) => { + currentDifficulty = event.target.value; + newGame(); + }); + themeToggle.addEventListener('click', () => { + const currentTheme = document.body.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; + applyTheme(currentTheme); + }); + initializeTheme(); + renderScoreboard(); // initialize newGame(); }); \ No newline at end of file diff --git a/starter/static/styles.css b/starter/static/styles.css index 1a6218ff..2618666a 100644 --- a/starter/static/styles.css +++ b/starter/static/styles.css @@ -1,22 +1,65 @@ +:root { + --bg-color: #f4f4f4; + --text-color: #333; + --board-bg: #fff; + --board-border: #333; + --cell-bg: #fafafa; + --cell-border: #bbb; + --cell-focus-bg: #e0f7fa; + --cell-prefilled-bg: #e0e0e0; + --cell-prefilled-color: #333; + --cell-incorrect-bg: #ffcdd2; + --cell-region-a: #f7f7e6; + --cell-region-b: #eef5ff; + --button-bg: #1976d2; + --button-hover-bg: #1565c0; + --button-text: #fff; + --shadow: rgba(0,0,0,0.1); +} + +body[data-theme="dark"] { + --bg-color: #1f1f1f; + --text-color: #f5f5f5; + --board-bg: #2b2b2b; + --board-border: #f5f5f5; + --cell-bg: #3a3a3a; + --cell-border: #777; + --cell-focus-bg: #264653; + --cell-prefilled-bg: #505050; + --cell-prefilled-color: #f5f5f5; + --cell-incorrect-bg: #7f1d1d; + --cell-region-a: #4a452f; + --cell-region-b: #233954; + --button-bg: #4f83cc; + --button-hover-bg: #3b6fa9; + --button-text: #fff; + --shadow: rgba(0,0,0,0.35); +} + body { font-family: Arial, sans-serif; - background: #f4f4f4; + background: var(--bg-color); + color: var(--text-color); text-align: center; margin: 0; - padding: 0; + padding: 16px; + min-height: 100vh; + overflow-x: hidden; } h1 { - margin-top: 30px; - color: #333; + margin-top: 16px; + color: var(--text-color); + font-size: clamp(1.5rem, 2.2vw, 2.2rem); } #sudoku-board { display: inline-block; - margin: 30px auto; - border: 4px solid #333; - background: #fff; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin: 24px auto; + border: 4px solid var(--board-border); + background: var(--board-bg); + box-shadow: 0 2px 8px var(--shadow); + width: min(100%, 420px); } .sudoku-row { @@ -24,38 +67,102 @@ h1 { } .sudoku-cell { - width: 40px; - height: 40px; - border: 1px solid #bbb; + width: min(max(32px, 4vw), 48px); + height: min(max(32px, 4vw), 48px); + border: 1px solid var(--cell-border); text-align: center; - font-size: 20px; + font-size: clamp(1rem, 1.5vw, 1.2rem); outline: none; - background: #fafafa; + background: var(--cell-bg); + color: var(--text-color); transition: background 0.2s; + box-sizing: border-box; +} + +.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(4), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(7), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(8), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(9), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(7), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(8), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(9), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(7), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(8), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(9) { + background: var(--cell-region-a); +} + +.sudoku-row:nth-child(1) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(1) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(1) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(2) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(2) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(2) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(3) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(3) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(3) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(4) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(5) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(1), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(2), +.sudoku-row:nth-child(6) .sudoku-cell:nth-child(3), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(7) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(8) .sudoku-cell:nth-child(6), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(4), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(5), +.sudoku-row:nth-child(9) .sudoku-cell:nth-child(6) { + background: var(--cell-region-b); } .sudoku-cell:focus { - background: #e0f7fa; + background: var(--cell-focus-bg); } .sudoku-cell.prefilled { - background: #e0e0e0; + background: var(--cell-prefilled-bg); font-weight: bold; - color: #333; + color: var(--cell-prefilled-color); } .sudoku-cell.incorrect { - background: #ffcdd2; + background: var(--cell-incorrect-bg); + border-color: #d32f2f; + color: #7f1d1d; } .sudoku-cell:nth-child(3), .sudoku-cell:nth-child(6) { - border-right: 3px solid #333; + 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; + border-bottom: 3px solid var(--board-border); } .controls { @@ -67,19 +174,89 @@ button { margin: 0 8px; font-size: 16px; border: none; - background: #1976d2; - color: #fff; + background: var(--button-bg); + color: var(--button-text); border-radius: 4px; cursor: pointer; transition: background 0.2s; } button:hover { - background: #1565c0; + background: var(--button-hover-bg); } #message { margin-left: 20px; - font-size: 16px; + font-size: 1rem; color: #d32f2f; } + +#theme-toggle { + margin-left: 10px; +} + +input, select { + padding: 8px 10px; + border-radius: 4px; + border: 1px solid var(--cell-border); + background: var(--cell-bg); + color: var(--text-color); + font-size: 1rem; +} + +@media (max-width: 760px) { + body { + padding: 12px; + } + + .controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + align-items: center; + justify-items: center; + } + + .controls button, + .controls select, + .controls input { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + + #message { + margin-left: 0; + margin-top: 8px; + text-align: center; + } + + #timer { + display: block; + margin-top: 8px; + } +} + +@media (max-width: 520px) { + #sudoku-board { + width: 100%; + } + + .sudoku-cell { + width: calc((100vw - 56px) / 9); + height: calc((100vw - 56px) / 9); + max-width: 44px; + max-height: 44px; + } + + .controls { + grid-template-columns: 1fr; + } + + button, + select, + input { + font-size: 0.95rem; + padding: 10px; + } +} diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py index 443b2452..4113e73d 100644 --- a/starter/sudoku_logic.py +++ b/starter/sudoku_logic.py @@ -3,13 +3,21 @@ SIZE = 9 EMPTY = 0 +DIFFICULTY_SETTINGS = { + 'easy': 40, + 'medium': 32, + 'hard': 24, +} + 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): @@ -24,31 +32,73 @@ def is_safe(board, row, col, num): return False return True -def fill_board(board): + +def find_empty(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 + return row, col + return None, None -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: + +def fill_board(board): + row, col = find_empty(board) + if row is None: + return True + + 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 - attempts -= 1 + return False + + +def count_solutions(board, limit=2): + board = deep_copy(board) + return _count_solutions(board, limit) + + +def _count_solutions(board, limit): + row, col = find_empty(board) + if row is None: + return 1 + + solutions = 0 + for candidate in range(1, SIZE + 1): + if is_safe(board, row, col, candidate): + board[row][col] = candidate + solutions += _count_solutions(board, limit) + board[row][col] = EMPTY + if solutions >= limit: + return solutions + return solutions + + +def remove_cells(board, clues): + positions = [(r, c) for r in range(SIZE) for c in range(SIZE)] + random.shuffle(positions) + removed = 0 + + while removed < SIZE * SIZE - clues and positions: + row, col = positions.pop() + if board[row][col] == EMPTY: + continue + + backup = board[row][col] + board[row][col] = EMPTY + + if count_solutions(board, limit=2) != 1: + board[row][col] = backup + else: + removed += 1 -def generate_puzzle(clues=35): +def generate_puzzle(clues=None, difficulty='easy'): + if clues is None: + clues = DIFFICULTY_SETTINGS.get(difficulty, DIFFICULTY_SETTINGS['easy']) board = create_empty_board() fill_board(board) solution = deep_copy(board) diff --git a/starter/templates/index.html b/starter/templates/index.html index e42ad04d..43e7f725 100644 --- a/starter/templates/index.html +++ b/starter/templates/index.html @@ -10,9 +10,24 @@