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
+
+
+
+### Copilot Refactoring
+
+
+
+### Testing Framework
+
+
+
+## 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
+
+
+
+
+
+
+
+
+
\ 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