Files
wordsearch/handlers/api.py
T
Mathew Sir Guest the bestandClaude Opus 4.6 9319f08875 feat: expand to 1014 words, add title-case display and 2 new themes
- Move word data from hardcoded seed.py to data/words.json
- Expand from 210 words across 7 categories to 1014 words across 14
  categories (animals, food, colors, sports, space, nature, music,
  science, countries, occupations, ocean, weather, mythology, technology)
- Word list sidebar now shows title case (Tiger) while grid stays
  uppercase; text input remains case-insensitive
- Add Ocean theme (light, blue accents) and Forest theme (dark, amber
  accents on deep green)
- Replace hardcoded rgba values with --coral-bg CSS variable so
  selection highlighting adapts per theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 02:43:43 -06:00

421 lines
13 KiB
Python

"""REST API handlers for game operations."""
import collections
import json
import re
import time
from datetime import datetime, timezone
import tornado.web
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
_PLAYER_NAME_RE = re.compile(r"^[a-zA-Z0-9 ]+$")
MAX_PLAYER_NAME_LEN = 20
def _sanitize_player_name(raw):
"""Validate and sanitize a player name. Returns name or None (invalid)."""
if not isinstance(raw, str):
return "Anonymous"
name = raw.strip()
if not name:
return "Anonymous"
if len(name) > MAX_PLAYER_NAME_LEN:
name = name[:MAX_PLAYER_NAME_LEN]
if not _PLAYER_NAME_RE.match(name):
return None
return name
class BaseAPIHandler(tornado.web.RequestHandler):
"""Base handler for JSON API endpoints."""
def set_default_headers(self):
self.set_header("Content-Type", "application/json")
def get_session(self):
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}))
class CategoriesHandler(BaseAPIHandler):
"""``GET /api/categories`` -- list all word categories."""
def get(self):
session = self.get_session()
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()
class NewGameHandler(BaseAPIHandler):
"""``POST /api/game/new`` -- create a new game.
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"]
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))
player_name = _sanitize_player_name(data.get("player_name", ""))
if player_name is None:
self.set_status(400)
self.write(
json.dumps(
{
"error": "Player name may only contain"
" letters, numbers, and spaces"
}
)
)
return
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
game.player_name = player_name
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()
def _game_to_dict(self, game):
return {
"id": game.id,
"board_size": game.board_size,
"player_name": game.player_name,
"category": game.category.name,
"grid": game.grid,
"words": [{"text": w.text, "found": w.found} for w in game.words],
"started_at": game.started_at.isoformat() + "Z",
"status": game.status,
"completed_at": None,
}
class GameStateHandler(BaseAPIHandler):
"""``GET /api/game/<id>`` -- retrieve current game state."""
def get(self, game_id):
session = self.get_session()
try:
game = session.query(Game).filter_by(id=game_id).first()
if not game:
self.set_status(404)
self.write(json.dumps({"error": "Game not found"}))
return
self.write(
json.dumps(
{
"id": game.id,
"board_size": game.board_size,
"player_name": game.player_name,
"category": game.category.name,
"grid": game.grid,
"words": [
{"text": w.text, "found": w.found} for w in game.words
],
"started_at": game.started_at.isoformat() + "Z",
"status": game.status,
"completed_at": (
game.completed_at.isoformat() + "Z"
if game.completed_at
else None
),
}
)
)
except Exception:
session.rollback()
raise
finally:
session.close()
class GuessHandler(BaseAPIHandler):
"""``POST /api/game/<id>/guess`` -- submit a word guess.
Accepts either ``{"word": "..."}`` for text input or
``{"cells": [{"row": N, "col": N}, ...]}`` for click selection.
"""
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()
if not game:
self.set_status(404)
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"])
elif "cells" in data:
result = self._handle_click_guess(session, game, data["cells"])
else:
self.set_status(400)
self.write(json.dumps({"error": "Provide 'word' or 'cells'"}))
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)
.filter_by(game_id=game.id, text=guess_text, found=False)
.first()
)
if not word:
return self._miss_response(game, session)
return self._mark_found(session, game, word)
def _handle_click_guess(self, session, game, cells):
grid = game.grid
try:
letters = "".join(grid[c["row"]][c["col"]] for c in cells)
except (IndexError, KeyError, TypeError):
return self._miss_response(game, session)
word = (
session.query(Word)
.filter_by(game_id=game.id, text=letters, found=False)
.first()
)
if not word:
return self._miss_response(game, session)
dr, dc = DIRECTIONS[word.direction]
expected_cells = [
{"row": word.start_row + i * dr, "col": word.start_col + i * dc}
for i in range(len(word.text))
]
if cells != expected_cells:
return self._miss_response(game, session)
return self._mark_found(session, game, word)
def _mark_found(self, session, game, word):
word.found = True
word.found_at = datetime.now(timezone.utc)
unfound = session.query(Word).filter_by(game_id=game.id, found=False).count()
if unfound == 0:
game.status = "completed"
game.completed_at = datetime.now(timezone.utc)
session.commit()
dr, dc = DIRECTIONS[word.direction]
cells = [
{"row": word.start_row + i * dr, "col": word.start_col + i * dc}
for i in range(len(word.text))
]
return {
"correct": True,
"word": word.text,
"cells": cells,
"words": [{"text": w.text, "found": w.found} for w in game.words],
"status": game.status,
"player_name": game.player_name,
"completed_at": (
game.completed_at.isoformat() + "Z" if game.completed_at else None
),
}
def _miss_response(self, game, session):
return {
"correct": False,
"word": None,
"cells": [],
"words": [{"text": w.text, "found": w.found} for w in game.words],
"status": game.status,
"player_name": game.player_name,
"completed_at": None,
}
class ScoreboardAPIHandler(BaseAPIHandler):
"""``GET /api/scoreboard`` -- leaderboards and popular categories."""
def get(self):
session = self.get_session()
try:
categories = session.query(Category).all()
leaderboards = {}
for cat in categories:
completed = (
session.query(Game)
.filter(
Game.category_id == cat.id,
Game.status == "completed",
Game.completed_at.isnot(None),
)
.all()
)
completed.sort(
key=lambda g: (g.completed_at - g.started_at).total_seconds()
)
top = completed[:10]
if top:
leaderboards[cat.name] = [
{
"player_name": g.player_name,
"time_seconds": (
g.completed_at - g.started_at
).total_seconds(),
"board_size": g.board_size,
"word_count": len(g.words),
"completed_at": g.completed_at.isoformat() + "Z",
}
for g in top
]
popular = []
for cat in categories:
count = (
session.query(Game)
.filter(
Game.category_id == cat.id,
Game.status == "completed",
)
.count()
)
popular.append(
{
"id": cat.id,
"name": cat.name,
"completed_games": count,
}
)
popular.sort(key=lambda x: x["completed_games"], reverse=True)
self.write(
json.dumps(
{
"leaderboards": leaderboards,
"popular_categories": popular,
}
)
)
except Exception:
session.rollback()
raise
finally:
session.close()