fix: harden API, add rate limiting, disable debug by default

- Add input validation and error handling across all API endpoints
  (malformed JSON, missing fields, invalid types return proper 4xx)
- Add per-IP rate limiting (10/min new games, 60/min other requests)
- Add session rollback on unhandled exceptions
- Default debug=False, add --debug CLI flag to opt in
- Frontend: add fetch error handling, fix feedback timeout stacking,
  disable buttons during requests, allow clicking found cells for
  overlapping word selection
- Expand seed data to 7 categories with 25-35 words each

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathew Sir Guest the best
2026-05-07 00:59:47 -06:00
co-authored by Claude Opus 4.6
parent 4d681a92ca
commit cd1dc9200b
11 changed files with 708 additions and 110 deletions
+6 -4
View File
@@ -21,12 +21,13 @@ from handlers.api import (
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 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. """Create and configure the Tornado application.
Args: Args:
session_factory: SQLAlchemy session factory. If None, API session_factory: SQLAlchemy session factory. If None, API
endpoints that require a database will not work. endpoints that require a database will not work.
debug: Enable debug mode (auto-reload, stack traces).
Returns: Returns:
A configured ``tornado.web.Application`` instance. A configured ``tornado.web.Application`` instance.
@@ -45,18 +46,19 @@ def make_app(session_factory=None):
template_path=os.path.join(BASE_DIR, "templates"), template_path=os.path.join(BASE_DIR, "templates"),
static_path=os.path.join(BASE_DIR, "static"), static_path=os.path.join(BASE_DIR, "static"),
session_factory=session_factory, session_factory=session_factory,
debug=True, debug=debug,
) )
@click.command() @click.command()
@click.option("--port", default=8888, type=int, help="Port to listen on.") @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.""" """Start the web server."""
engine = get_engine() engine = get_engine()
init_db(engine) init_db(engine)
session_factory = get_session_factory(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) app.listen(port)
print(f"Server started at http://localhost:{port}") print(f"Server started at http://localhost:{port}")
tornado.ioloop.IOLoop.current().start() tornado.ioloop.IOLoop.current().start()
+98 -5
View File
@@ -1,6 +1,8 @@
"""REST API handlers for game operations.""" """REST API handlers for game operations."""
import collections
import json import json
import time
from datetime import datetime, timezone from datetime import datetime, timezone
import tornado.web import tornado.web
@@ -9,6 +11,27 @@ from game.factory import BoardFactory, DIRECTIONS
from models.category import Category from models.category import Category
from models.game import Game, Word 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): class BaseAPIHandler(tornado.web.RequestHandler):
"""Base handler for JSON API endpoints.""" """Base handler for JSON API endpoints."""
@@ -20,6 +43,14 @@ class BaseAPIHandler(tornado.web.RequestHandler):
Session = self.application.settings["session_factory"] Session = self.application.settings["session_factory"]
return Session() 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): def write_error(self, status_code, **kwargs):
self.write(json.dumps({"error": self._reason})) self.write(json.dumps({"error": self._reason}))
@@ -32,6 +63,9 @@ class CategoriesHandler(BaseAPIHandler):
try: try:
categories = session.query(Category).all() categories = session.query(Category).all()
self.write(json.dumps([{"id": c.id, "name": c.name} for c in categories])) self.write(json.dumps([{"id": c.id, "name": c.name} for c in categories]))
except Exception:
session.rollback()
raise
finally: finally:
session.close() session.close()
@@ -39,28 +73,72 @@ class CategoriesHandler(BaseAPIHandler):
class NewGameHandler(BaseAPIHandler): class NewGameHandler(BaseAPIHandler):
"""``POST /api/game/new`` -- create a new game. """``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): def post(self):
if not self.check_rate_limit("new_game"):
return
session = self.get_session() session = self.get_session()
try: 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"] 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)) 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) factory = BoardFactory(session)
game, words = factory.create( game, words = factory.create(
category_id=category_id, category_id=category_id,
board_size=board_size, 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) session.add(game)
for w in words: for w in words:
session.add(w) session.add(w)
session.commit() session.commit()
self.write(json.dumps(self._game_to_dict(game))) self.write(json.dumps(self._game_to_dict(game)))
except Exception:
session.rollback()
raise
finally: finally:
session.close() session.close()
@@ -108,6 +186,9 @@ class GameStateHandler(BaseAPIHandler):
} }
) )
) )
except Exception:
session.rollback()
raise
finally: finally:
session.close() session.close()
@@ -120,6 +201,8 @@ class GuessHandler(BaseAPIHandler):
""" """
def post(self, game_id): def post(self, game_id):
if not self.check_rate_limit():
return
session = self.get_session() session = self.get_session()
try: try:
game = session.query(Game).filter_by(id=game_id).first() 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"})) self.write(json.dumps({"error": "Game not found"}))
return 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: if "word" in data:
result = self._handle_text_guess(session, game, data["word"]) result = self._handle_text_guess(session, game, data["word"])
@@ -140,10 +228,15 @@ class GuessHandler(BaseAPIHandler):
return return
self.write(json.dumps(result)) self.write(json.dumps(result))
except Exception:
session.rollback()
raise
finally: finally:
session.close() session.close()
def _handle_text_guess(self, session, game, guess_text): 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() guess_text = guess_text.upper().strip()
word = ( word = (
session.query(Word) session.query(Word)
@@ -159,7 +252,7 @@ class GuessHandler(BaseAPIHandler):
grid = game.grid grid = game.grid
try: try:
letters = "".join(grid[c["row"]][c["col"]] for c in cells) letters = "".join(grid[c["row"]][c["col"]] for c in cells)
except (IndexError, KeyError): except (IndexError, KeyError, TypeError):
return self._miss_response(game, session) return self._miss_response(game, session)
word = ( word = (
+169 -7
View File
@@ -23,6 +23,21 @@ SEED_DATA = {
"CRANE", "CRANE",
"MOOSE", "MOOSE",
"RAVEN", "RAVEN",
"JAGUAR",
"PARROT",
"TURTLE",
"SALMON",
"FERRET",
"COYOTE",
"OSPREY",
"LIZARD",
"BADGER",
"PELICAN",
"MONKEY",
"RABBIT",
"IGUANA",
"CONDOR",
"JACKAL",
], ],
"colors": [ "colors": [
"CRIMSON", "CRIMSON",
@@ -45,6 +60,21 @@ SEED_DATA = {
"JADE", "JADE",
"CYAN", "CYAN",
"MAUVE", "MAUVE",
"COBALT",
"COPPER",
"EBONY",
"PEACH",
"CREAM",
"LEMON",
"ORCHID",
"SALMON",
"SIENNA",
"KHAKI",
"LILAC",
"PEARL",
"CHARCOAL",
"MAGENTA",
"EMERALD",
], ],
"food": [ "food": [
"PIZZA", "PIZZA",
@@ -67,6 +97,129 @@ SEED_DATA = {
"CURRY", "CURRY",
"BAGEL", "BAGEL",
"FUDGE", "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(): for cat_name, words in SEED_DATA.items():
existing = session.query(Category).filter_by(name=cat_name).first() existing = session.query(Category).filter_by(name=cat_name).first()
if existing: if existing:
print(f"Category '{cat_name}' already exists, skipping.") cat = existing
continue else:
cat = Category(name=cat_name) cat = Category(name=cat_name)
session.add(cat) session.add(cat)
session.flush() session.flush()
added = 0
for w in words: for w in words:
session.add(CategoryWord(category_id=cat.id, word=w)) exists = (
print(f"Seeded category '{cat_name}' with {len(words)} words.") 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.commit()
session.close() session.close()
+121 -72
View File
@@ -4,6 +4,7 @@
let gameState = null; let gameState = null;
let timerInterval = null; let timerInterval = null;
let selectedCells = []; let selectedCells = [];
let feedbackTimeout = null;
// DOM refs // DOM refs
const setupDiv = document.getElementById("game-setup"); const setupDiv = document.getElementById("game-setup");
@@ -11,12 +12,13 @@
const categorySelect = document.getElementById("category-select"); const categorySelect = document.getElementById("category-select");
const boardSizeInput = document.getElementById("board-size"); const boardSizeInput = document.getElementById("board-size");
const sizeDisplay = document.getElementById("size-display"); 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 newGameBtn = document.getElementById("new-game-btn");
const gridTable = document.getElementById("grid-table"); const gridTable = document.getElementById("grid-table");
const wordList = document.getElementById("word-list"); const wordList = document.getElementById("word-list");
const wordInput = document.getElementById("word-input"); const wordInput = document.getElementById("word-input");
const guessBtn = document.getElementById("guess-btn"); const guessBtn = document.getElementById("guess-btn");
const submitSelectionBtn = document.getElementById("submit-selection-btn");
const clearSelectionBtn = document.getElementById("clear-selection-btn"); const clearSelectionBtn = document.getElementById("clear-selection-btn");
const guessFeedback = document.getElementById("guess-feedback"); const guessFeedback = document.getElementById("guess-feedback");
const timerEl = document.getElementById("timer"); const timerEl = document.getElementById("timer");
@@ -27,32 +29,50 @@
// Load categories // Load categories
async function loadCategories() { async function loadCategories() {
const resp = await fetch("/api/categories"); try {
const categories = await resp.json(); const resp = await fetch("/api/categories");
categorySelect.innerHTML = ""; if (!resp.ok) throw new Error("Server error");
categories.forEach(function (cat) { const categories = await resp.json();
const opt = document.createElement("option"); categorySelect.innerHTML = "";
opt.value = cat.id; categories.forEach(function (cat) {
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1); const opt = document.createElement("option");
categorySelect.appendChild(opt); 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 () { boardSizeInput.addEventListener("input", function () {
sizeDisplay.textContent = this.value; sizeDisplay.textContent = this.value;
}); });
wordCountInput.addEventListener("input", function () {
wordCountDisplay.textContent = this.value;
});
// New game // New game
newGameBtn.addEventListener("click", async function () { newGameBtn.addEventListener("click", async function () {
const categoryId = parseInt(categorySelect.value); try {
const boardSize = parseInt(boardSizeInput.value); const categoryId = parseInt(categorySelect.value);
const resp = await fetch("/api/game/new", { const boardSize = parseInt(boardSizeInput.value);
method: "POST", const wordCount = parseInt(wordCountInput.value);
headers: { "Content-Type": "application/json" }, const resp = await fetch("/api/game/new", {
body: JSON.stringify({ category_id: categoryId, board_size: boardSize }), method: "POST",
}); headers: { "Content-Type": "application/json" },
gameState = await resp.json(); body: JSON.stringify({ category_id: categoryId, board_size: boardSize, word_count: wordCount }),
showBoard(); });
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() { function showBoard() {
@@ -130,63 +150,85 @@
const word = wordInput.value.trim(); const word = wordInput.value.trim();
if (!word) return; if (!word) return;
wordInput.value = ""; 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) { function onCellClick(row, col, td) {
if (gameState.status === "completed") return; if (gameState.status === "completed") return;
// Check if already selected — deselect // No start cell yet — this is the first click
const idx = selectedCells.findIndex( if (selectedCells.length === 0) {
function (c) { return c.row === row && c.col === col; } selectedCells.push({ row: row, col: col });
); td.classList.add("cell-selected");
if (idx !== -1) {
selectedCells.splice(idx, 1);
td.classList.remove("cell-selected");
updateSelectionButtons(); updateSelectionButtons();
return; return;
} }
// Validate straight line // Clicking the same cell — deselect
if (selectedCells.length >= 2) { if (selectedCells[0].row === row && selectedCells[0].col === col) {
if (!isValidExtension(row, col)) { clearSelection();
showFeedback("Selection must form a straight line.", "text-warning"); return;
return;
}
} }
selectedCells.push({ row: row, col: col }); // Second click — compute line from start to end
td.classList.add("cell-selected"); var startR = selectedCells[0].row;
updateSelectionButtons(); var startC = selectedCells[0].col;
} var dr = row - startR;
var dc = col - startC;
function isValidExtension(row, col) { // Validate straight line
if (selectedCells.length < 2) return true; var absDr = Math.abs(dr);
const dr = selectedCells[1].row - selectedCells[0].row; var absDc = Math.abs(dc);
const dc = selectedCells[1].col - selectedCells[0].col; if (absDr !== absDc && absDr !== 0 && absDc !== 0) {
const len = selectedCells.length; showFeedback("Selection must form a straight line.", "text-warning");
const expectedRow = selectedCells[0].row + dr * len; clearSelection();
const expectedCol = selectedCells[0].col + dc * len; return;
return row === expectedRow && col === expectedCol; }
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() { function updateSelectionButtons() {
if (selectedCells.length > 0) { if (selectedCells.length > 0) {
submitSelectionBtn.classList.remove("d-none");
clearSelectionBtn.classList.remove("d-none"); clearSelectionBtn.classList.remove("d-none");
} else { } else {
submitSelectionBtn.classList.add("d-none");
clearSelectionBtn.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); clearSelectionBtn.addEventListener("click", clearSelection);
function clearSelection() { function clearSelection() {
@@ -199,25 +241,30 @@
// Send guess to server // Send guess to server
async function sendGuess(payload) { async function sendGuess(payload) {
const resp = await fetch("/api/game/" + gameState.id + "/guess", { try {
method: "POST", const resp = await fetch("/api/game/" + gameState.id + "/guess", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify(payload), headers: { "Content-Type": "application/json" },
}); body: JSON.stringify(payload),
const result = await resp.json(); });
gameState.words = result.words; if (!resp.ok) throw new Error("Server error");
gameState.status = result.status; const result = await resp.json();
gameState.words = result.words;
gameState.status = result.status;
if (result.correct) { if (result.correct) {
highlightFoundCells(result.cells); highlightFoundCells(result.cells);
renderWordList(); renderWordList();
showFeedback("Found: " + result.word + "!", "text-success"); showFeedback("Found: " + result.word + "!", "text-success");
} else { } else {
showFeedback("Not a match.", "text-danger"); showFeedback("Not a match.", "text-danger");
} }
if (result.status === "completed") { if (result.status === "completed") {
onGameComplete(result.completed_at); onGameComplete(result.completed_at);
}
} catch (err) {
showFeedback("Request failed. Try again.", "text-danger");
} }
} }
@@ -231,11 +278,13 @@
} }
function showFeedback(msg, cls) { function showFeedback(msg, cls) {
if (feedbackTimeout) clearTimeout(feedbackTimeout);
guessFeedback.textContent = msg; guessFeedback.textContent = msg;
guessFeedback.className = "mt-2 " + cls; guessFeedback.className = "mt-2 " + cls;
setTimeout(function () { feedbackTimeout = setTimeout(function () {
guessFeedback.textContent = ""; guessFeedback.textContent = "";
guessFeedback.className = "mt-2"; guessFeedback.className = "mt-2";
feedbackTimeout = null;
}, 2000); }, 2000);
} }
+39 -2
View File
@@ -1,8 +1,45 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block active_page %}{% set active_page = "about" %}{% end %}
{% block title %}Word Search — About{% end %} {% block title %}Word Search — About{% end %}
{% block content %} {% block content %}
<h2>About</h2> <h2 class="mb-4">About</h2>
<p>About page coming soon.</p>
<div class="content-section">
<h3>🧩 What is Word Search?</h3>
<p>
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.
</p>
</div>
<div class="content-section">
<h3>🛠️ Built With</h3>
<ul>
<li><strong>Python</strong> — core language</li>
<li><strong>Tornado</strong> — async web framework</li>
<li><strong>Bootstrap 5</strong> — responsive UI</li>
<li><strong>Bootstrap Icons</strong> — interface icons</li>
</ul>
</div>
<div class="content-section">
<h3>✍️ Authors</h3>
<ul>
<li><strong>Mathew Guest</strong> — creator and developer</li>
<li><strong>🤖 Claude</strong> (Anthropic) — AI co-author</li>
</ul>
</div>
<div class="content-section">
<h3>📂 Source Code</h3>
<p>
The source code for this project will be available on GitHub.
<em>Link coming soon.</em>
</p>
</div>
{% end %} {% end %}
+30 -6
View File
@@ -6,14 +6,20 @@
<title>{% block title %}Word Search{% end %}</title> <title>{% block title %}Word Search{% end %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1WR6zNiELmFp5mGg7gCjSP54e0TpoS" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"> crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet">
<link rel="stylesheet" href="{{ static_url('css/theme.css') }}"> <link rel="stylesheet" href="{{ static_url('css/theme.css') }}">
</head> </head>
<body> <body>
{% block active_page %}{% end %}
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<div class="container"> <div class="container">
<a class="navbar-brand" href="/">Word Search</a> <a class="navbar-brand" href="/">
<i class="bi bi-search"></i> Word Search
</a>
<button class="navbar-toggler" type="button" <button class="navbar-toggler" type="button"
data-bs-toggle="collapse" data-bs-target="#navbarNav"> data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
@@ -21,16 +27,28 @@
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/">Home</a> <a class="nav-link {% if active_page == 'home' %}active{% end %}"
href="/">
<i class="bi bi-house-door"></i> Home
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/game">Play</a> <a class="nav-link {% if active_page == 'play' %}active{% end %}"
href="/game">
<i class="bi bi-controller"></i> Play
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/howtoplay">How to Play</a> <a class="nav-link {% if active_page == 'howtoplay' %}active{% end %}"
href="/howtoplay">
<i class="bi bi-question-circle"></i> How to Play
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/about">About</a> <a class="nav-link {% if active_page == 'about' %}active{% end %}"
href="/about">
<i class="bi bi-info-circle"></i> About
</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -41,6 +59,12 @@
{% block content %}{% end %} {% block content %}{% end %}
</main> </main>
<footer class="site-footer">
<div class="container">
&copy; 2026 Word Search
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
+10 -9
View File
@@ -11,18 +11,25 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="row g-3 align-items-end"> <div class="row g-3 align-items-end">
<div class="col-md-4"> <div class="col-md-3">
<label for="category-select" class="form-label">Category</label> <label for="category-select" class="form-label">Category</label>
<select id="category-select" class="form-select"></select> <select id="category-select" class="form-select"></select>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label for="board-size" class="form-label"> <label for="board-size" class="form-label">
Board Size: <span id="size-display">10</span> Board Size: <span id="size-display">10</span>
</label> </label>
<input type="range" id="board-size" class="form-range" <input type="range" id="board-size" class="form-range"
min="8" max="20" value="10"> min="8" max="20" value="10">
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label for="word-count" class="form-label">
Word Count: <span id="word-count-display">8</span>
</label>
<input type="range" id="word-count" class="form-range"
min="3" max="30" value="8">
</div>
<div class="col-md-3">
<button id="new-game-btn" class="btn btn-accent btn-lg w-100"> <button id="new-game-btn" class="btn btn-accent btn-lg w-100">
Start Game Start Game
</button> </button>
@@ -74,12 +81,6 @@
Submit Submit
</button> </button>
</div> </div>
<div class="col-auto">
<button id="submit-selection-btn"
class="btn btn-outline-light d-none">
Submit Selection
</button>
</div>
<div class="col-auto"> <div class="col-auto">
<button id="clear-selection-btn" <button id="clear-selection-btn"
class="btn btn-outline-secondary d-none"> class="btn btn-outline-secondary d-none">
+74 -2
View File
@@ -1,8 +1,80 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block active_page %}{% set active_page = "howtoplay" %}{% end %}
{% block title %}Word Search — How to Play{% end %} {% block title %}Word Search — How to Play{% end %}
{% block content %} {% block content %}
<h2>How to Play</h2> <h2 class="mb-4">📖 How to Play</h2>
<p>Instructions coming soon.</p>
<div class="content-section">
<h3 class="mb-4">Step-by-Step Instructions</h3>
<div class="step-item">
<span class="step-number">1</span>
<p>
📋 <strong>Check the word list</strong> — Look at the list of words
displayed alongside the grid. These are the words you need to find.
</p>
</div>
<div class="step-item">
<span class="step-number">2</span>
<p>
🔍 <strong>Scan for the first letter</strong> — Pick a word from the
list and scan the grid for its first letter. This gives you a starting
point.
</p>
</div>
<div class="step-item">
<span class="step-number">3</span>
<p>
↗️ <strong>Look in all directions</strong> — Words can run
horizontally, vertically, or diagonally — and they can go forward or
backward. Check all eight directions from your starting letter.
</p>
</div>
<div class="step-item">
<span class="step-number">4</span>
<p>
👆 <strong>Select the word</strong> — Click or tap the first letter of
the word, then click the last letter to highlight the entire word.
</p>
</div>
<div class="step-item">
<span class="step-number">5</span>
<p>
<strong>Words get crossed off</strong> — When you correctly find a
word, it gets crossed off the list so you can track your progress.
</p>
</div>
<div class="step-item">
<span class="step-number">6</span>
<p>
🏆 <strong>Find them all to win!</strong> — The puzzle is complete when
every word on the list has been found. Try to beat your best time!
</p>
</div>
</div>
<h3 class="mb-3">💡 Tips</h3>
<div class="tip-card">
<strong>Start with uncommon letters</strong> — Letters like Q, X, Z, and J
appear less often in the grid, making words with those letters easier to spot.
</div>
<div class="tip-card">
<strong>Scan systematically</strong> — Work through the grid row by row or
column by column rather than jumping around randomly.
</div>
<div class="tip-card">
<strong>Look for word shapes</strong> — Sometimes you can spot diagonal words
by the distinctive slanted pattern they make in the grid.
</div>
{% end %} {% end %}
+6 -3
View File
@@ -1,14 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block active_page %}{% set active_page = "home" %}{% end %}
{% block title %}Word Search — Home{% end %} {% block title %}Word Search — Home{% end %}
{% block content %} {% block content %}
<div class="text-center py-5"> <header class="hero">
<div class="hero-emoji">🧩</div>
<h1 class="display-4 fw-bold mb-3">Word Search</h1> <h1 class="display-4 fw-bold mb-3">Word Search</h1>
<p class="lead mb-4"> <p class="lead mb-4">
Find hidden words in a grid of letters. Words can appear horizontally, Find hidden words in a grid of letters. Words can appear horizontally,
vertically, or diagonally. How fast can you find them all? vertically, or diagonally. How fast can you find them all?
</p> </p>
<a href="/game" class="btn btn-accent btn-lg px-5">Start Game</a> <a href="/game" class="btn btn-accent btn-lg px-5">🎮 Start Game</a>
</div> </header>
{% end %} {% end %}
+7
View File
@@ -23,6 +23,13 @@ def db_session(db_engine):
session.close() session.close()
@pytest.fixture(autouse=True)
def clear_rate_limits():
from handlers.api import _rate_buckets
_rate_buckets.clear()
@pytest.fixture @pytest.fixture
def app(db_engine): def app(db_engine):
from app import make_app from app import make_app
+148
View File
@@ -110,3 +110,151 @@ async def test_text_guess_incorrect(http_server_client, seeded_db):
assert resp.code == 200 assert resp.code == 200
data = json.loads(resp.body) data = json.loads(resp.body)
assert data["correct"] is False assert data["correct"] is False
async def test_new_game_invalid_json(http_server_client, seeded_db):
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body="not json",
headers={"Content-Type": "application/json"},
raise_error=False,
)
assert resp.code == 400
data = json.loads(resp.body)
assert "error" in data
async def test_new_game_missing_category_id(http_server_client, seeded_db):
body = json.dumps({"board_size": 10})
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
raise_error=False,
)
assert resp.code == 400
data = json.loads(resp.body)
assert "error" in data
async def test_new_game_string_board_size(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id, "board_size": "big"})
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
raise_error=False,
)
assert resp.code == 400
data = json.loads(resp.body)
assert "error" in data
async def test_new_game_custom_word_count(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id, "board_size": 10, "word_count": 4})
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert len(data["words"]) <= 4
async def test_new_game_string_word_count(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id, "word_count": "many"})
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
raise_error=False,
)
assert resp.code == 400
data = json.loads(resp.body)
assert "error" in data
async def test_new_game_nonexistent_category(http_server_client, seeded_db):
body = json.dumps({"category_id": 99999})
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
raise_error=False,
)
assert resp.code == 404
data = json.loads(resp.body)
assert "error" in data
async def test_guess_invalid_json(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id})
create_resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
game_id = json.loads(create_resp.body)["id"]
resp = await http_server_client.fetch(
f"/api/game/{game_id}/guess",
method="POST",
body="not json",
headers={"Content-Type": "application/json"},
raise_error=False,
)
assert resp.code == 400
data = json.loads(resp.body)
assert "error" in data
async def test_guess_non_string_word(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id})
create_resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
game_id = json.loads(create_resp.body)["id"]
guess_body = json.dumps({"word": 12345})
resp = await http_server_client.fetch(
f"/api/game/{game_id}/guess",
method="POST",
body=guess_body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert data["correct"] is False
async def test_guess_cells_with_null_element(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id})
create_resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
game_id = json.loads(create_resp.body)["id"]
guess_body = json.dumps({"cells": [None, {"row": 0, "col": 0}]})
resp = await http_server_client.fetch(
f"/api/game/{game_id}/guess",
method="POST",
body=guess_body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert data["correct"] is False