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:
co-authored by
Claude Opus 4.6
parent
4d681a92ca
commit
cd1dc9200b
@@ -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()
|
||||
|
||||
+96
-3
@@ -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:
|
||||
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
|
||||
|
||||
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 = (
|
||||
|
||||
@@ -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 = existing
|
||||
else:
|
||||
cat = Category(name=cat_name)
|
||||
session.add(cat)
|
||||
session.flush()
|
||||
|
||||
added = 0
|
||||
for w in 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))
|
||||
print(f"Seeded category '{cat_name}' with {len(words)} words.")
|
||||
added += 1
|
||||
print(f"Category '{cat_name}': added {added} new words.")
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
+88
-39
@@ -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,7 +29,9 @@
|
||||
|
||||
// Load categories
|
||||
async function loadCategories() {
|
||||
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) {
|
||||
@@ -36,23 +40,39 @@
|
||||
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 () {
|
||||
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 }),
|
||||
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 = "";
|
||||
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");
|
||||
updateSelectionButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate straight line
|
||||
if (selectedCells.length >= 2) {
|
||||
if (!isValidExtension(row, col)) {
|
||||
showFeedback("Selection must form a straight line.", "text-warning");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
// Clicking the same cell — deselect
|
||||
if (selectedCells[0].row === row && selectedCells[0].col === col) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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,11 +241,13 @@
|
||||
|
||||
// Send guess to server
|
||||
async function sendGuess(payload) {
|
||||
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;
|
||||
@@ -219,6 +263,9 @@
|
||||
if (result.status === "completed") {
|
||||
onGameComplete(result.completed_at);
|
||||
}
|
||||
} catch (err) {
|
||||
showFeedback("Request failed. Try again.", "text-danger");
|
||||
}
|
||||
}
|
||||
|
||||
function highlightFoundCells(cells) {
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
© 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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user