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__))
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()
+98 -5
View File
@@ -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 = (
+169 -7
View File
@@ -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()
+121 -72
View File
@@ -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);
}
+39 -2
View File
@@ -1,8 +1,45 @@
{% extends "base.html" %}
{% block active_page %}{% set active_page = "about" %}{% end %}
{% block title %}Word Search — About{% end %}
{% block content %}
<h2>About</h2>
<p>About page coming soon.</p>
<h2 class="mb-4">About</h2>
<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 %}
+30 -6
View File
@@ -6,14 +6,20 @@
<title>{% block title %}Word Search{% end %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1WR6zNiELmFp5mGg7gCjSP54e0TpoS"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
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') }}">
</head>
<body>
{% block active_page %}{% end %}
<nav class="navbar navbar-expand-lg">
<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"
data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
@@ -21,16 +27,28 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<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 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 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 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>
</ul>
</div>
@@ -41,6 +59,12 @@
{% block content %}{% end %}
</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"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
+10 -9
View File
@@ -11,18 +11,25 @@
<div class="card">
<div class="card-body">
<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>
<select id="category-select" class="form-select"></select>
</div>
<div class="col-md-4">
<div class="col-md-3">
<label for="board-size" class="form-label">
Board Size: <span id="size-display">10</span>
</label>
<input type="range" id="board-size" class="form-range"
min="8" max="20" value="10">
</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">
Start Game
</button>
@@ -74,12 +81,6 @@
Submit
</button>
</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">
<button id="clear-selection-btn"
class="btn btn-outline-secondary d-none">
+74 -2
View File
@@ -1,8 +1,80 @@
{% extends "base.html" %}
{% block active_page %}{% set active_page = "howtoplay" %}{% end %}
{% block title %}Word Search — How to Play{% end %}
{% block content %}
<h2>How to Play</h2>
<p>Instructions coming soon.</p>
<h2 class="mb-4">📖 How to Play</h2>
<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 %}
+6 -3
View File
@@ -1,14 +1,17 @@
{% extends "base.html" %}
{% block active_page %}{% set active_page = "home" %}{% end %}
{% block title %}Word Search — Home{% end %}
{% 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>
<p class="lead mb-4">
Find hidden words in a grid of letters. Words can appear horizontally,
vertically, or diagonally. How fast can you find them all?
</p>
<a href="/game" class="btn btn-accent btn-lg px-5">Start Game</a>
</div>
<a href="/game" class="btn btn-accent btn-lg px-5">🎮 Start Game</a>
</header>
{% end %}
+7
View File
@@ -23,6 +23,13 @@ def db_session(db_engine):
session.close()
@pytest.fixture(autouse=True)
def clear_rate_limits():
from handlers.api import _rate_buckets
_rate_buckets.clear()
@pytest.fixture
def app(db_engine):
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
data = json.loads(resp.body)
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