feat: expand to 1014 words, add title-case display and 2 new themes
- Move word data from hardcoded seed.py to data/words.json - Expand from 210 words across 7 categories to 1014 words across 14 categories (animals, food, colors, sports, space, nature, music, science, countries, occupations, ocean, weather, mythology, technology) - Word list sidebar now shows title case (Tiger) while grid stays uppercase; text input remains case-insensitive - Add Ocean theme (light, blue accents) and Forest theme (dark, amber accents on deep green) - Replace hardcoded rgba values with --coral-bg CSS variable so selection highlighting adapts per theme Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cd1dc9200b
commit
9319f08875
+108
@@ -2,6 +2,7 @@
|
||||
|
||||
import collections
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -33,6 +34,24 @@ def _check_rate(ip, bucket="default"):
|
||||
return True
|
||||
|
||||
|
||||
_PLAYER_NAME_RE = re.compile(r"^[a-zA-Z0-9 ]+$")
|
||||
MAX_PLAYER_NAME_LEN = 20
|
||||
|
||||
|
||||
def _sanitize_player_name(raw):
|
||||
"""Validate and sanitize a player name. Returns name or None (invalid)."""
|
||||
if not isinstance(raw, str):
|
||||
return "Anonymous"
|
||||
name = raw.strip()
|
||||
if not name:
|
||||
return "Anonymous"
|
||||
if len(name) > MAX_PLAYER_NAME_LEN:
|
||||
name = name[:MAX_PLAYER_NAME_LEN]
|
||||
if not _PLAYER_NAME_RE.match(name):
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
class BaseAPIHandler(tornado.web.RequestHandler):
|
||||
"""Base handler for JSON API endpoints."""
|
||||
|
||||
@@ -112,6 +131,19 @@ class NewGameHandler(BaseAPIHandler):
|
||||
return
|
||||
word_count = max(3, min(30, word_count))
|
||||
|
||||
player_name = _sanitize_player_name(data.get("player_name", ""))
|
||||
if player_name is None:
|
||||
self.set_status(400)
|
||||
self.write(
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Player name may only contain"
|
||||
" letters, numbers, and spaces"
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
category = session.query(Category).filter_by(id=category_id).first()
|
||||
if not category:
|
||||
self.set_status(404)
|
||||
@@ -130,6 +162,7 @@ class NewGameHandler(BaseAPIHandler):
|
||||
self.write(json.dumps({"error": "Failed to generate board with words"}))
|
||||
return
|
||||
|
||||
game.player_name = player_name
|
||||
session.add(game)
|
||||
for w in words:
|
||||
session.add(w)
|
||||
@@ -146,6 +179,7 @@ class NewGameHandler(BaseAPIHandler):
|
||||
return {
|
||||
"id": game.id,
|
||||
"board_size": game.board_size,
|
||||
"player_name": game.player_name,
|
||||
"category": game.category.name,
|
||||
"grid": game.grid,
|
||||
"words": [{"text": w.text, "found": w.found} for w in game.words],
|
||||
@@ -171,6 +205,7 @@ class GameStateHandler(BaseAPIHandler):
|
||||
{
|
||||
"id": game.id,
|
||||
"board_size": game.board_size,
|
||||
"player_name": game.player_name,
|
||||
"category": game.category.name,
|
||||
"grid": game.grid,
|
||||
"words": [
|
||||
@@ -296,6 +331,7 @@ class GuessHandler(BaseAPIHandler):
|
||||
"cells": cells,
|
||||
"words": [{"text": w.text, "found": w.found} for w in game.words],
|
||||
"status": game.status,
|
||||
"player_name": game.player_name,
|
||||
"completed_at": (
|
||||
game.completed_at.isoformat() + "Z" if game.completed_at else None
|
||||
),
|
||||
@@ -308,5 +344,77 @@ class GuessHandler(BaseAPIHandler):
|
||||
"cells": [],
|
||||
"words": [{"text": w.text, "found": w.found} for w in game.words],
|
||||
"status": game.status,
|
||||
"player_name": game.player_name,
|
||||
"completed_at": None,
|
||||
}
|
||||
|
||||
|
||||
class ScoreboardAPIHandler(BaseAPIHandler):
|
||||
"""``GET /api/scoreboard`` -- leaderboards and popular categories."""
|
||||
|
||||
def get(self):
|
||||
session = self.get_session()
|
||||
try:
|
||||
categories = session.query(Category).all()
|
||||
|
||||
leaderboards = {}
|
||||
for cat in categories:
|
||||
completed = (
|
||||
session.query(Game)
|
||||
.filter(
|
||||
Game.category_id == cat.id,
|
||||
Game.status == "completed",
|
||||
Game.completed_at.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
completed.sort(
|
||||
key=lambda g: (g.completed_at - g.started_at).total_seconds()
|
||||
)
|
||||
top = completed[:10]
|
||||
if top:
|
||||
leaderboards[cat.name] = [
|
||||
{
|
||||
"player_name": g.player_name,
|
||||
"time_seconds": (
|
||||
g.completed_at - g.started_at
|
||||
).total_seconds(),
|
||||
"board_size": g.board_size,
|
||||
"word_count": len(g.words),
|
||||
"completed_at": g.completed_at.isoformat() + "Z",
|
||||
}
|
||||
for g in top
|
||||
]
|
||||
|
||||
popular = []
|
||||
for cat in categories:
|
||||
count = (
|
||||
session.query(Game)
|
||||
.filter(
|
||||
Game.category_id == cat.id,
|
||||
Game.status == "completed",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
popular.append(
|
||||
{
|
||||
"id": cat.id,
|
||||
"name": cat.name,
|
||||
"completed_games": count,
|
||||
}
|
||||
)
|
||||
popular.sort(key=lambda x: x["completed_games"], reverse=True)
|
||||
|
||||
self.write(
|
||||
json.dumps(
|
||||
{
|
||||
"leaderboards": leaderboards,
|
||||
"popular_categories": popular,
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
Reference in New Issue
Block a user