diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 000000000..3da8d8167
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,41 @@
+# Sudoku Project Instructions
+
+This project is a Flask-based Sudoku application.
+
+Code Style:
+
+- Prefer readability over clever implementations.
+- Use modern Python features and type hints where appropriate.
+- Keep functions small and focused.
+- Avoid duplicated code.
+- Follow consistent naming conventions.
+- Add comments only when they improve understanding.
+
+Architecture:
+
+- Separate game logic from Flask routes.
+- Keep UI, business logic, and persistence concerns isolated.
+- Prefer reusable helper functions and classes.
+
+Sudoku Requirements:
+
+- Every generated puzzle must have exactly one solution.
+- Support Easy, Medium, and Hard difficulties.
+- Prefilled cells must remain locked.
+- Invalid moves should provide immediate visual feedback.
+- Hint cells should become locked after being filled.
+- Maintain a Top 10 leaderboard.
+- Persist leaderboard data between sessions.
+
+Frontend Requirements:
+
+- Mobile responsive layout.
+- Support light and dark themes.
+- Alternate colors for 3x3 Sudoku regions.
+- Maintain accessibility and readability.
+
+Testing:
+
+- Use pytest.
+- Preserve existing behavior during refactoring.
+- Add tests for Sudoku generation and validation logic.=[]p
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..1bd2c9ba7
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "python-envs.pythonProjects": []
+}
diff --git "a/Screenshot 2026-09-04 at 12.17.37\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.17.37\342\200\257PM.png"
new file mode 100644
index 000000000..3b2fd8a20
Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.17.37\342\200\257PM.png" differ
diff --git "a/Screenshot 2026-09-04 at 12.17.52\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.17.52\342\200\257PM.png"
new file mode 100644
index 000000000..0fd756677
Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.17.52\342\200\257PM.png" differ
diff --git "a/Screenshot 2026-09-04 at 12.41.08\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.41.08\342\200\257PM.png"
new file mode 100644
index 000000000..8f50a9619
Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.41.08\342\200\257PM.png" differ
diff --git "a/Screenshot 2026-09-04 at 12.44.25\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.44.25\342\200\257PM.png"
new file mode 100644
index 000000000..4a7da95cf
Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.44.25\342\200\257PM.png" differ
diff --git "a/Screenshot 2026-09-04 at 12.44.39\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.44.39\342\200\257PM.png"
new file mode 100644
index 000000000..ea3e0df40
Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.44.39\342\200\257PM.png" differ
diff --git "a/Screenshot 2026-09-04 at 4.02.25\342\200\257PM.png" "b/Screenshot 2026-09-04 at 4.02.25\342\200\257PM.png"
new file mode 100644
index 000000000..490253aca
Binary files /dev/null and "b/Screenshot 2026-09-04 at 4.02.25\342\200\257PM.png" differ
diff --git "a/Screenshot 2026-09-04 at 4.02.33\342\200\257PM.png" "b/Screenshot 2026-09-04 at 4.02.33\342\200\257PM.png"
new file mode 100644
index 000000000..06e85f1bf
Binary files /dev/null and "b/Screenshot 2026-09-04 at 4.02.33\342\200\257PM.png" differ
diff --git "a/Screenshot 2026-09-04 at 4.11.23\342\200\257PM.png" "b/Screenshot 2026-09-04 at 4.11.23\342\200\257PM.png"
new file mode 100644
index 000000000..83efd7018
Binary files /dev/null and "b/Screenshot 2026-09-04 at 4.11.23\342\200\257PM.png" differ
diff --git a/starter/__pycache__/app.cpython-313.pyc b/starter/__pycache__/app.cpython-313.pyc
new file mode 100644
index 000000000..3e78c18e5
Binary files /dev/null and b/starter/__pycache__/app.cpython-313.pyc differ
diff --git a/starter/__pycache__/sudoku_logic.cpython-313.pyc b/starter/__pycache__/sudoku_logic.cpython-313.pyc
new file mode 100644
index 000000000..147b382a9
Binary files /dev/null and b/starter/__pycache__/sudoku_logic.cpython-313.pyc differ
diff --git a/starter/app.py b/starter/app.py
index 0f526b757..213fd2628 100644
--- a/starter/app.py
+++ b/starter/app.py
@@ -15,11 +15,38 @@ def index():
@app.route('/new')
def new_game():
- clues = int(request.args.get('clues', 35))
+ difficulty = request.args.get('difficulty', 'medium')
+
+ try:
+ clues = sudoku_logic.clue_count_for_difficulty(difficulty)
+ except ValueError as error:
+ return jsonify({'error': str(error)}), 400
+
puzzle, solution = sudoku_logic.generate_puzzle(clues)
CURRENT['puzzle'] = puzzle
CURRENT['solution'] = solution
+
return jsonify({'puzzle': puzzle})
+@app.route('/hint', methods=['POST'])
+def get_hint():
+ data = request.json or {}
+ board = data.get('board')
+ puzzle = CURRENT.get('puzzle')
+ solution = CURRENT.get('solution')
+
+ if puzzle is None or solution is None:
+ return jsonify({'error': 'No game in progress'}), 400
+
+ for row in range(sudoku_logic.SIZE):
+ for col in range(sudoku_logic.SIZE):
+ if puzzle[row][col] == sudoku_logic.EMPTY and board[row][col] == sudoku_logic.EMPTY:
+ return jsonify({
+ 'row': row,
+ 'col': col,
+ 'value': solution[row][col],
+ })
+
+ return jsonify({'error': 'No empty cells available'}), 400
@app.route('/check', methods=['POST'])
def check_solution():
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/static/main.js b/starter/static/main.js
index 2028e1026..00e85eafe 100644
--- a/starter/static/main.js
+++ b/starter/static/main.js
@@ -1,23 +1,136 @@
+let gameCompleted = false;
// Client-side rendering and interaction for the Flask-backed Sudoku
+let timerInterval = null;
+let timerStart = null;
const SIZE = 9;
let puzzle = [];
+let hintsUsed = 0;
+let currentDifficulty = 'medium';
+const LEADERBOARD_KEY = 'sudokuLeaderboard';
+const THEME_KEY = 'sudokuTheme';
+
+function applyTheme(theme) {
+ const isDark = theme === 'dark';
+
+ document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
+
+ const toggle = document.getElementById('theme-toggle');
+ toggle.setAttribute('aria-pressed', String(isDark));
+ toggle.textContent = isDark ? 'Light mode' : 'Dark mode';
+}
+
+function initializeTheme() {
+ const savedTheme = localStorage.getItem(THEME_KEY);
+ const theme = savedTheme === 'dark' ? 'dark' : 'light';
+
+ applyTheme(theme);
+
+ document.getElementById('theme-toggle').addEventListener('click', () => {
+ const nextTheme =
+ document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
+
+ localStorage.setItem(THEME_KEY, nextTheme);
+ applyTheme(nextTheme);
+ });
+}
+function loadLeaderboard() {
+ try {
+ const scores = JSON.parse(localStorage.getItem(LEADERBOARD_KEY) || '[]');
+ return Array.isArray(scores) ? scores : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveScore(score) {
+ const scores = [...loadLeaderboard(), score];
+
+ scores.sort(
+ (left, right) =>
+ left.timeSeconds - right.timeSeconds || left.hintsUsed - right.hintsUsed,
+ );
+
+ localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(scores.slice(0, 10)));
+}
+
+function formatScoreTime(seconds) {
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+
+ return `${String(minutes).padStart(2, '0')}:${String(
+ remainingSeconds,
+ ).padStart(2, '0')}`;
+}
+
+function renderLeaderboard() {
+ const leaderboardBody = document.getElementById('leaderboard-body');
+
+ if (!leaderboardBody) {
+ return;
+ }
+
+ leaderboardBody.innerHTML = '';
+
+ loadLeaderboard().forEach((score, index) => {
+ const row = document.createElement('tr');
+
+ [
+ index + 1,
+ score.name,
+ formatScoreTime(score.timeSeconds),
+ score.difficulty,
+ score.hintsUsed,
+ ].forEach((value) => {
+ const cell = document.createElement('td');
+ cell.textContent = value;
+ row.appendChild(cell);
+ });
+
+ leaderboardBody.appendChild(row);
+ });
+}
+
+function recordScore() {
+ const nameInput = document.getElementById('player-name');
+ const name = nameInput.value.trim() || 'Anonymous';
+ const timeSeconds = Math.floor((Date.now() - timerStart) / 1000);
+
+ saveScore({
+ name,
+ timeSeconds,
+ difficulty: currentDifficulty,
+ hintsUsed,
+ completedAt: new Date().toISOString(),
+ });
+
+ renderLeaderboard();
+}
function createBoardElement() {
const boardDiv = document.getElementById('sudoku-board');
+
boardDiv.innerHTML = '';
for (let i = 0; i < SIZE; i++) {
const rowDiv = document.createElement('div');
rowDiv.className = 'sudoku-row';
for (let j = 0; j < SIZE; j++) {
const input = document.createElement('input');
+ input.dataset.region =
+ (Math.floor(i / 3) + Math.floor(j / 3)) % 2 === 0
+ ? 'base'
+ : 'alternate';
input.type = 'text';
input.maxLength = 1;
input.className = 'sudoku-cell';
input.dataset.row = i;
input.dataset.col = j;
- input.addEventListener('input', (e) => {
- const val = e.target.value.replace(/[^1-9]/g, '');
- e.target.value = val;
+ input.addEventListener('input', (event) => {
+ if (gameCompleted) {
+ return;
+ }
+ const value = event.target.value.replace(/[^1-9]/g, '');
+ event.target.value = value;
+ validateCell(event.target);
});
rowDiv.appendChild(input);
}
@@ -25,7 +138,30 @@ function createBoardElement() {
}
}
+function stopTimer() {
+ clearInterval(timerInterval);
+ timerInterval = null;
+}
+
+function finishGame() {
+ gameCompleted = true;
+ stopTimer();
+
+ const inputs = document
+ .getElementById('sudoku-board')
+ .getElementsByTagName('input');
+
+ for (const input of inputs) {
+ input.disabled = true;
+ }
+
+ const message = document.getElementById('message');
+ message.className = 'message-success';
+ message.innerText = 'Congratulations! You solved it!';
+}
+
function renderPuzzle(puz) {
+ gameCompleted = false;
puzzle = puz;
createBoardElement();
const boardDiv = document.getElementById('sudoku-board');
@@ -47,14 +183,147 @@ function renderPuzzle(puz) {
}
}
+function getCurrentBoard() {
+ const inputs = document
+ .getElementById('sudoku-board')
+ .getElementsByTagName('input');
+
+ const board = [];
+
+ for (let row = 0; row < SIZE; row++) {
+ board[row] = [];
+
+ for (let col = 0; col < SIZE; col++) {
+ const input = inputs[row * SIZE + col];
+ board[row][col] = input.value ? parseInt(input.value, 10) : 0;
+ }
+ }
+
+ return board;
+}
+
+async function requestHint() {
+ const res = await fetch('/hint', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ board: getCurrentBoard() }),
+ });
+
+ const data = await res.json();
+ const message = document.getElementById('message');
+
+ if (data.error) {
+ message.style.color = '#d32f2f';
+ message.innerText = data.error;
+ return;
+ }
+
+ const input = document.querySelector(
+ `.sudoku-cell[data-row="${data.row}"][data-col="${data.col}"]`,
+ );
+
+ input.value = data.value;
+ input.disabled = true;
+ input.className = 'sudoku-cell hinted';
+ hintsUsed += 1;
+
+ message.className = 'message-success';
+ message.innerText = 'Hint added.';
+}
+
+function isValidMove(board, row, col, value) {
+ for (let index = 0; index < SIZE; index++) {
+ if (index !== col && board[row][index] === value) {
+ return false;
+ }
+
+ if (index !== row && board[index][col] === value) {
+ return false;
+ }
+ }
+
+ const boxRow = row - (row % 3);
+ const boxCol = col - (col % 3);
+
+ for (let boxRowIndex = boxRow; boxRowIndex < boxRow + 3; boxRowIndex++) {
+ for (let boxColIndex = boxCol; boxColIndex < boxCol + 3; boxColIndex++) {
+ if (
+ (boxRowIndex !== row || boxColIndex !== col) &&
+ board[boxRowIndex][boxColIndex] === value
+ ) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+function validateCell(input) {
+ if (gameCompleted) {
+ return;
+ }
+ const value = input.value ? parseInt(input.value, 10) : 0;
+
+ input.classList.remove('invalid');
+
+ if (!value) {
+ return;
+ }
+
+ const board = getCurrentBoard();
+ const row = Number(input.dataset.row);
+ const col = Number(input.dataset.col);
+
+ if (!isValidMove(board, row, col, value)) {
+ input.classList.add('invalid');
+ }
+}
+
+function formatElapsedTime(milliseconds) {
+ const totalSeconds = Math.floor(milliseconds / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+}
+
+function startTimer() {
+ clearInterval(timerInterval);
+
+ timerStart = Date.now();
+ document.getElementById('timer').innerText = '00:00';
+
+ timerInterval = setInterval(() => {
+ const elapsed = Date.now() - timerStart;
+ document.getElementById('timer').innerText = formatElapsedTime(elapsed);
+ }, 1000);
+}
+
async function newGame() {
- const res = await fetch('/new');
+ const difficulty = document.getElementById('difficulty').value;
+ const res = await fetch(`/new?difficulty=${encodeURIComponent(difficulty)}`);
const data = await res.json();
+
+ if (data.error) {
+ document.getElementById('message').innerText = data.error;
+ return;
+ }
+
+ currentDifficulty = difficulty;
+ hintsUsed = 0;
+
renderPuzzle(data.puzzle);
- document.getElementById('message').innerText = '';
+ startTimer();
+ const message = document.getElementById('message');
+ message.className = '';
+ message.innerText = '';
}
async function checkSolution() {
+ if (gameCompleted) {
+ return;
+ }
const boardDiv = document.getElementById('sudoku-board');
const inputs = boardDiv.getElementsByTagName('input');
const board = [];
@@ -68,17 +337,17 @@ async function checkSolution() {
}
const res = await fetch('/check', {
method: 'POST',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({board})
+ 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;
+ msg.className = 'message-error';
+ msg.innerText = 'Some cells are incorrect.';
return;
}
- const incorrect = new Set(data.incorrect.map(x => x[0]*SIZE + x[1]));
+ const incorrect = new Set(data.incorrect.map((x) => x[0] * SIZE + x[1]));
for (let idx = 0; idx < inputs.length; idx++) {
const inp = inputs[idx];
if (inp.disabled) continue;
@@ -88,8 +357,8 @@ async function checkSolution() {
}
}
if (incorrect.size === 0) {
- msg.style.color = '#388e3c';
- msg.innerText = 'Congratulations! You solved it!';
+ finishGame();
+ recordScore();
} else {
msg.style.color = '#d32f2f';
msg.innerText = 'Some cells are incorrect.';
@@ -98,8 +367,15 @@ async function checkSolution() {
// Wire buttons
window.addEventListener('load', () => {
+ initializeTheme();
+
document.getElementById('new-game').addEventListener('click', newGame);
- document.getElementById('check-solution').addEventListener('click', checkSolution);
- // initialize
+ document.getElementById('difficulty').addEventListener('change', newGame);
+ document
+ .getElementById('check-solution')
+ .addEventListener('click', checkSolution);
+ document.getElementById('hint').addEventListener('click', requestHint);
+
+ renderLeaderboard();
newGame();
-});
\ No newline at end of file
+});
diff --git a/starter/static/styles.css b/starter/static/styles.css
index 1a6218ff9..7e0c0a71e 100644
--- a/starter/static/styles.css
+++ b/starter/static/styles.css
@@ -1,85 +1,269 @@
+:root {
+ color-scheme: light;
+ --page-bg: #f4f4f4;
+ --text: #333;
+ --surface: #fff;
+ --cell-bg: #fafafa;
+ --border: #bbb;
+ --strong-border: #333;
+ --prefilled-bg: #e0e0e0;
+ --focus-bg: #e0f7fa;
+ --hint-bg: #fff3cd;
+ --hint-text: #856404;
+ --invalid-bg: #ffcdd2;
+ --invalid-border: #d32f2f;
+ --accent: #1976d2;
+ --accent-hover: #1565c0;
+ --region-base-bg: #fafafa;
+ --region-alternate-bg: #eef5ff;
+}
+
+:root[data-theme='dark'] {
+ color-scheme: dark;
+ --page-bg: #121212;
+ --text: #f1f1f1;
+ --surface: #1e1e1e;
+ --cell-bg: #252525;
+ --border: #666;
+ --strong-border: #d6d6d6;
+ --prefilled-bg: #3a3a3a;
+ --focus-bg: #164e63;
+ --hint-bg: #66551c;
+ --hint-text: #fff3a3;
+ --invalid-bg: #6b2525;
+ --invalid-border: #ef5350;
+ --accent: #42a5f5;
+ --accent-hover: #64b5f6;
+ --region-base-bg: #252525;
+ --region-alternate-bg: #303b46;
+}
+
body {
- font-family: Arial, sans-serif;
- background: #f4f4f4;
- text-align: center;
- margin: 0;
- padding: 0;
+ font-family: Arial, sans-serif;
+ background: var(--page-bg);
+ color: var(--text);
+ text-align: center;
+ margin: 0;
+ padding: 0;
}
h1 {
- margin-top: 30px;
- color: #333;
+ margin-top: 30px;
+ font-size: clamp(1.5rem, 8vw, 2rem);
+ margin: 20px 0;
+ color: var(--text);
}
#sudoku-board {
- display: inline-block;
- margin: 30px auto;
- border: 4px solid #333;
- background: #fff;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+ display: inline-block;
+ margin: 30px auto;
+ border: 4px solid var(--strong-border);
+ background: var(--surface);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+ width: min(calc(100vw - 24px), 386px);
+ box-sizing: border-box;
}
.sudoku-row {
- display: flex;
+ display: flex;
+ width: 100%;
}
.sudoku-cell {
- width: 40px;
- height: 40px;
- border: 1px solid #bbb;
- text-align: center;
- font-size: 20px;
- outline: none;
- background: #fafafa;
- transition: background 0.2s;
+ min-width: 0;
+ flex: 1 1 0;
+ display: block;
+ box-sizing: border-box;
+ width: auto;
+ height: auto;
+ aspect-ratio: 1;
+ padding: 0;
+ text-align: center;
+ line-height: 1;
+ font-family: inherit;
+ font-family: 'Trebuchet MS', sans-serif;
+ font-size: clamp(1rem, 5vw, 1.35rem);
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0;
+ text-align: center;
+ color: var(--text);
+ text-indent: 0;
+}
+
+#sudoku-board {
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.sudoku-cell[data-region='alternate'] {
+ background: var(--region-alternate-bg);
}
.sudoku-cell:focus {
- background: #e0f7fa;
+ background: var(--focus-bg);
}
.sudoku-cell.prefilled {
- background: #e0e0e0;
- font-weight: bold;
- color: #333;
+ background: var(--prefilled-bg);
+ font-weight: bold;
+ color: var(--text);
}
-.sudoku-cell.incorrect {
- background: #ffcdd2;
+.sudoku-cell.incorrect,
+.sudoku-cell.invalid {
+ background: var(--invalid-bg);
+ border-color: var(--invalid-border);
+}
+
+.sudoku-cell.hinted {
+ background: var(--hint-bg);
+ font-weight: bold;
+ color: var(--hint-text);
}
.sudoku-cell:nth-child(3),
.sudoku-cell:nth-child(6) {
- border-right: 3px solid #333;
+ border-right: 3px solid var(--strong-border);
}
.sudoku-row:nth-child(3) .sudoku-cell,
.sudoku-row:nth-child(6) .sudoku-cell {
- border-bottom: 3px solid #333;
+ border-bottom: 3px solid var(--strong-border);
}
.controls {
- margin: 20px auto;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding-inline: 12px;
+}
+
+.controls button {
+ margin: 0;
+}
+
+.controls input,
+.controls select,
+.controls button {
+ box-sizing: border-box;
+ height: 38px;
+ margin: 0;
+}
+
+.controls label {
+ white-space: nowrap;
+}
+
+#timer {
+ min-width: 86px;
}
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;
+ min-height: 38px;
+ padding: 8px 16px;
+ margin: 0;
+ border: 1px solid var(--accent);
+ border-radius: 5px;
+ font-family: inherit;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1;
+ background: var(--accent);
+ color: #fff;
+ cursor: pointer;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+ transition:
+ background 0.2s,
+ transform 0.2s,
+ box-shadow 0.2s;
}
button:hover {
- background: #1565c0;
+ background: var(--accent-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 3px 6px rgba(0, 0, 0, 0.25);
+}
+
+button:focus-visible {
+ outline: 3px solid var(--focus-bg);
+ outline-offset: 2px;
}
#message {
- margin-left: 20px;
- font-size: 16px;
- color: #d32f2f;
+ flex-basis: 100%;
+ margin-left: 0;
+ font-size: 16px;
+}
+
+.message-success {
+ color: #388e3c;
+}
+
+.message-error {
+ color: #ef5350;
+}
+
+#leaderboard {
+ width: min(90%, 700px);
+ margin: 40px auto;
+ overflow-x: auto;
+}
+
+#leaderboard table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--surface);
+ min-width: 520px;
+}
+
+#leaderboard th,
+#leaderboard td {
+ padding: 10px;
+ border: 1px solid var(--border);
+ text-align: center;
+ padding: 8px 6px;
+ font-size: clamp(0.75rem, 2.5vw, 1rem);
+ overflow-wrap: anywhere;
+}
+
+#leaderboard th {
+ background: var(--accent);
+ color: #fff;
+}
+
+#leaderboard td {
+ color: var(--text);
+}
+
+#leaderboard tbody tr:nth-child(even) {
+ background: var(--page-bg);
+}
+
+#player-name,
+#difficulty {
+ padding: 8px;
+ font-size: 16px;
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+#player-name {
+ max-width: 180px;
+}
+
+#theme-toggle {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+}
+
+@media (max-width: 480px) {
+ #theme-toggle {
+ position: static;
+ margin-bottom: 8px;
+ }
}
diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py
index 443b24524..f3497424e 100644
--- a/starter/sudoku_logic.py
+++ b/starter/sudoku_logic.py
@@ -4,54 +4,121 @@
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 count_solutions(board, limit=2):
+ """Return the number of solutions, capped at limit."""
+ solutions = 0
+
+ for row in range(SIZE):
+ for col in range(SIZE):
+ if board[row][col] == EMPTY:
+ 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
+
+ return 1
+
+
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
+ if not 0 <= clues <= SIZE * SIZE:
+ raise ValueError("clues must be between 0 and 81")
+
+ positions = [
+ (row, col)
+ for row in range(SIZE)
+ for col in range(SIZE)
+ if board[row][col] != EMPTY
+ ]
+ random.shuffle(positions)
+
+ for row, col in positions:
+ if sum(
+ cell != EMPTY
+ for current_row in board
+ for cell in current_row
+ ) <= clues:
+ break
+
+ value = board[row][col]
+ board[row][col] = EMPTY
+
+ if count_solutions(board) != 1:
+ board[row][col] = value
+
+DIFFICULTY_CLUES = {
+ "easy": 45,
+ "medium": 35,
+ "hard": 25,
+}
+
+
+def clue_count_for_difficulty(difficulty):
+ try:
+ return DIFFICULTY_CLUES[difficulty.lower()]
+ except (AttributeError, KeyError):
+ raise ValueError(
+ "difficulty must be easy, medium, or hard"
+ )
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
+ return puzzle, solution
\ No newline at end of file
diff --git a/starter/templates/index.html b/starter/templates/index.html
index e42ad04da..8f618b5e6 100644
--- a/starter/templates/index.html
+++ b/starter/templates/index.html
@@ -1,18 +1,61 @@
-
-
-
- Sudoku Game
-
-
-
- Sudoku Game
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+ Sudoku Game
+
+
+
+
+ Sudoku Game
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Time: 00:00
+
+
+
+
+
+ Top 10 Leaderboard
+
+
+
+
+ | Rank |
+ Name |
+ Time |
+ Difficulty |
+ Hints |
+
+
+
+
+
+
+
+
+
+
diff --git a/starter/tests/__init__.py b/starter/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/starter/tests/__pycache__/__init__.cpython-313.pyc b/starter/tests/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 000000000..1d9ca5852
Binary files /dev/null and b/starter/tests/__pycache__/__init__.cpython-313.pyc differ
diff --git a/starter/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc b/starter/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 000000000..89a798104
Binary files /dev/null and b/starter/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc differ
diff --git a/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc b/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc
new file mode 100644
index 000000000..b92c5e1cf
Binary files /dev/null and b/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc differ
diff --git a/starter/tests/__pycache__/test_app.cpython-313-pytest-9.1.1.pyc b/starter/tests/__pycache__/test_app.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 000000000..b92c5e1cf
Binary files /dev/null and b/starter/tests/__pycache__/test_app.cpython-313-pytest-9.1.1.pyc differ
diff --git a/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc
new file mode 100644
index 000000000..4dba858c4
Binary files /dev/null and b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc differ
diff --git a/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-9.1.1.pyc b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 000000000..4dba858c4
Binary files /dev/null and b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-9.1.1.pyc differ
diff --git a/starter/tests/conftest.py b/starter/tests/conftest.py
new file mode 100644
index 000000000..87c62a506
--- /dev/null
+++ b/starter/tests/conftest.py
@@ -0,0 +1,6 @@
+import sys
+from pathlib import Path
+
+# Add parent directory (starter/) to sys.path so tests can import app and sudoku_logic
+STARTER_DIR = Path(__file__).parent.parent
+sys.path.insert(0, str(STARTER_DIR))
\ No newline at end of file
diff --git a/starter/tests/test_app.py b/starter/tests/test_app.py
new file mode 100644
index 000000000..c20e7739f
--- /dev/null
+++ b/starter/tests/test_app.py
@@ -0,0 +1,101 @@
+import app as app_module
+import sudoku_logic
+import pytest
+
+
+@pytest.fixture
+def client():
+ app_module.app.config["TESTING"] = True
+ app_module.CURRENT["puzzle"] = None
+ app_module.CURRENT["solution"] = None
+
+ with app_module.app.test_client() as test_client:
+ yield test_client
+
+ app_module.CURRENT["puzzle"] = None
+ app_module.CURRENT["solution"] = None
+
+
+def test_index_returns_game_page(client):
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert b"Sudoku Game" in response.data
+
+
+def test_new_game_returns_puzzle(client):
+ response = client.get("/new?clues=35")
+
+ assert response.status_code == 200
+ data = response.get_json()
+
+ assert "puzzle" in data
+ assert len(data["puzzle"]) == 9
+ assert all(len(row) == 9 for row in data["puzzle"])
+
+
+def test_check_requires_game_in_progress(client):
+ response = client.post("/check", json={"board": []})
+
+ assert response.status_code == 400
+ assert response.get_json() == {"error": "No game in progress"}
+
+
+def test_check_accepts_current_solution(client):
+ puzzle, solution = sudoku_logic.generate_puzzle(clues=35)
+ app_module.CURRENT["puzzle"] = puzzle
+ app_module.CURRENT["solution"] = solution
+
+ response = client.post("/check", json={"board": solution})
+
+ assert response.status_code == 200
+ assert response.get_json() == {"incorrect": []}
+
+
+def test_check_reports_incorrect_cells(client):
+ puzzle, solution = sudoku_logic.generate_puzzle(clues=35)
+ app_module.CURRENT["puzzle"] = puzzle
+ app_module.CURRENT["solution"] = solution
+
+ board = [row[:] for row in solution]
+ board[0][0] = 0
+
+ response = client.post("/check", json={"board": board})
+
+ assert response.status_code == 200
+ assert response.get_json()["incorrect"] == [[0, 0]]
+
+@pytest.mark.parametrize(
+ ("difficulty", "expected_clues"),
+ [
+ ("easy", 45),
+ ("medium", 35),
+ ("hard", 25),
+ ],
+)
+def test_new_game_uses_selected_difficulty(
+ client,
+ difficulty,
+ expected_clues,
+):
+ response = client.get(f"/new?difficulty={difficulty}")
+
+ assert response.status_code == 200
+
+ puzzle = response.get_json()["puzzle"]
+ clues = sum(
+ cell != sudoku_logic.EMPTY
+ for row in puzzle
+ for cell in row
+ )
+
+ assert clues == expected_clues
+
+
+def test_new_game_rejects_invalid_difficulty(client):
+ response = client.get("/new?difficulty=expert")
+
+ assert response.status_code == 400
+ assert response.get_json() == {
+ "error": "difficulty must be easy, medium, or hard"
+ }
\ No newline at end of file
diff --git a/starter/tests/test_sudoku_logic.py b/starter/tests/test_sudoku_logic.py
new file mode 100644
index 000000000..af7a59eba
--- /dev/null
+++ b/starter/tests/test_sudoku_logic.py
@@ -0,0 +1,133 @@
+import sudoku_logic
+
+
+def is_valid_solution(board):
+ expected = set(range(1, sudoku_logic.SIZE + 1))
+
+ rows_valid = all(set(row) == expected for row in board)
+ columns_valid = all(
+ {board[row][column] for row in range(sudoku_logic.SIZE)} == expected
+ for column in range(sudoku_logic.SIZE)
+ )
+
+ boxes_valid = all(
+ {
+ board[row][column]
+ for row in range(box_row, box_row + 3)
+ for column in range(box_column, box_column + 3)
+ }
+ == expected
+ for box_row in range(0, sudoku_logic.SIZE, 3)
+ for box_column in range(0, sudoku_logic.SIZE, 3)
+ )
+
+ return rows_valid and columns_valid and boxes_valid
+
+
+def test_create_empty_board():
+ board = sudoku_logic.create_empty_board()
+
+ assert len(board) == 9
+ assert all(len(row) == 9 for row in board)
+ assert all(cell == sudoku_logic.EMPTY for row in board for cell in row)
+
+
+def test_is_safe_rejects_row_conflict():
+ board = sudoku_logic.create_empty_board()
+ board[0][0] = 5
+
+ assert not sudoku_logic.is_safe(board, 0, 1, 5)
+
+
+def test_is_safe_rejects_column_conflict():
+ board = sudoku_logic.create_empty_board()
+ board[1][1] = 6
+
+ assert not sudoku_logic.is_safe(board, 0, 1, 6)
+
+
+def test_is_safe_rejects_box_conflict():
+ board = sudoku_logic.create_empty_board()
+ board[1][1] = 7
+
+ assert not sudoku_logic.is_safe(board, 0, 0, 7)
+
+
+def test_is_safe_accepts_valid_candidate():
+ board = sudoku_logic.create_empty_board()
+
+ assert sudoku_logic.is_safe(board, 0, 0, 1)
+
+
+def test_fill_board_fills_board_in_place():
+ board = sudoku_logic.create_empty_board()
+
+ result = sudoku_logic.fill_board(board)
+
+ assert result is True
+ assert is_valid_solution(board)
+
+
+def test_generate_puzzle_returns_puzzle_and_solution():
+ puzzle, solution = sudoku_logic.generate_puzzle(clues=35)
+
+ assert len(puzzle) == 9
+ assert len(solution) == 9
+ assert is_valid_solution(solution)
+
+ assert sum(
+ cell != sudoku_logic.EMPTY
+ for row in puzzle
+ for cell in row
+ ) == 35
+
+ for row in range(9):
+ for column in range(9):
+ if puzzle[row][column] != sudoku_logic.EMPTY:
+ assert puzzle[row][column] == solution[row][column]
+def test_count_solutions_returns_one_for_generated_puzzle():
+ puzzle, _ = sudoku_logic.generate_puzzle(clues=35)
+
+ assert sudoku_logic.count_solutions(puzzle) == 1
+
+
+def test_count_solutions_stops_at_two_for_empty_board():
+ board = sudoku_logic.create_empty_board()
+
+ assert sudoku_logic.count_solutions(board) == 2
+
+
+def test_remove_cells_preserves_requested_clue_count_and_uniqueness():
+ board = sudoku_logic.create_empty_board()
+ sudoku_logic.fill_board(board)
+
+ sudoku_logic.remove_cells(board, clues=35)
+
+ assert sum(
+ cell != sudoku_logic.EMPTY
+ for row in board
+ for cell in row
+ ) == 35
+ assert sudoku_logic.count_solutions(board) == 1
+
+import pytest
+
+
+@pytest.mark.parametrize(
+ ("difficulty", "expected_clues"),
+ [
+ ("easy", 45),
+ ("medium", 35),
+ ("hard", 25),
+ ],
+)
+def test_clue_count_for_difficulty(difficulty, expected_clues):
+ assert (
+ sudoku_logic.clue_count_for_difficulty(difficulty)
+ == expected_clues
+ )
+
+
+def test_clue_count_for_difficulty_rejects_invalid_value():
+ with pytest.raises(ValueError):
+ sudoku_logic.clue_count_for_difficulty("expert")
\ No newline at end of file