diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..af76ea144 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,15 @@ +# GitHub Copilot Instructions + +This project is a Flask-based Sudoku application. + +When generating code: + +- Follow PEP 8 coding standards. +- Preserve the existing project structure. +- Keep functions modular and reusable. +- Avoid breaking existing functionality. +- Add concise comments only when they improve readability. +- Prefer readable and maintainable code over clever code. +- Ensure compatibility with Flask and the existing Sudoku logic. +- Suggest Pythonic solutions whenever possible. +- Keep HTML, CSS, and JavaScript clean and organized. diff --git a/.gitignore b/.gitignore index 2fcd240b2..4579cd48b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ Thumbs.db # Ignore Python virtual environment .venv/ +__pycache__/ +__pycache__/ +*.py[cod] diff --git a/screenshots/01_difficulty_levels.png.png b/screenshots/01_difficulty_levels.png.png new file mode 100644 index 000000000..053b53d27 Binary files /dev/null and b/screenshots/01_difficulty_levels.png.png differ diff --git a/screenshots/01_home_page.png.png b/screenshots/01_home_page.png.png new file mode 100644 index 000000000..d67f079cd Binary files /dev/null and b/screenshots/01_home_page.png.png differ diff --git a/screenshots/02_hint_feature.png..png b/screenshots/02_hint_feature.png..png new file mode 100644 index 000000000..ad433494d Binary files /dev/null and b/screenshots/02_hint_feature.png..png differ diff --git a/screenshots/03_immediate_validation.png.png b/screenshots/03_immediate_validation.png.png new file mode 100644 index 000000000..34ad2f6b9 Binary files /dev/null and b/screenshots/03_immediate_validation.png.png differ diff --git a/screenshots/04_timer_feature.png.png b/screenshots/04_timer_feature.png.png new file mode 100644 index 000000000..b57fa7abc Binary files /dev/null and b/screenshots/04_timer_feature.png.png differ diff --git a/starter/app.py b/starter/app.py index 0f526b757..a70031c0b 100644 --- a/starter/app.py +++ b/starter/app.py @@ -1,39 +1,111 @@ from flask import Flask, render_template, jsonify, request import sudoku_logic +import random app = Flask(__name__) -# Keep a simple in-memory store for current puzzle and solution CURRENT = { - 'puzzle': None, - 'solution': None + "puzzle": None, + "solution": None } -@app.route('/') + +@app.route("/") def index(): - return render_template('index.html') + return render_template("index.html") + -@app.route('/new') +@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}) + difficulty = request.args.get("difficulty", "easy").lower() + + if difficulty not in sudoku_logic.DIFFICULTY_LEVELS: + difficulty = "easy" + + puzzle, solution = sudoku_logic.generate_puzzle(difficulty) + + CURRENT["puzzle"] = puzzle + CURRENT["solution"] = solution + + return jsonify({ + "difficulty": difficulty, + "puzzle": puzzle + }) + + +@app.route("/hint") +def get_hint(): + puzzle = CURRENT["puzzle"] + solution = CURRENT["solution"] + + if puzzle is None or solution is None: + return jsonify({"error": "No active game"}), 400 + + empty = [] + + for r in range(sudoku_logic.SIZE): + for c in range(sudoku_logic.SIZE): + if puzzle[r][c] == 0: + empty.append((r, c)) + + if not empty: + return jsonify({"message": "Puzzle already completed"}) + + row, col = random.choice(empty) + + value = solution[row][col] + + puzzle[row][col] = value + + return jsonify({ + "row": row, + "col": col, + "value": value + }) -@app.route('/check', methods=['POST']) + +# ---------- NEW ROUTE ---------- +@app.route("/validate", methods=["POST"]) +def validate_cell(): + + if CURRENT["solution"] is None: + return jsonify({"error": "No active game"}), 400 + + data = request.get_json() + + row = int(data["row"]) + col = int(data["col"]) + value = int(data["value"]) + + correct = CURRENT["solution"][row][col] == value + + return jsonify({ + "correct": correct + }) + + +@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 + + if CURRENT["solution"] is None: + return jsonify({"error": "No game in progress"}), 400 + + data = request.get_json() + + board = data.get("board", []) + 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}) -if __name__ == '__main__': + for row in range(sudoku_logic.SIZE): + for col in range(sudoku_logic.SIZE): + if board[row][col] != CURRENT["solution"][row][col]: + incorrect.append([row, col]) + + return jsonify({ + "correct": len(incorrect) == 0, + "incorrect": incorrect + }) + + +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 2028e1026..38aa664f1 100644 --- a/starter/static/main.js +++ b/starter/static/main.js @@ -1,105 +1,255 @@ -// Client-side rendering and interaction for the Flask-backed Sudoku const SIZE = 9; let puzzle = []; +// ---------------- TIMER ---------------- +let timer = null; +let seconds = 0; + +function startTimer() { + clearInterval(timer); + seconds = 0; + updateTimer(); + + timer = setInterval(function () { + seconds++; + updateTimer(); + }, 1000); +} + +function stopTimer() { + clearInterval(timer); +} + +function updateTimer() { + const minutes = String(Math.floor(seconds / 60)).padStart(2, "0"); + const secs = String(seconds % 60).padStart(2, "0"); + + document.getElementById("timer").innerText = + `Time: ${minutes}:${secs}`; +} +// --------------------------------------- + + 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.type = 'text'; - input.maxLength = 1; - input.className = 'sudoku-cell'; - input.dataset.row = i; - input.dataset.col = j; - input.addEventListener('input', (e) => { - const val = e.target.value.replace(/[^1-9]/g, ''); - e.target.value = val; - }); - rowDiv.appendChild(input); + const board = document.getElementById("sudoku-board"); + board.innerHTML = ""; + + for (let row = 0; row < SIZE; row++) { + + const rowDiv = document.createElement("div"); + rowDiv.className = "sudoku-row"; + + for (let col = 0; col < SIZE; col++) { + + const input = document.createElement("input"); + + input.type = "text"; + input.maxLength = 1; + input.className = "sudoku-cell"; + + input.dataset.row = row; + input.dataset.col = col; + + input.addEventListener("input", async function (e) { + + e.target.value = e.target.value.replace(/[^1-9]/g, ""); + + if (e.target.value === "") { + e.target.classList.remove("incorrect"); + return; + } + + const response = await fetch("/validate", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + row: row, + col: col, + value: parseInt(e.target.value) + }) + }); + + const data = await response.json(); + + if (data.correct) { + e.target.classList.remove("incorrect"); + } else { + e.target.classList.add("incorrect"); + } + }); + + rowDiv.appendChild(input); + } + + board.appendChild(rowDiv); } - boardDiv.appendChild(rowDiv); - } } -function renderPuzzle(puz) { - puzzle = puz; - createBoardElement(); - const boardDiv = document.getElementById('sudoku-board'); - const inputs = boardDiv.getElementsByTagName('input'); - for (let i = 0; i < SIZE; i++) { - for (let j = 0; j < SIZE; j++) { - const idx = i * SIZE + j; - const val = puzzle[i][j]; - const inp = inputs[idx]; - if (val !== 0) { - inp.value = val; - inp.disabled = true; - inp.className += ' prefilled'; - } else { - inp.value = ''; - inp.disabled = false; - } +function renderPuzzle(board) { + + puzzle = board; + + createBoardElement(); + + const cells = document.querySelectorAll(".sudoku-cell"); + + for (let row = 0; row < SIZE; row++) { + + for (let col = 0; col < SIZE; col++) { + + const index = row * SIZE + col; + + if (board[row][col] !== 0) { + + cells[index].value = board[row][col]; + cells[index].disabled = true; + cells[index].classList.add("prefilled"); + + } else { + + cells[index].value = ""; + cells[index].disabled = false; + } + } } - } } async function newGame() { - const res = await fetch('/new'); - const data = await res.json(); - renderPuzzle(data.puzzle); - document.getElementById('message').innerText = ''; + + const difficulty = + document.getElementById("difficulty").value; + + const response = + await fetch(`/new?difficulty=${difficulty}`); + + const data = await response.json(); + + renderPuzzle(data.puzzle); + + // Start timer for every new game + startTimer(); + + document.getElementById("message").innerText = ""; +} + +async function hint() { + + const response = await fetch("/hint"); + + const data = await response.json(); + + if (data.error) { + document.getElementById("message").innerText = data.error; + return; + } + + const cells = document.querySelectorAll(".sudoku-cell"); + + const index = data.row * SIZE + data.col; + + cells[index].value = data.value; + cells[index].disabled = true; + cells[index].classList.add("prefilled"); } 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 cells = document.querySelectorAll(".sudoku-cell"); + + const board = []; + + for (let row = 0; row < SIZE; row++) { + + board[row] = []; + + for (let col = 0; col < SIZE; col++) { + + const index = row * SIZE + col; + + board[row][col] = + cells[index].value === "" + ? 0 + : parseInt(cells[index].value); + } + } + + const response = await fetch("/check", { + + method: "POST", + + headers: { + "Content-Type": "application/json" + }, + + body: JSON.stringify({ + board: board + }) + }); + + const data = await response.json(); + + const message = document.getElementById("message"); + + if (data.error) { + + message.style.color = "red"; + message.innerText = data.error; + + return; } - } - 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; - } - 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; - inp.className = 'sudoku-cell'; - if (incorrect.has(idx)) { - inp.className = 'sudoku-cell incorrect'; + + const incorrect = new Set( + data.incorrect.map(cell => cell[0] * SIZE + cell[1]) + ); + + cells.forEach((cell, index) => { + + if (cell.disabled) + return; + + cell.className = "sudoku-cell"; + + if (cell.value === "") { + return; + } + + if (incorrect.has(index)) { + cell.classList.add("incorrect"); + } + }); + + if (incorrect.size === 0) { + + message.style.color = "green"; + message.innerText = + "Congratulations! You solved the puzzle!"; + + // Stop timer when solved + stopTimer(); + + } else { + + message.style.color = "red"; + message.innerText = + "Some cells are incorrect."; } - } - 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.'; - } } -// Wire buttons -window.addEventListener('load', () => { - document.getElementById('new-game').addEventListener('click', newGame); - document.getElementById('check-solution').addEventListener('click', checkSolution); - // initialize - newGame(); -}); \ No newline at end of file +window.onload = function () { + + document + .getElementById("new-game") + .addEventListener("click", newGame); + + document + .getElementById("hint") + .addEventListener("click", hint); + + document + .getElementById("check-solution") + .addEventListener("click", checkSolution); + + newGame(); +}; \ No newline at end of file diff --git a/starter/static/styles.css b/starter/static/styles.css index 1a6218ff9..be77ac6df 100644 --- a/starter/static/styles.css +++ b/starter/static/styles.css @@ -11,12 +11,57 @@ h1 { color: #333; } +.controls { + margin: 20px auto; +} + +label { + font-size: 16px; + font-weight: bold; +} + +select { + padding: 8px; + font-size: 16px; + margin: 0 10px; +} + +button { + padding: 8px 18px; + margin: 0 8px; + font-size: 16px; + border: none; + background: #1976d2; + color: white; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +button:hover { + background: #1565c0; +} + +#timer { + margin: 15px; + font-size: 22px; + font-weight: bold; + color: #1976d2; +} + +#message { + display: block; + margin-top: 15px; + font-size: 18px; + font-weight: bold; +} + #sudoku-board { display: inline-block; - margin: 30px auto; + margin: 20px auto; border: 4px solid #333; - background: #fff; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + background: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .sudoku-row { @@ -24,11 +69,11 @@ h1 { } .sudoku-cell { - width: 40px; - height: 40px; + width: 45px; + height: 45px; border: 1px solid #bbb; text-align: center; - font-size: 20px; + font-size: 22px; outline: none; background: #fafafa; transition: background 0.2s; @@ -46,6 +91,8 @@ h1 { .sudoku-cell.incorrect { background: #ffcdd2; + color: #d32f2f; + font-weight: bold; } .sudoku-cell:nth-child(3), @@ -56,30 +103,4 @@ h1 { .sudoku-row:nth-child(3) .sudoku-cell, .sudoku-row:nth-child(6) .sudoku-cell { border-bottom: 3px solid #333; -} - -.controls { - margin: 20px auto; -} - -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; -} - -button:hover { - background: #1565c0; -} - -#message { - margin-left: 20px; - font-size: 16px; - color: #d32f2f; -} +} \ No newline at end of file diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py index 443b24524..d8e890856 100644 --- a/starter/sudoku_logic.py +++ b/starter/sudoku_logic.py @@ -4,54 +4,98 @@ SIZE = 9 EMPTY = 0 +# Number of clues for each difficulty +DIFFICULTY_LEVELS = { + "easy": 35, + "medium": 45, + "hard": 55 +} + + 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 + numbers = list(range(1, SIZE + 1)) + random.shuffle(numbers) + + for number in numbers: + if is_safe(board, row, col, number): + board[row][col] = number + 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) + cells_to_remove = SIZE * SIZE - clues + + while cells_to_remove > 0: + row = random.randint(0, SIZE - 1) + col = random.randint(0, SIZE - 1) + if board[row][col] != EMPTY: board[row][col] = EMPTY - attempts -= 1 + cells_to_remove -= 1 + + +def generate_puzzle(difficulty="easy"): + """ + Generate a Sudoku puzzle based on difficulty. + + Available difficulties: + - easy + - medium + - hard + """ + + difficulty = difficulty.lower() + + clues = DIFFICULTY_LEVELS.get( + difficulty, + DIFFICULTY_LEVELS["easy"] + ) -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..b914ddb09 100644 --- a/starter/templates/index.html +++ b/starter/templates/index.html @@ -1,18 +1,42 @@ - - + + +
- -