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__))
|
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()
|
||||||
|
|||||||
+96
-3
@@ -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:
|
try:
|
||||||
data = json.loads(self.request.body)
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
data = json.loads(self.request.body)
|
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 = (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
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))
|
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.commit()
|
||||||
session.close()
|
session.close()
|
||||||
|
|||||||
+88
-39
@@ -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,7 +29,9 @@
|
|||||||
|
|
||||||
// Load categories
|
// Load categories
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
const resp = await fetch("/api/categories");
|
const resp = await fetch("/api/categories");
|
||||||
|
if (!resp.ok) throw new Error("Server error");
|
||||||
const categories = await resp.json();
|
const categories = await resp.json();
|
||||||
categorySelect.innerHTML = "";
|
categorySelect.innerHTML = "";
|
||||||
categories.forEach(function (cat) {
|
categories.forEach(function (cat) {
|
||||||
@@ -36,23 +40,39 @@
|
|||||||
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1);
|
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1);
|
||||||
categorySelect.appendChild(opt);
|
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 () {
|
||||||
|
try {
|
||||||
const categoryId = parseInt(categorySelect.value);
|
const categoryId = parseInt(categorySelect.value);
|
||||||
const boardSize = parseInt(boardSizeInput.value);
|
const boardSize = parseInt(boardSizeInput.value);
|
||||||
|
const wordCount = parseInt(wordCountInput.value);
|
||||||
const resp = await fetch("/api/game/new", {
|
const resp = await fetch("/api/game/new", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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();
|
gameState = await resp.json();
|
||||||
showBoard();
|
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 = "";
|
||||||
|
guessBtn.disabled = true;
|
||||||
|
try {
|
||||||
await sendGuess({ word: word });
|
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; }
|
|
||||||
);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedCells.push({ row: row, col: col });
|
selectedCells.push({ row: row, col: col });
|
||||||
td.classList.add("cell-selected");
|
td.classList.add("cell-selected");
|
||||||
updateSelectionButtons();
|
updateSelectionButtons();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidExtension(row, col) {
|
// Clicking the same cell — deselect
|
||||||
if (selectedCells.length < 2) return true;
|
if (selectedCells[0].row === row && selectedCells[0].col === col) {
|
||||||
const dr = selectedCells[1].row - selectedCells[0].row;
|
clearSelection();
|
||||||
const dc = selectedCells[1].col - selectedCells[0].col;
|
return;
|
||||||
const len = selectedCells.length;
|
}
|
||||||
const expectedRow = selectedCells[0].row + dr * len;
|
|
||||||
const expectedCol = selectedCells[0].col + dc * len;
|
// Second click — compute line from start to end
|
||||||
return row === expectedRow && col === expectedCol;
|
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() {
|
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,11 +241,13 @@
|
|||||||
|
|
||||||
// Send guess to server
|
// Send guess to server
|
||||||
async function sendGuess(payload) {
|
async function sendGuess(payload) {
|
||||||
|
try {
|
||||||
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
|
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
if (!resp.ok) throw new Error("Server error");
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
gameState.words = result.words;
|
gameState.words = result.words;
|
||||||
gameState.status = result.status;
|
gameState.status = result.status;
|
||||||
@@ -219,6 +263,9 @@
|
|||||||
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightFoundCells(cells) {
|
function highlightFoundCells(cells) {
|
||||||
@@ -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
@@ -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
@@ -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">
|
||||||
|
© 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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user