"""REST API handlers for game operations.""" import json 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 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 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])) 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). """ def post(self): session = self.get_session() try: data = json.loads(self.request.body) category_id = data["category_id"] board_size = data.get("board_size", 10) board_size = max(8, min(20, board_size)) factory = BoardFactory(session) game, words = factory.create( category_id=category_id, board_size=board_size, ) session.add(game) for w in words: session.add(w) session.commit() self.write(json.dumps(self._game_to_dict(game))) finally: session.close() def _game_to_dict(self, game): return { "id": game.id, "board_size": game.board_size, "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, "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 ), } ) ) 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): 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 data = json.loads(self.request.body) 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)) finally: session.close() def _handle_text_guess(self, session, game, guess_text): 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): 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, "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, "completed_at": None, }