Add player_name column to games (migration included), with regex-based validation and anti-abuse sanitization. New /scoreboard page shows recent games from localStorage, popular categories with play counts, and top-10 per-category leaderboards sorted by completion time. Also includes two-click reverse word matching, base URL prefix support for reverse-proxy hosting, BasePageHandler refactoring, themed table CSS for all 5 themes, and comprehensive test coverage for player names and scoreboard API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1793 lines
49 KiB
Markdown
1793 lines
49 KiB
Markdown
# 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 %}
|
|
<!-- Game Setup -->
|
|
<div id="game-setup">
|
|
<h2 class="mb-4">New Game</h2>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-md-4">
|
|
<label for="category-select" class="form-label">Category</label>
|
|
<select id="category-select" class="form-select"></select>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="board-size" class="form-label">
|
|
Board Size: <span id="size-display">10</span>
|
|
</label>
|
|
<input type="range" id="board-size" class="form-range"
|
|
min="8" max="20" value="10">
|
|
</div>
|
|
<div class="col-md-4">
|
|
<button id="new-game-btn" class="btn btn-accent btn-lg w-100">
|
|
Start Game
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Game Board -->
|
|
<div id="game-board" class="d-none">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h2 class="mb-0">Word Search</h2>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<span id="timer" class="badge bg-dark fs-6">0:00</span>
|
|
<button id="play-again-btn" class="btn btn-outline-light btn-sm d-none">
|
|
Play Again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Completion Banner -->
|
|
<div id="completion-banner" class="alert alert-success d-none" role="alert">
|
|
<strong>Congratulations!</strong> You found all the words in
|
|
<span id="final-time"></span>!
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- Grid -->
|
|
<div class="col-lg-8 mb-3">
|
|
<div class="card">
|
|
<div class="card-body p-2">
|
|
<table id="grid-table" class="game-grid mx-auto"></table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Input Area -->
|
|
<div class="card mt-3" id="input-area">
|
|
<div class="card-body">
|
|
<div class="row g-2">
|
|
<div class="col">
|
|
<input type="text" id="word-input"
|
|
class="form-control"
|
|
placeholder="Type a word..."
|
|
autocomplete="off">
|
|
</div>
|
|
<div class="col-auto">
|
|
<button id="guess-btn" class="btn btn-accent">
|
|
Submit
|
|
</button>
|
|
</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">
|
|
<button id="clear-selection-btn"
|
|
class="btn btn-outline-secondary d-none">
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="guess-feedback" class="mt-2"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Word List -->
|
|
<div class="col-lg-4">
|
|
<div class="card">
|
|
<div class="card-header">Words to Find</div>
|
|
<ul id="word-list" class="list-group list-group-flush"></ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="{{ static_url('js/game.js') }}"></script>
|
|
{% end %}
|
|
```
|
|
|
|
- [ ] **Step 2: Create static/js/game.js**
|
|
|
|
Create `static/js/game.js`:
|
|
|
|
```javascript
|
|
(function () {
|
|
"use strict";
|
|
|
|
let gameState = null;
|
|
let timerInterval = null;
|
|
let selectedCells = [];
|
|
|
|
// DOM refs
|
|
const setupDiv = document.getElementById("game-setup");
|
|
const boardDiv = document.getElementById("game-board");
|
|
const categorySelect = document.getElementById("category-select");
|
|
const boardSizeInput = document.getElementById("board-size");
|
|
const sizeDisplay = document.getElementById("size-display");
|
|
const newGameBtn = document.getElementById("new-game-btn");
|
|
const gridTable = document.getElementById("grid-table");
|
|
const wordList = document.getElementById("word-list");
|
|
const wordInput = document.getElementById("word-input");
|
|
const guessBtn = document.getElementById("guess-btn");
|
|
const submitSelectionBtn = document.getElementById("submit-selection-btn");
|
|
const clearSelectionBtn = document.getElementById("clear-selection-btn");
|
|
const guessFeedback = document.getElementById("guess-feedback");
|
|
const timerEl = document.getElementById("timer");
|
|
const completionBanner = document.getElementById("completion-banner");
|
|
const finalTime = document.getElementById("final-time");
|
|
const playAgainBtn = document.getElementById("play-again-btn");
|
|
const inputArea = document.getElementById("input-area");
|
|
|
|
// Load categories
|
|
async function loadCategories() {
|
|
const resp = await fetch("/api/categories");
|
|
const categories = await resp.json();
|
|
categorySelect.innerHTML = "";
|
|
categories.forEach(function (cat) {
|
|
const opt = document.createElement("option");
|
|
opt.value = cat.id;
|
|
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1);
|
|
categorySelect.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
boardSizeInput.addEventListener("input", function () {
|
|
sizeDisplay.textContent = this.value;
|
|
});
|
|
|
|
// New game
|
|
newGameBtn.addEventListener("click", async function () {
|
|
const categoryId = parseInt(categorySelect.value);
|
|
const boardSize = parseInt(boardSizeInput.value);
|
|
const resp = await fetch("/api/game/new", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ category_id: categoryId, board_size: boardSize }),
|
|
});
|
|
gameState = await resp.json();
|
|
showBoard();
|
|
});
|
|
|
|
function showBoard() {
|
|
setupDiv.classList.add("d-none");
|
|
boardDiv.classList.remove("d-none");
|
|
completionBanner.classList.add("d-none");
|
|
playAgainBtn.classList.add("d-none");
|
|
inputArea.classList.remove("d-none");
|
|
selectedCells = [];
|
|
renderGrid();
|
|
renderWordList();
|
|
startTimer();
|
|
}
|
|
|
|
function renderGrid() {
|
|
gridTable.innerHTML = "";
|
|
gameState.grid.forEach(function (row, r) {
|
|
const tr = document.createElement("tr");
|
|
row.forEach(function (letter, c) {
|
|
const td = document.createElement("td");
|
|
td.textContent = letter;
|
|
td.dataset.row = r;
|
|
td.dataset.col = c;
|
|
td.addEventListener("click", function () {
|
|
onCellClick(r, c, td);
|
|
});
|
|
tr.appendChild(td);
|
|
});
|
|
gridTable.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function renderWordList() {
|
|
wordList.innerHTML = "";
|
|
gameState.words.forEach(function (w) {
|
|
const li = document.createElement("li");
|
|
li.className = "list-group-item";
|
|
li.textContent = w.text;
|
|
li.dataset.word = w.text;
|
|
if (w.found) {
|
|
li.classList.add("word-found");
|
|
}
|
|
wordList.appendChild(li);
|
|
});
|
|
}
|
|
|
|
// Timer
|
|
function startTimer() {
|
|
if (timerInterval) clearInterval(timerInterval);
|
|
const startedAt = new Date(gameState.started_at);
|
|
function tick() {
|
|
const elapsed = Math.floor((Date.now() - startedAt.getTime()) / 1000);
|
|
const mins = Math.floor(elapsed / 60);
|
|
const secs = elapsed % 60;
|
|
timerEl.textContent = mins + ":" + (secs < 10 ? "0" : "") + secs;
|
|
}
|
|
tick();
|
|
timerInterval = setInterval(tick, 1000);
|
|
}
|
|
|
|
function stopTimer() {
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
timerInterval = null;
|
|
}
|
|
}
|
|
|
|
// Text guess
|
|
guessBtn.addEventListener("click", submitTextGuess);
|
|
wordInput.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter") submitTextGuess();
|
|
});
|
|
|
|
async function submitTextGuess() {
|
|
const word = wordInput.value.trim();
|
|
if (!word) return;
|
|
wordInput.value = "";
|
|
await sendGuess({ word: word });
|
|
}
|
|
|
|
// Click selection
|
|
function onCellClick(row, col, td) {
|
|
if (gameState.status === "completed") return;
|
|
|
|
// Check if already selected — deselect
|
|
const idx = selectedCells.findIndex(
|
|
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;
|
|
}
|
|
} else if (selectedCells.length === 1) {
|
|
// Any adjacent-ish cell is fine for the second pick — direction is set
|
|
}
|
|
|
|
selectedCells.push({ row: row, col: col });
|
|
td.classList.add("cell-selected");
|
|
updateSelectionButtons();
|
|
}
|
|
|
|
function isValidExtension(row, col) {
|
|
if (selectedCells.length < 2) return true;
|
|
const dr = selectedCells[1].row - selectedCells[0].row;
|
|
const dc = selectedCells[1].col - selectedCells[0].col;
|
|
// Normalize direction
|
|
const len = selectedCells.length;
|
|
const expectedRow = selectedCells[0].row + dr * len;
|
|
const expectedCol = selectedCells[0].col + dc * len;
|
|
return row === expectedRow && col === expectedCol;
|
|
}
|
|
|
|
function updateSelectionButtons() {
|
|
if (selectedCells.length > 0) {
|
|
submitSelectionBtn.classList.remove("d-none");
|
|
clearSelectionBtn.classList.remove("d-none");
|
|
} else {
|
|
submitSelectionBtn.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);
|
|
|
|
function clearSelection() {
|
|
selectedCells = [];
|
|
document.querySelectorAll(".cell-selected").forEach(function (td) {
|
|
td.classList.remove("cell-selected");
|
|
});
|
|
updateSelectionButtons();
|
|
}
|
|
|
|
// Send guess to server
|
|
async function sendGuess(payload) {
|
|
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const result = await resp.json();
|
|
gameState.words = result.words;
|
|
gameState.status = result.status;
|
|
|
|
if (result.correct) {
|
|
highlightFoundCells(result.cells);
|
|
renderWordList();
|
|
showFeedback("Found: " + result.word + "!", "text-success");
|
|
} else {
|
|
showFeedback("Not a match.", "text-danger");
|
|
}
|
|
|
|
if (result.status === "completed") {
|
|
onGameComplete(result.completed_at);
|
|
}
|
|
}
|
|
|
|
function highlightFoundCells(cells) {
|
|
cells.forEach(function (c) {
|
|
const td = gridTable.querySelector(
|
|
'td[data-row="' + c.row + '"][data-col="' + c.col + '"]'
|
|
);
|
|
if (td) td.classList.add("cell-found");
|
|
});
|
|
}
|
|
|
|
function showFeedback(msg, cls) {
|
|
guessFeedback.textContent = msg;
|
|
guessFeedback.className = "mt-2 " + cls;
|
|
setTimeout(function () {
|
|
guessFeedback.textContent = "";
|
|
guessFeedback.className = "mt-2";
|
|
}, 2000);
|
|
}
|
|
|
|
function onGameComplete(completedAt) {
|
|
stopTimer();
|
|
completionBanner.classList.remove("d-none");
|
|
finalTime.textContent = timerEl.textContent;
|
|
playAgainBtn.classList.remove("d-none");
|
|
inputArea.classList.add("d-none");
|
|
}
|
|
|
|
playAgainBtn.addEventListener("click", function () {
|
|
boardDiv.classList.add("d-none");
|
|
setupDiv.classList.remove("d-none");
|
|
});
|
|
|
|
// Init
|
|
loadCategories();
|
|
})();
|
|
```
|
|
|
|
- [ ] **Step 3: Add game grid and interaction styles to theme.css**
|
|
|
|
Append to `static/css/theme.css`:
|
|
|
|
```css
|
|
/* Game grid */
|
|
.game-grid {
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.game-grid td {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
text-align: center;
|
|
vertical-align: middle;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
font-family: monospace;
|
|
border: 1px solid var(--teal-mid);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: background-color 0.15s;
|
|
}
|
|
|
|
.game-grid td:hover {
|
|
background-color: var(--teal-mid);
|
|
}
|
|
|
|
.game-grid td.cell-selected {
|
|
background-color: #1a6b6b;
|
|
outline: 2px solid var(--teal-light);
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.game-grid td.cell-found {
|
|
background-color: var(--teal-accent);
|
|
color: #ffffff;
|
|
}
|
|
|
|
/* Word list */
|
|
.list-group-item {
|
|
background-color: var(--teal-dark);
|
|
border-color: var(--teal-mid);
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.list-group-item.word-found {
|
|
text-decoration: line-through;
|
|
color: var(--teal-accent);
|
|
}
|
|
|
|
.card-header {
|
|
background-color: var(--teal-mid);
|
|
border-color: var(--teal-mid);
|
|
color: #ffffff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Form controls in dark theme */
|
|
.form-select,
|
|
.form-control {
|
|
background-color: var(--teal-dark);
|
|
border-color: var(--teal-mid);
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.form-select:focus,
|
|
.form-control:focus {
|
|
background-color: var(--teal-dark);
|
|
border-color: var(--teal-accent);
|
|
color: #e0e0e0;
|
|
box-shadow: 0 0 0 0.2rem rgba(0, 179, 179, 0.25);
|
|
}
|
|
|
|
.form-range::-webkit-slider-thumb {
|
|
background: var(--teal-accent);
|
|
}
|
|
|
|
.form-range::-moz-range-thumb {
|
|
background: var(--teal-accent);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Manual verification**
|
|
|
|
Start the server and seed the DB:
|
|
|
|
```bash
|
|
uv run python seed.py
|
|
uv run python app.py
|
|
```
|
|
|
|
Open http://localhost:8888/game in a browser. Verify:
|
|
1. Category dropdown is populated (Animals, Colors, Food)
|
|
2. Board size slider works (displays value)
|
|
3. "Start Game" creates a board — grid renders with letters
|
|
4. Word list sidebar shows target words
|
|
5. Timer counts up
|
|
6. Type a word from the list and submit — word highlights on grid, crosses off list
|
|
7. Click cells in a line and submit selection — same result
|
|
8. Finding all words shows completion banner and stops timer
|
|
9. "Play Again" returns to setup
|
|
|
|
- [ ] **Step 5: Run all tests**
|
|
|
|
```bash
|
|
uv run pytest -v
|
|
```
|
|
|
|
Expected: All tests PASS.
|
|
|
|
- [ ] **Step 6: Run formatters and linter**
|
|
|
|
```bash
|
|
uv run black . && uv run flake8
|
|
```
|
|
|
|
Expected: Clean.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add templates/game.html static/js/game.js static/css/theme.css
|
|
git commit -m "feat: add game page frontend with grid, word list, timer, and dual input"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Delete placeholder tests
|
|
|
|
**Files:**
|
|
- Modify: `tests/unit/test_hello.py`
|
|
- Modify: `tests/e2e/test_hello.py`
|
|
|
|
- [ ] **Step 1: Remove placeholder tests**
|
|
|
|
Delete `tests/unit/test_hello.py` and update `tests/e2e/test_hello.py` — move its `test_homepage_returns_200` into `tests/e2e/test_api.py` (it already uses the same app fixture pattern).
|
|
|
|
Add to the end of `tests/e2e/test_api.py`:
|
|
|
|
```python
|
|
async def test_homepage_returns_200(http_server_client):
|
|
response = await http_server_client.fetch("/")
|
|
assert response.code == 200
|
|
```
|
|
|
|
Then delete `tests/unit/test_hello.py` and `tests/e2e/test_hello.py`.
|
|
|
|
- [ ] **Step 2: Run all tests**
|
|
|
|
```bash
|
|
uv run pytest -v
|
|
```
|
|
|
|
Expected: All tests PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add -A tests/
|
|
git commit -m "chore: remove placeholder tests, consolidate homepage test"
|
|
```
|