diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..55465b4 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +exclude = .venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e7c871b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + + - repo: https://github.com/pycqa/flake8 + rev: 7.1.2 + hooks: + - id: flake8 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7eccd3 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# ai_demo_backend + +A backend HTTP server that powers a word search puzzle game playable in a web browser. Built with Python and Tornado. + +## What is Word Search? + +Word search is a puzzle where a grid of letters contains hidden words placed horizontally, vertically, or diagonally. Players scan the grid to find and select each hidden word from a provided list. + +This backend serves the game logic — generating puzzles, validating found words, and managing game state — over HTTP for a browser-based frontend. + +## Installation + +Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/). + +```sh +uv sync +``` + +This installs all runtime and dev dependencies into a local `.venv`. + +## Database Setup + +Apply migrations and seed the database with word categories (animals, colors, food): + +```sh +uv run alembic upgrade head +uv run python seed.py +``` + +This creates a local `wordsearch.db` SQLite file. The seed script is idempotent — running it again skips existing categories. + +## Development + +Format code: + +```sh +uv run black . +``` + +Lint: + +```sh +uv run flake8 +``` + +## Testing + +Run all tests: + +```sh +uv run pytest +``` + +Run only unit tests: + +```sh +uv run pytest tests/unit +``` + +Run only e2e tests: + +```sh +uv run pytest tests/e2e +``` + +## Documentation + +Build the API documentation: + +```sh +uv run sphinx-build -b html docs/source docs/build/html +``` + +Then open `docs/build/html/index.html` in a browser. + +## See Also + * Mathew Guest + * Co-Authored with Claude diff --git a/alembic/versions/3a2aa8e15433_add_player_name_to_games.py b/alembic/versions/3a2aa8e15433_add_player_name_to_games.py new file mode 100644 index 0000000..208cabe --- /dev/null +++ b/alembic/versions/3a2aa8e15433_add_player_name_to_games.py @@ -0,0 +1,40 @@ +"""add player_name to games + +Revision ID: 3a2aa8e15433 +Revises: 71016fe57b9d +Create Date: 2026-05-07 02:32:19.229401 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "3a2aa8e15433" +down_revision: Union[str, Sequence[str], None] = "71016fe57b9d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "games", + sa.Column( + "player_name", + sa.String(length=20), + server_default="Anonymous", + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("games", "player_name") + # ### end Alembic commands ### diff --git a/app.py b/app.py index e7cf6b9..f500a21 100644 --- a/app.py +++ b/app.py @@ -23,13 +23,14 @@ from handlers.api import ( BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -def make_app(session_factory=None, debug=False): +def make_app(session_factory=None, debug=False, prefix="/"): """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). + prefix: URL prefix for reverse-proxy hosting (e.g. ``/wordsearch/``). Returns: A configured ``tornado.web.Application`` instance. @@ -50,6 +51,7 @@ def make_app(session_factory=None, debug=False): template_path=os.path.join(BASE_DIR, "templates"), static_path=os.path.join(BASE_DIR, "static"), session_factory=session_factory, + base_url=prefix, debug=debug, ) @@ -57,12 +59,22 @@ def make_app(session_factory=None, debug=False): @click.command() @click.option("--port", default=8888, type=int, help="Port to listen on.") @click.option("--debug", is_flag=True, help="Enable debug mode.") -def main(port, debug): +@click.option( + "--prefix", + default="/", + help="URL prefix for reverse-proxy hosting (e.g. /wordsearch/).", +) +def main(port, debug, prefix): """Start the web server.""" + if not prefix.startswith("/"): + prefix = "/" + prefix + if not prefix.endswith("/"): + prefix = prefix + "/" + engine = get_engine() init_db(engine) session_factory = get_session_factory(engine) - app = make_app(session_factory=session_factory, debug=debug) + app = make_app(session_factory=session_factory, debug=debug, prefix=prefix) app.listen(port) print(f"Server started at http://localhost:{port}") tornado.ioloop.IOLoop.current().start() diff --git a/docs/superpowers/plans/2026-05-06-game-logic.md b/docs/superpowers/plans/2026-05-06-game-logic.md new file mode 100644 index 0000000..71a79c0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-game-logic.md @@ -0,0 +1,1792 @@ +# Word Search Game Logic Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the full word search game — database models, board generation, REST API, and interactive frontend — so a user can pick a category, generate a board, find words by typing or clicking, and see results highlighted with a timer. + +**Architecture:** Server-side game logic with SQLAlchemy models persisted to SQLite via Alembic. Tornado REST endpoints handle game creation and guess validation. A vanilla JS frontend renders the grid, handles two input methods (text + click), and shows real-time state. + +**Tech Stack:** Python 3.13, Tornado, SQLAlchemy, Alembic, SQLite, Bootstrap 5, vanilla JS + +**Spec:** `docs/superpowers/specs/2026-05-06-game-logic-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `models/__init__.py` | Create | Exports Base | +| `models/category.py` | Create | Category, CategoryWord models | +| `models/game.py` | Create | Game, Word models | +| `db.py` | Create | Engine, session factory, init_db | +| `game/__init__.py` | Create | Package init | +| `game/factory.py` | Create | BoardFactory — grid generation + word placement | +| `handlers/api.py` | Create | CategoriesHandler, NewGameHandler, GameStateHandler, GuessHandler | +| `app.py` | Modify | Add DB init, API routes, db_session in settings | +| `templates/game.html` | Modify | Game setup UI + board rendering | +| `static/js/game.js` | Create | Frontend game logic (fetch, render, input handling) | +| `static/css/theme.css` | Modify | Add grid + game-specific styles | +| `alembic.ini` | Create | Alembic configuration | +| `alembic/` | Create | Migrations directory (via `alembic init`) | +| `seed.py` | Create | Populate categories + words | +| `tests/unit/test_models.py` | Create | Model unit tests | +| `tests/unit/test_factory.py` | Create | BoardFactory unit tests | +| `tests/e2e/test_api.py` | Create | API endpoint integration tests | +| `tests/conftest.py` | Create | Shared fixtures (in-memory DB, app with DB) | +| `pyproject.toml` | Modify | Add sqlalchemy, alembic deps | + +--- + +### Task 1: Add dependencies + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add sqlalchemy and alembic to project dependencies** + +```bash +uv add sqlalchemy alembic +``` + +- [ ] **Step 2: Verify dependencies installed** + +```bash +uv run python -c "import sqlalchemy; import alembic; print('ok')" +``` + +Expected: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "feat: add sqlalchemy and alembic dependencies" +``` + +--- + +### Task 2: SQLAlchemy models + +**Files:** +- Create: `models/__init__.py` +- Create: `models/category.py` +- Create: `models/game.py` +- Create: `tests/unit/test_models.py` +- Create: `tests/conftest.py` + +- [ ] **Step 1: Write failing tests for models** + +Create `tests/conftest.py`: + +```python +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from models import Base + + +@pytest.fixture +def db_session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + yield session + session.close() + engine.dispose() +``` + +Create `tests/unit/test_models.py`: + +```python +from models.category import Category, CategoryWord +from models.game import Game, Word + + +def test_create_category(db_session): + cat = Category(name="animals") + db_session.add(cat) + db_session.commit() + assert cat.id is not None + assert cat.name == "animals" + + +def test_create_category_word(db_session): + cat = Category(name="animals") + db_session.add(cat) + db_session.flush() + word = CategoryWord(category_id=cat.id, word="TIGER") + db_session.add(word) + db_session.commit() + assert word.category_id == cat.id + assert word.word == "TIGER" + + +def test_create_game(db_session): + cat = Category(name="animals") + db_session.add(cat) + db_session.flush() + game = Game( + category_id=cat.id, + board_size=10, + grid=[["A"] * 10 for _ in range(10)], + ) + db_session.add(game) + db_session.commit() + assert game.id is not None + assert len(game.id) == 36 # UUID + assert game.status == "in_progress" + assert game.started_at is not None + assert game.completed_at is None + + +def test_create_word(db_session): + cat = Category(name="animals") + db_session.add(cat) + db_session.flush() + game = Game(category_id=cat.id, board_size=10, grid=[]) + db_session.add(game) + db_session.flush() + word = Word( + game_id=game.id, + text="TIGER", + start_row=0, + start_col=0, + direction="E", + ) + db_session.add(word) + db_session.commit() + assert word.found is False + assert word.found_at is None + + +def test_game_words_relationship(db_session): + cat = Category(name="animals") + db_session.add(cat) + db_session.flush() + game = Game(category_id=cat.id, board_size=10, grid=[]) + db_session.add(game) + db_session.flush() + w1 = Word(game_id=game.id, text="TIGER", start_row=0, start_col=0, direction="E") + w2 = Word(game_id=game.id, text="LION", start_row=1, start_col=0, direction="S") + db_session.add_all([w1, w2]) + db_session.commit() + db_session.refresh(game) + assert len(game.words) == 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/unit/test_models.py -v +``` + +Expected: ImportError / ModuleNotFoundError — `models` package doesn't exist yet. + +- [ ] **Step 3: Create models package and Base** + +Create `models/__init__.py`: + +```python +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass +``` + +- [ ] **Step 4: Create Category and CategoryWord models** + +Create `models/category.py`: + +```python +from sqlalchemy import Column, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import relationship + +from models import Base + + +class Category(Base): + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), unique=True, nullable=False) + + words = relationship("CategoryWord", back_populates="category") + + +class CategoryWord(Base): + __tablename__ = "category_words" + __table_args__ = (UniqueConstraint("category_id", "word"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) + word = Column(String(20), nullable=False) + + category = relationship("Category", back_populates="words") +``` + +- [ ] **Step 5: Create Game and Word models** + +Create `models/game.py`: + +```python +import uuid +from datetime import datetime, timezone + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, +) +from sqlalchemy.orm import relationship +from sqlalchemy.types import JSON + +from models import Base + + +class Game(Base): + __tablename__ = "games" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) + board_size = Column(Integer, nullable=False, default=10) + grid = Column(JSON, nullable=False) + status = Column(String(20), nullable=False, default="in_progress") + started_at = Column( + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc) + ) + completed_at = Column(DateTime, nullable=True) + + category = relationship("Category") + words = relationship("Word", back_populates="game") + + +class Word(Base): + __tablename__ = "words" + + id = Column(Integer, primary_key=True, autoincrement=True) + game_id = Column(String(36), ForeignKey("games.id"), nullable=False) + text = Column(String(20), nullable=False) + start_row = Column(Integer, nullable=False) + start_col = Column(Integer, nullable=False) + direction = Column(String(2), nullable=False) + found = Column(Boolean, nullable=False, default=False) + found_at = Column(DateTime, nullable=True) + + game = relationship("Game", back_populates="words") +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +uv run pytest tests/unit/test_models.py -v +``` + +Expected: All 5 tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add models/ tests/conftest.py tests/unit/test_models.py +git commit -m "feat: add SQLAlchemy models for Category, CategoryWord, Game, Word" +``` + +--- + +### Task 3: Database setup module + +**Files:** +- Create: `db.py` + +- [ ] **Step 1: Create db.py with engine and session factory** + +```python +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from models import Base + +DB_PATH = "sqlite:///wordsearch.db" + + +def get_engine(db_url=None): + return create_engine(db_url or DB_PATH) + + +def get_session_factory(engine): + return sessionmaker(bind=engine) + + +def init_db(engine): + Base.metadata.create_all(engine) +``` + +- [ ] **Step 2: Verify it imports cleanly** + +```bash +uv run python -c "from db import get_engine, get_session_factory, init_db; print('ok')" +``` + +Expected: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add db.py +git commit -m "feat: add database setup module" +``` + +--- + +### Task 4: Board factory + +**Files:** +- Create: `game/__init__.py` +- Create: `game/factory.py` +- Create: `tests/unit/test_factory.py` + +- [ ] **Step 1: Write failing tests for BoardFactory** + +Create `tests/unit/test_factory.py`: + +```python +import random + +from models.category import Category, CategoryWord +from game.factory import BoardFactory + +DIRECTIONS = { + "E": (0, 1), + "W": (0, -1), + "S": (1, 0), + "N": (-1, 0), + "SE": (1, 1), + "SW": (1, -1), + "NE": (-1, 1), + "NW": (-1, -1), +} + + +def _seed_category(db_session, name, words): + cat = Category(name=name) + db_session.add(cat) + db_session.flush() + for w in words: + db_session.add(CategoryWord(category_id=cat.id, word=w)) + db_session.commit() + return cat + + +def test_factory_creates_game_and_words(db_session): + cat = _seed_category( + db_session, + "animals", + ["TIGER", "LION", "BEAR", "WOLF", "EAGLE", "HAWK", "DEER", "FROG"], + ) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=10, word_count=5) + assert game.board_size == 10 + assert game.category_id == cat.id + assert len(game.grid) == 10 + assert all(len(row) == 10 for row in game.grid) + assert len(words) <= 5 + assert len(words) >= 1 + + +def test_factory_words_are_on_grid(db_session): + cat = _seed_category( + db_session, + "test", + ["CAT", "DOG", "BAT", "RAT", "OWL", "COW", "PIG", "HEN"], + ) + random.seed(42) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=10, word_count=5) + for word in words: + dr, dc = DIRECTIONS[word.direction] + extracted = "" + r, c = word.start_row, word.start_col + for _ in range(len(word.text)): + extracted += game.grid[r][c] + r += dr + c += dc + assert extracted == word.text + + +def test_factory_fills_all_cells(db_session): + cat = _seed_category(db_session, "small", ["AB", "CD"]) + factory = BoardFactory(db_session) + game, _ = factory.create(category_id=cat.id, board_size=8, word_count=2) + for row in game.grid: + for cell in row: + assert cell.isalpha() and cell.isupper() and len(cell) == 1 + + +def test_factory_filters_words_too_long(db_session): + cat = _seed_category( + db_session, + "mixed", + ["HIPPOPOTAMUS", "CAT", "DOG"], # HIPPOPOTAMUS won't fit in 8x8 + ) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=8, word_count=3) + placed_texts = [w.text for w in words] + assert "HIPPOPOTAMUS" not in placed_texts + + +def test_factory_clamps_word_count(db_session): + cat = _seed_category(db_session, "tiny", ["CAT", "DOG"]) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=10, word_count=10) + assert len(words) <= 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/unit/test_factory.py -v +``` + +Expected: ImportError — `game.factory` doesn't exist yet. + +- [ ] **Step 3: Create game package** + +Create `game/__init__.py` (empty file): + +```python +``` + +- [ ] **Step 4: Create BoardFactory** + +Create `game/factory.py`: + +```python +import random +import string + +from models.category import CategoryWord +from models.game import Game, Word + + +DIRECTIONS = { + "E": (0, 1), + "W": (0, -1), + "S": (1, 0), + "N": (-1, 0), + "SE": (1, 1), + "SW": (1, -1), + "NE": (-1, 1), + "NW": (-1, -1), +} + +MAX_PLACEMENT_ATTEMPTS = 100 + + +class BoardFactory: + def __init__(self, session): + self.session = session + + def create(self, category_id, board_size=10, word_count=8): + candidates = ( + self.session.query(CategoryWord) + .filter(CategoryWord.category_id == category_id) + .all() + ) + # Filter words that fit in the grid + candidates = [cw for cw in candidates if len(cw.word) <= board_size] + random.shuffle(candidates) + selected = candidates[:word_count] + + grid = [[""] * board_size for _ in range(board_size)] + placed_words = [] + + for cw in selected: + result = self._place_word(grid, cw.word, board_size) + if result: + row, col, direction = result + placed_words.append( + Word( + text=cw.word, + start_row=row, + start_col=col, + direction=direction, + ) + ) + + # Fill empty cells with random letters + for r in range(board_size): + for c in range(board_size): + if grid[r][c] == "": + grid[r][c] = random.choice(string.ascii_uppercase) + + game = Game( + category_id=category_id, + board_size=board_size, + grid=grid, + ) + + for w in placed_words: + w.game = game + + return game, placed_words + + def _place_word(self, grid, word, board_size): + directions = list(DIRECTIONS.keys()) + for _ in range(MAX_PLACEMENT_ATTEMPTS): + direction = random.choice(directions) + dr, dc = DIRECTIONS[direction] + max_row = board_size - 1 + max_col = board_size - 1 + word_len = len(word) + + # Calculate valid start ranges + if dr > 0: + row_range = range(0, board_size - word_len + 1) + elif dr < 0: + row_range = range(word_len - 1, board_size) + else: + row_range = range(0, board_size) + + if dc > 0: + col_range = range(0, board_size - word_len + 1) + elif dc < 0: + col_range = range(word_len - 1, board_size) + else: + col_range = range(0, board_size) + + if not row_range or not col_range: + continue + + row = random.choice(list(row_range)) + col = random.choice(list(col_range)) + + if self._can_place(grid, word, row, col, dr, dc): + self._do_place(grid, word, row, col, dr, dc) + return row, col, direction + + return None + + def _can_place(self, grid, word, row, col, dr, dc): + for i, letter in enumerate(word): + r = row + i * dr + c = col + i * dc + if grid[r][c] != "" and grid[r][c] != letter: + return False + return True + + def _do_place(self, grid, word, row, col, dr, dc): + for i, letter in enumerate(word): + r = row + i * dr + c = col + i * dc + grid[r][c] = letter +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +uv run pytest tests/unit/test_factory.py -v +``` + +Expected: All 5 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add game/ tests/unit/test_factory.py +git commit -m "feat: add BoardFactory for word search grid generation" +``` + +--- + +### Task 5: Alembic setup and seed data + +**Files:** +- Create: `alembic.ini` (via `alembic init`) +- Create: `alembic/env.py` (modify generated file) +- Create: `seed.py` + +- [ ] **Step 1: Initialize Alembic** + +```bash +uv run alembic init alembic +``` + +- [ ] **Step 2: Configure alembic.ini** + +In `alembic.ini`, set the database URL: + +```ini +sqlalchemy.url = sqlite:///wordsearch.db +``` + +- [ ] **Step 3: Update alembic/env.py to use our models** + +In `alembic/env.py`, add the model imports so autogenerate works. Near the top of the file, after existing imports: + +```python +from models import Base +from models.category import Category, CategoryWord # noqa: F401 +from models.game import Game, Word # noqa: F401 + +target_metadata = Base.metadata +``` + +Replace the existing `target_metadata = None` line with the above `target_metadata = Base.metadata`. + +- [ ] **Step 4: Generate initial migration** + +```bash +uv run alembic revision --autogenerate -m "initial tables" +``` + +Expected: Creates a migration file in `alembic/versions/`. + +- [ ] **Step 5: Apply migration** + +```bash +uv run alembic upgrade head +``` + +Expected: Creates `wordsearch.db` with all tables. + +- [ ] **Step 6: Create seed.py** + +Create `seed.py`: + +```python +from db import get_engine, get_session_factory, init_db +from models.category import Category, CategoryWord + + +SEED_DATA = { + "animals": [ + "TIGER", "DOLPHIN", "EAGLE", "WOLF", "BEAR", + "LION", "HAWK", "DEER", "FROG", "SHARK", + "SNAKE", "WHALE", "ZEBRA", "PANDA", "OTTER", + "FALCON", "BISON", "CRANE", "MOOSE", "RAVEN", + ], + "colors": [ + "CRIMSON", "VIOLET", "AMBER", "SCARLET", "INDIGO", + "MAROON", "CORAL", "IVORY", "SILVER", "GOLDEN", + "BRONZE", "TEAL", "OLIVE", "PLUM", "RUST", + "SAGE", "NAVY", "JADE", "CYAN", "MAUVE", + ], + "food": [ + "PIZZA", "SUSHI", "MANGO", "PASTA", "TACO", + "BREAD", "STEAK", "GRAPE", "MELON", "PEACH", + "OLIVE", "LEMON", "BACON", "WAFFLE", "CREPE", + "DONUT", "SALAD", "CURRY", "BAGEL", "FUDGE", + ], +} + + +def seed(): + engine = get_engine() + init_db(engine) + Session = get_session_factory(engine) + session = Session() + + 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 = Category(name=cat_name) + session.add(cat) + session.flush() + for w in words: + session.add(CategoryWord(category_id=cat.id, word=w)) + print(f"Seeded category '{cat_name}' with {len(words)} words.") + + session.commit() + session.close() + print("Done.") + + +if __name__ == "__main__": + seed() +``` + +- [ ] **Step 7: Run the seed script** + +```bash +uv run python seed.py +``` + +Expected: +``` +Seeded category 'animals' with 20 words. +Seeded category 'colors' with 20 words. +Seeded category 'food' with 20 words. +Done. +``` + +- [ ] **Step 8: Verify seed data** + +```bash +uv run python -c " +from db import get_engine, get_session_factory +from models.category import Category, CategoryWord +s = get_session_factory(get_engine())() +for c in s.query(Category).all(): + count = s.query(CategoryWord).filter_by(category_id=c.id).count() + print(f'{c.name}: {count} words') +s.close() +" +``` + +Expected: +``` +animals: 20 words +colors: 20 words +food: 20 words +``` + +- [ ] **Step 9: Add wordsearch.db to .gitignore** + +Append to `.gitignore`: + +``` +wordsearch.db +``` + +- [ ] **Step 10: Commit** + +```bash +git add alembic/ alembic.ini seed.py .gitignore +git commit -m "feat: add Alembic migrations and category seed data" +``` + +--- + +### Task 6: API handlers + +**Files:** +- Create: `handlers/api.py` +- Modify: `app.py` +- Create: `tests/e2e/test_api.py` +- Modify: `tests/conftest.py` + +- [ ] **Step 1: Update conftest.py with app fixture that has a DB session** + +Update `tests/conftest.py` to add an app fixture with an in-memory DB: + +```python +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from models import Base +from models.category import Category, CategoryWord # noqa: F401 +from models.game import Game, Word # noqa: F401 + + +@pytest.fixture +def db_engine(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture +def db_session(db_engine): + Session = sessionmaker(bind=db_engine) + session = Session() + yield session + session.close() + + +@pytest.fixture +def app(db_engine): + from app import make_app + + Session = sessionmaker(bind=db_engine) + application = make_app(session_factory=Session) + return application +``` + +- [ ] **Step 2: Write failing API tests** + +Create `tests/e2e/test_api.py`: + +```python +import json + +import pytest + +from models.category import Category, CategoryWord + + +def _seed(db_session): + cat = Category(name="animals") + db_session.add(cat) + db_session.flush() + for w in ["TIGER", "LION", "BEAR", "WOLF", "EAGLE", "HAWK", "DEER", "FROG"]: + db_session.add(CategoryWord(category_id=cat.id, word=w)) + db_session.commit() + return cat + + +@pytest.fixture +def seeded_db(db_session): + return _seed(db_session) + + +async def test_get_categories(http_server_client, seeded_db): + resp = await http_server_client.fetch("/api/categories") + assert resp.code == 200 + data = json.loads(resp.body) + assert len(data) >= 1 + assert data[0]["name"] == "animals" + + +async def test_create_new_game(http_server_client, seeded_db): + body = json.dumps({"category_id": seeded_db.id, "board_size": 10}) + 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 data["board_size"] == 10 + assert data["status"] == "in_progress" + assert len(data["grid"]) == 10 + assert len(data["words"]) >= 1 + # Words should NOT include position info + for w in data["words"]: + assert "start_row" not in w + assert "direction" not in w + + +async def test_get_game_state(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}") + assert resp.code == 200 + data = json.loads(resp.body) + assert data["id"] == game_id + + +async def test_text_guess_correct(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_data = json.loads(create_resp.body) + game_id = game_data["id"] + # Pick a word that was placed + target_word = game_data["words"][0]["text"] + + guess_body = json.dumps({"word": target_word}) + 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 True + assert data["word"] == target_word + assert "cells" in data + + +async def test_text_guess_incorrect(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": "ZZZZNOTAWORD"}) + 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 +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +uv run pytest tests/e2e/test_api.py -v +``` + +Expected: Errors — `make_app` doesn't accept `session_factory`, API routes don't exist. + +- [ ] **Step 4: Create handlers/api.py** + +Create `handlers/api.py`: + +```python +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): + 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): + 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): + 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): + 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): + 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): + # Extract letters from the grid at the given coordinates + grid = game.grid + try: + letters = "".join(grid[c["row"]][c["col"]] for c in cells) + except (IndexError, KeyError): + return self._miss_response(game, session) + + # Find a matching word + word = ( + session.query(Word) + .filter_by(game_id=game.id, text=letters, found=False) + .first() + ) + if not word: + return self._miss_response(game, session) + + # Verify the cells match the word's actual placement + 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) + + # Check if all words found + 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, + } +``` + +- [ ] **Step 5: Update app.py to accept session_factory and add API routes** + +Replace `app.py` with: + +```python +import os + +import click +import tornado.ioloop +import tornado.web + +from db import get_engine, get_session_factory, init_db +from handlers.main import MainHandler +from handlers.game import GameHandler +from handlers.api import ( + CategoriesHandler, + NewGameHandler, + GameStateHandler, + GuessHandler, +) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def make_app(session_factory=None): + return tornado.web.Application( + [ + (r"/", MainHandler), + (r"/game", GameHandler), + (r"/api/categories", CategoriesHandler), + (r"/api/game/new", NewGameHandler), + (r"/api/game/([^/]+)", GameStateHandler), + (r"/api/game/([^/]+)/guess", GuessHandler), + ], + template_path=os.path.join(BASE_DIR, "templates"), + static_path=os.path.join(BASE_DIR, "static"), + session_factory=session_factory, + debug=True, + ) + + +@click.command() +@click.option("--port", default=8888, type=int, help="Port to listen on.") +def main(port): + engine = get_engine() + init_db(engine) + session_factory = get_session_factory(engine) + app = make_app(session_factory=session_factory) + app.listen(port) + print(f"Server started at http://localhost:{port}") + tornado.ioloop.IOLoop.current().start() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 6: Update pyproject.toml packages.find to include new packages** + +In `pyproject.toml`, update the `[tool.setuptools.packages.find]` section: + +```toml +[tool.setuptools.packages.find] +include = ["handlers*", "models*", "game*"] +``` + +- [ ] **Step 7: Run API tests to verify they pass** + +```bash +uv run pytest tests/e2e/test_api.py -v +``` + +Expected: All 5 tests PASS. + +- [ ] **Step 8: Run all tests to verify nothing is broken** + +```bash +uv run pytest -v +``` + +Expected: All tests PASS (model tests, factory tests, API tests, existing e2e test). + +- [ ] **Step 9: Commit** + +```bash +git add handlers/api.py app.py tests/conftest.py tests/e2e/test_api.py pyproject.toml +git commit -m "feat: add REST API for categories, game creation, and guessing" +``` + +--- + +### Task 7: Frontend — game page and JavaScript + +**Files:** +- Modify: `templates/game.html` +- Create: `static/js/game.js` +- Modify: `static/css/theme.css` + +- [ ] **Step 1: Rewrite templates/game.html with game UI structure** + +Replace `templates/game.html` with: + +```html +{% extends "base.html" %} + +{% block title %}Word Search — Play{% end %} + +{% block content %} + +
Instructions coming soon.
+{% end %} +``` + +Create `templates/about.html`: + +```html +{% extends "base.html" %} + +{% block title %}Word Search — About{% end %} + +{% block content %} +About page coming soon.
+{% end %} +``` + +- [ ] **Step 4: Register routes in app.py** + +In `app.py`, add imports after the existing handler imports: + +```python +from handlers.howtoplay import HowToPlayHandler +from handlers.about import AboutHandler +``` + +Add routes to the `make_app()` route list, after the `/game` route: + +```python +(r"/howtoplay", HowToPlayHandler), +(r"/about", AboutHandler), +``` + +- [ ] **Step 5: Run tests to verify all pass** + +Run: `uv run pytest tests/e2e/ -v` + +Expected: All 4 tests PASS + +- [ ] **Step 6: Lint and commit** + +Run: `uv run black . && uv run flake8` + +```bash +git add handlers/howtoplay.py handlers/about.py templates/howtoplay.html templates/about.html app.py tests/e2e/test_hello.py +git commit -m "feat: add howtoplay and about routes with placeholder templates" +``` + +--- + +### Task 3: Swap CSS palette and add component styles + +**Files:** +- Modify: `static/css/theme.css` + +- [ ] **Step 1: Replace full contents of `static/css/theme.css`** + +```css +:root { + --indigo-darkest: #1a1a2e; + --indigo-dark: #16213e; + --indigo-mid: #0f3460; + --coral: #e94560; + --coral-light: #ff6b81; + --gold: #f5c518; + --text-primary: #e0e0e0; + --text-muted: #a0aec0; +} + +body { + background-color: var(--indigo-darkest); + color: var(--text-primary); +} + +/* Navbar */ +.navbar { + background-color: var(--indigo-dark); + border-bottom: 1px solid var(--indigo-mid); +} + +.navbar-brand { + color: var(--coral); + font-weight: 700; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.navbar-brand:hover { + color: var(--coral-light); +} + +.navbar-nav .nav-link { + color: var(--text-muted); + border-radius: 6px; + padding: 0.4rem 1rem; + margin: 0 0.15rem; + font-weight: 500; + transition: background-color 0.2s, color 0.2s; +} + +.navbar-nav .nav-link:hover { + color: #ffffff; + background-color: rgba(15, 52, 96, 0.5); +} + +.navbar-nav .nav-link.active { + color: #ffffff; + background-color: var(--indigo-mid); +} + +/* Hero */ +.hero { + text-align: center; + padding: 3rem 1rem; +} + +.hero-emoji { + font-size: 3rem; + margin-bottom: 0.75rem; +} + +.hero h1 { + color: #ffffff; + font-weight: 800; +} + +.hero .lead { + color: var(--text-muted); + max-width: 500px; + margin-left: auto; + margin-right: auto; +} + +/* Buttons */ +.btn-accent { + background-color: var(--coral); + border-color: var(--coral); + color: #ffffff; + font-weight: 600; +} + +.btn-accent:hover { + background-color: var(--coral-light); + border-color: var(--coral-light); + color: #ffffff; +} + +/* Cards */ +.card { + background-color: var(--indigo-dark); + border-color: var(--indigo-mid); +} + +/* Content pages */ +.content-section { + background-color: var(--indigo-dark); + border: 1px solid var(--indigo-mid); + border-radius: 8px; + padding: 2rem; + margin-bottom: 1.5rem; +} + +.step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + background-color: var(--indigo-mid); + color: var(--gold); + border-radius: 50%; + font-weight: 700; + font-size: 0.875rem; + flex-shrink: 0; +} + +.step-item { + display: flex; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1.25rem; +} + +.step-item p { + margin: 0; +} + +.tip-card { + background-color: rgba(15, 52, 96, 0.4); + border-left: 3px solid var(--gold); + border-radius: 4px; + padding: 1rem 1.25rem; + margin-bottom: 0.75rem; +} + +/* Footer */ +.site-footer { + border-top: 1px solid var(--indigo-mid); + padding: 1.25rem 0; + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; + margin-top: 3rem; +} +``` + +- [ ] **Step 2: Verify the app still renders** + +Run: `uv run pytest tests/e2e/ -v` + +Expected: All 4 tests PASS (CSS changes don't break templates) + +- [ ] **Step 3: Commit** + +```bash +git add static/css/theme.css +git commit -m "style: swap teal palette for dark indigo with coral accents" +``` + +--- + +### Task 4: Restructure base.html + +**Files:** +- Modify: `templates/base.html` + +- [ ] **Step 1: Replace full contents of `templates/base.html`** + +```html + + + + + ++ Find hidden words in a grid of letters. Words can appear horizontally, + vertically, or diagonally. How fast can you find them all? +
+ 🎮 Start Game +Game board coming soon.
++ 📋 Check the word list — Look at the list of words + displayed alongside the grid. These are the words you need to find. +
++ 🔍 Scan for the first letter — Pick a word from the + list and scan the grid for its first letter. This gives you a starting + point. +
++ ↗️ Look in all directions — Words can run + horizontally, vertically, or diagonally — and they can go forward or + backward. Check all eight directions from your starting letter. +
++ 👆 Select the word — Click or tap the first letter of + the word, then click the last letter to highlight the entire word. +
++ ✅ Words get crossed off — When you correctly find a + word, it gets crossed off the list so you can track your progress. +
++ 🏆 Find them all to win! — The puzzle is complete when + every word on the list has been found. Try to beat your best time! +
++ 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. +
++ The source code for this project will be available on GitHub. + Link coming soon. +
+