diff --git a/app.py b/app.py index 1a28c9a..c6bf00c 100644 --- a/app.py +++ b/app.py @@ -21,12 +21,13 @@ from handlers.api import ( BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -def make_app(session_factory=None): +def make_app(session_factory=None, debug=False): """Create and configure the Tornado application. Args: session_factory: SQLAlchemy session factory. If None, API endpoints that require a database will not work. + debug: Enable debug mode (auto-reload, stack traces). Returns: A configured ``tornado.web.Application`` instance. @@ -45,18 +46,19 @@ def make_app(session_factory=None): template_path=os.path.join(BASE_DIR, "templates"), static_path=os.path.join(BASE_DIR, "static"), session_factory=session_factory, - debug=True, + debug=debug, ) @click.command() @click.option("--port", default=8888, type=int, help="Port to listen on.") -def main(port): +@click.option("--debug", is_flag=True, help="Enable debug mode.") +def main(port, debug): """Start the web server.""" engine = get_engine() init_db(engine) session_factory = get_session_factory(engine) - app = make_app(session_factory=session_factory) + app = make_app(session_factory=session_factory, debug=debug) app.listen(port) print(f"Server started at http://localhost:{port}") tornado.ioloop.IOLoop.current().start() diff --git a/handlers/api.py b/handlers/api.py index 528a6bc..dd0dcf2 100644 --- a/handlers/api.py +++ b/handlers/api.py @@ -1,6 +1,8 @@ """REST API handlers for game operations.""" +import collections import json +import time from datetime import datetime, timezone import tornado.web @@ -9,6 +11,27 @@ from game.factory import BoardFactory, DIRECTIONS from models.category import Category from models.game import Game, Word +# Per-IP rate limit: tracks {ip: deque of timestamps} +_rate_buckets = collections.defaultdict(collections.deque) + +# Limits: (max_requests, window_seconds) +RATE_LIMITS = { + "default": (60, 60), + "new_game": (10, 60), +} + + +def _check_rate(ip, bucket="default"): + max_requests, window = RATE_LIMITS[bucket] + now = time.monotonic() + timestamps = _rate_buckets[f"{bucket}:{ip}"] + while timestamps and timestamps[0] <= now - window: + timestamps.popleft() + if len(timestamps) >= max_requests: + return False + timestamps.append(now) + return True + class BaseAPIHandler(tornado.web.RequestHandler): """Base handler for JSON API endpoints.""" @@ -20,6 +43,14 @@ class BaseAPIHandler(tornado.web.RequestHandler): Session = self.application.settings["session_factory"] return Session() + def check_rate_limit(self, bucket="default"): + """Return True if request is allowed, else write 429 and return False.""" + if not _check_rate(self.request.remote_ip, bucket): + self.set_status(429) + self.write(json.dumps({"error": "Too many requests"})) + return False + return True + def write_error(self, status_code, **kwargs): self.write(json.dumps({"error": self._reason})) @@ -32,6 +63,9 @@ class CategoriesHandler(BaseAPIHandler): try: categories = session.query(Category).all() self.write(json.dumps([{"id": c.id, "name": c.name} for c in categories])) + except Exception: + session.rollback() + raise finally: session.close() @@ -39,28 +73,72 @@ class CategoriesHandler(BaseAPIHandler): class NewGameHandler(BaseAPIHandler): """``POST /api/game/new`` -- create a new game. - Expects JSON body with ``category_id`` and optional ``board_size`` (8-20). + Expects JSON body with ``category_id`` and optional ``board_size`` (8-20) + and ``word_count`` (3-30). """ def post(self): + if not self.check_rate_limit("new_game"): + return session = self.get_session() try: - data = json.loads(self.request.body) + try: + data = json.loads(self.request.body) + except (json.JSONDecodeError, TypeError): + self.set_status(400) + self.write(json.dumps({"error": "Invalid JSON"})) + return + + if "category_id" not in data: + self.set_status(400) + self.write(json.dumps({"error": "category_id is required"})) + return + category_id = data["category_id"] - board_size = data.get("board_size", 10) + + try: + board_size = int(data.get("board_size", 10)) + except (TypeError, ValueError): + self.set_status(400) + self.write(json.dumps({"error": "board_size must be a number"})) + return board_size = max(8, min(20, board_size)) + try: + word_count = int(data.get("word_count", 8)) + except (TypeError, ValueError): + self.set_status(400) + self.write(json.dumps({"error": "word_count must be a number"})) + return + word_count = max(3, min(30, word_count)) + + category = session.query(Category).filter_by(id=category_id).first() + if not category: + self.set_status(404) + self.write(json.dumps({"error": "Category not found"})) + return + factory = BoardFactory(session) game, words = factory.create( category_id=category_id, board_size=board_size, + word_count=word_count, ) + + if not words: + self.set_status(500) + self.write(json.dumps({"error": "Failed to generate board with words"})) + return + session.add(game) for w in words: session.add(w) session.commit() self.write(json.dumps(self._game_to_dict(game))) + except Exception: + session.rollback() + raise finally: session.close() @@ -108,6 +186,9 @@ class GameStateHandler(BaseAPIHandler): } ) ) + except Exception: + session.rollback() + raise finally: session.close() @@ -120,6 +201,8 @@ class GuessHandler(BaseAPIHandler): """ def post(self, game_id): + if not self.check_rate_limit(): + return session = self.get_session() try: game = session.query(Game).filter_by(id=game_id).first() @@ -128,7 +211,12 @@ class GuessHandler(BaseAPIHandler): self.write(json.dumps({"error": "Game not found"})) return - data = json.loads(self.request.body) + try: + data = json.loads(self.request.body) + except (json.JSONDecodeError, TypeError): + self.set_status(400) + self.write(json.dumps({"error": "Invalid JSON"})) + return if "word" in data: result = self._handle_text_guess(session, game, data["word"]) @@ -140,10 +228,15 @@ class GuessHandler(BaseAPIHandler): return self.write(json.dumps(result)) + except Exception: + session.rollback() + raise finally: session.close() def _handle_text_guess(self, session, game, guess_text): + if not isinstance(guess_text, str): + return self._miss_response(game, session) guess_text = guess_text.upper().strip() word = ( session.query(Word) @@ -159,7 +252,7 @@ class GuessHandler(BaseAPIHandler): grid = game.grid try: letters = "".join(grid[c["row"]][c["col"]] for c in cells) - except (IndexError, KeyError): + except (IndexError, KeyError, TypeError): return self._miss_response(game, session) word = ( diff --git a/seed.py b/seed.py index 96d85c1..67b161c 100644 --- a/seed.py +++ b/seed.py @@ -23,6 +23,21 @@ SEED_DATA = { "CRANE", "MOOSE", "RAVEN", + "JAGUAR", + "PARROT", + "TURTLE", + "SALMON", + "FERRET", + "COYOTE", + "OSPREY", + "LIZARD", + "BADGER", + "PELICAN", + "MONKEY", + "RABBIT", + "IGUANA", + "CONDOR", + "JACKAL", ], "colors": [ "CRIMSON", @@ -45,6 +60,21 @@ SEED_DATA = { "JADE", "CYAN", "MAUVE", + "COBALT", + "COPPER", + "EBONY", + "PEACH", + "CREAM", + "LEMON", + "ORCHID", + "SALMON", + "SIENNA", + "KHAKI", + "LILAC", + "PEARL", + "CHARCOAL", + "MAGENTA", + "EMERALD", ], "food": [ "PIZZA", @@ -67,6 +97,129 @@ SEED_DATA = { "CURRY", "BAGEL", "FUDGE", + "SHRIMP", + "CHEESE", + "BUTTER", + "CHERRY", + "PEPPER", + "GINGER", + "PRETZEL", + "BRISKET", + "NOODLE", + "OYSTER", + "TURNIP", + "RADISH", + "CELERY", + "COOKIE", + "BISQUE", + ], + "sports": [ + "SOCCER", + "TENNIS", + "BOXING", + "RUGBY", + "GOLF", + "DIVING", + "ROWING", + "HOCKEY", + "FENCING", + "ARCHER", + "SPRINT", + "DISCUS", + "SQUASH", + "KARATE", + "JUDO", + "RELAY", + "SLALOM", + "CRAWL", + "VAULT", + "SERVE", + "VOLLEY", + "TACKLE", + "PADDLE", + "JAVELIN", + "HURDLE", + ], + "space": [ + "PLANET", + "GALAXY", + "NEBULA", + "COMET", + "ORBIT", + "QUASAR", + "PULSAR", + "LUNAR", + "SOLAR", + "METEOR", + "AURORA", + "ECLIPSE", + "ZENITH", + "CRATER", + "COSMOS", + "PHOTON", + "PLASMA", + "ROCKET", + "SATURN", + "VENUS", + "MARS", + "PLUTO", + "TITAN", + "VORTEX", + "FLARE", + ], + "nature": [ + "RIVER", + "OCEAN", + "CANYON", + "FOREST", + "DESERT", + "ISLAND", + "GLACIER", + "MEADOW", + "VALLEY", + "SUMMIT", + "LAGOON", + "TUNDRA", + "MARSH", + "CLIFF", + "DELTA", + "FJORD", + "GROVE", + "RIDGE", + "CORAL", + "BROOK", + "DUNE", + "GEYSER", + "CAVERN", + "RAPIDS", + "SAVANNA", + ], + "music": [ + "GUITAR", + "PIANO", + "DRUMS", + "VIOLIN", + "FLUTE", + "CELLO", + "BANJO", + "ORGAN", + "HARP", + "TRUMPET", + "RHYTHM", + "MELODY", + "CHORD", + "TEMPO", + "FORTE", + "TREBLE", + "OCTAVE", + "SONATA", + "BALLAD", + "OPERA", + "HYMN", + "LYRIC", + "CHORUS", + "BRIDGE", + "DUET", ], } @@ -80,14 +233,23 @@ def seed(): for cat_name, words in SEED_DATA.items(): existing = session.query(Category).filter_by(name=cat_name).first() if existing: - print(f"Category '{cat_name}' already exists, skipping.") - continue - cat = Category(name=cat_name) - session.add(cat) - session.flush() + cat = existing + else: + cat = Category(name=cat_name) + session.add(cat) + session.flush() + + added = 0 for w in words: - session.add(CategoryWord(category_id=cat.id, word=w)) - print(f"Seeded category '{cat_name}' with {len(words)} words.") + exists = ( + session.query(CategoryWord) + .filter_by(category_id=cat.id, word=w) + .first() + ) + if not exists: + session.add(CategoryWord(category_id=cat.id, word=w)) + added += 1 + print(f"Category '{cat_name}': added {added} new words.") session.commit() session.close() diff --git a/static/js/game.js b/static/js/game.js index 799a90a..d9c84db 100644 --- a/static/js/game.js +++ b/static/js/game.js @@ -4,6 +4,7 @@ let gameState = null; let timerInterval = null; let selectedCells = []; + let feedbackTimeout = null; // DOM refs const setupDiv = document.getElementById("game-setup"); @@ -11,12 +12,13 @@ const categorySelect = document.getElementById("category-select"); const boardSizeInput = document.getElementById("board-size"); const sizeDisplay = document.getElementById("size-display"); + const wordCountInput = document.getElementById("word-count"); + const wordCountDisplay = document.getElementById("word-count-display"); const newGameBtn = document.getElementById("new-game-btn"); const gridTable = document.getElementById("grid-table"); const wordList = document.getElementById("word-list"); const wordInput = document.getElementById("word-input"); const guessBtn = document.getElementById("guess-btn"); - const submitSelectionBtn = document.getElementById("submit-selection-btn"); const clearSelectionBtn = document.getElementById("clear-selection-btn"); const guessFeedback = document.getElementById("guess-feedback"); const timerEl = document.getElementById("timer"); @@ -27,32 +29,50 @@ // Load categories async function loadCategories() { - const resp = await fetch("/api/categories"); - const categories = await resp.json(); - categorySelect.innerHTML = ""; - categories.forEach(function (cat) { - const opt = document.createElement("option"); - opt.value = cat.id; - opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1); - categorySelect.appendChild(opt); - }); + try { + const resp = await fetch("/api/categories"); + if (!resp.ok) throw new Error("Server error"); + const categories = await resp.json(); + categorySelect.innerHTML = ""; + categories.forEach(function (cat) { + const opt = document.createElement("option"); + opt.value = cat.id; + opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1); + categorySelect.appendChild(opt); + }); + } catch (err) { + showFeedback("Failed to load categories.", "text-danger"); + } } boardSizeInput.addEventListener("input", function () { sizeDisplay.textContent = this.value; }); + wordCountInput.addEventListener("input", function () { + wordCountDisplay.textContent = this.value; + }); + // New game newGameBtn.addEventListener("click", async function () { - const categoryId = parseInt(categorySelect.value); - const boardSize = parseInt(boardSizeInput.value); - const resp = await fetch("/api/game/new", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ category_id: categoryId, board_size: boardSize }), - }); - gameState = await resp.json(); - showBoard(); + try { + const categoryId = parseInt(categorySelect.value); + const boardSize = parseInt(boardSizeInput.value); + const wordCount = parseInt(wordCountInput.value); + const resp = await fetch("/api/game/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ category_id: categoryId, board_size: boardSize, word_count: wordCount }), + }); + if (!resp.ok) { + const errData = await resp.json().catch(function () { return {}; }); + throw new Error(errData.error || "Failed to create game"); + } + gameState = await resp.json(); + showBoard(); + } catch (err) { + showFeedback(err.message || "Failed to create game.", "text-danger"); + } }); function showBoard() { @@ -130,63 +150,85 @@ const word = wordInput.value.trim(); if (!word) return; wordInput.value = ""; - await sendGuess({ word: word }); + guessBtn.disabled = true; + try { + await sendGuess({ word: word }); + } finally { + guessBtn.disabled = false; + } } - // Click selection + // Click selection — two-click: first letter, then last letter function onCellClick(row, col, td) { if (gameState.status === "completed") return; - // Check if already selected — deselect - const idx = selectedCells.findIndex( - function (c) { return c.row === row && c.col === col; } - ); - if (idx !== -1) { - selectedCells.splice(idx, 1); - td.classList.remove("cell-selected"); + // No start cell yet — this is the first click + if (selectedCells.length === 0) { + selectedCells.push({ row: row, col: col }); + td.classList.add("cell-selected"); updateSelectionButtons(); return; } - // Validate straight line - if (selectedCells.length >= 2) { - if (!isValidExtension(row, col)) { - showFeedback("Selection must form a straight line.", "text-warning"); - return; - } + // Clicking the same cell — deselect + if (selectedCells[0].row === row && selectedCells[0].col === col) { + clearSelection(); + return; } - selectedCells.push({ row: row, col: col }); - td.classList.add("cell-selected"); - updateSelectionButtons(); - } + // Second click — compute line from start to end + var startR = selectedCells[0].row; + var startC = selectedCells[0].col; + var dr = row - startR; + var dc = col - startC; - function isValidExtension(row, col) { - if (selectedCells.length < 2) return true; - const dr = selectedCells[1].row - selectedCells[0].row; - const dc = selectedCells[1].col - selectedCells[0].col; - const len = selectedCells.length; - const expectedRow = selectedCells[0].row + dr * len; - const expectedCol = selectedCells[0].col + dc * len; - return row === expectedRow && col === expectedCol; + // Validate straight line + var absDr = Math.abs(dr); + var absDc = Math.abs(dc); + if (absDr !== absDc && absDr !== 0 && absDc !== 0) { + showFeedback("Selection must form a straight line.", "text-warning"); + clearSelection(); + return; + } + + var steps = Math.max(absDr, absDc); + if (steps === 0) return; + + // Unit direction + var stepR = dr === 0 ? 0 : dr / absDr; + var stepC = dc === 0 ? 0 : dc / absDc; + + // Build full cell list from start to end + var cells = []; + for (var i = 0; i <= steps; i++) { + cells.push({ row: startR + stepR * i, col: startC + stepC * i }); + } + + // Highlight all cells + cells.forEach(function (c) { + var cell = gridTable.querySelector( + 'td[data-row="' + c.row + '"][data-col="' + c.col + '"]' + ); + if (cell) cell.classList.add("cell-selected"); + }); + + // Auto-submit + selectedCells = cells; + guessBtn.disabled = true; + sendGuess({ cells: cells }).then(function () { + guessBtn.disabled = false; + clearSelection(); + }); } function updateSelectionButtons() { if (selectedCells.length > 0) { - submitSelectionBtn.classList.remove("d-none"); clearSelectionBtn.classList.remove("d-none"); } else { - submitSelectionBtn.classList.add("d-none"); clearSelectionBtn.classList.add("d-none"); } } - submitSelectionBtn.addEventListener("click", async function () { - if (selectedCells.length === 0) return; - await sendGuess({ cells: selectedCells }); - clearSelection(); - }); - clearSelectionBtn.addEventListener("click", clearSelection); function clearSelection() { @@ -199,25 +241,30 @@ // Send guess to server async function sendGuess(payload) { - const resp = await fetch("/api/game/" + gameState.id + "/guess", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const result = await resp.json(); - gameState.words = result.words; - gameState.status = result.status; + try { + const resp = await fetch("/api/game/" + gameState.id + "/guess", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw new Error("Server error"); + const result = await resp.json(); + gameState.words = result.words; + gameState.status = result.status; - if (result.correct) { - highlightFoundCells(result.cells); - renderWordList(); - showFeedback("Found: " + result.word + "!", "text-success"); - } else { - showFeedback("Not a match.", "text-danger"); - } + if (result.correct) { + highlightFoundCells(result.cells); + renderWordList(); + showFeedback("Found: " + result.word + "!", "text-success"); + } else { + showFeedback("Not a match.", "text-danger"); + } - if (result.status === "completed") { - onGameComplete(result.completed_at); + if (result.status === "completed") { + onGameComplete(result.completed_at); + } + } catch (err) { + showFeedback("Request failed. Try again.", "text-danger"); } } @@ -231,11 +278,13 @@ } function showFeedback(msg, cls) { + if (feedbackTimeout) clearTimeout(feedbackTimeout); guessFeedback.textContent = msg; guessFeedback.className = "mt-2 " + cls; - setTimeout(function () { + feedbackTimeout = setTimeout(function () { guessFeedback.textContent = ""; guessFeedback.className = "mt-2"; + feedbackTimeout = null; }, 2000); } diff --git a/templates/about.html b/templates/about.html index 356a7bf..0449d95 100644 --- a/templates/about.html +++ b/templates/about.html @@ -1,8 +1,45 @@ {% extends "base.html" %} +{% block active_page %}{% set active_page = "about" %}{% end %} + {% block title %}Word Search — About{% end %} {% block content %} -

About

-

About page coming soon.

+

ℹ️ About

+ +
+

🧩 What is Word Search?

+

+ Word Search is a browser-based puzzle game where you find hidden words in a + grid of letters. Words can be placed horizontally, vertically, or + diagonally — forwards or backwards. The backend generates puzzles, + validates your selections, and tracks your progress in real time. +

+
+ +
+

🛠️ Built With

+ +
+ +
+

✍️ Authors

+ +
+ +
+

📂 Source Code

+

+ The source code for this project will be available on GitHub. + Link coming soon. +

+
{% end %} diff --git a/templates/base.html b/templates/base.html index 91e23b7..ad3a0d6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,14 +6,20 @@ {% block title %}Word Search{% end %} + + {% block active_page %}{% end %} +