"""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/`` -- 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//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()