Compare commits

..

3 Commits

10 changed files with 491 additions and 343 deletions
@@ -1,30 +0,0 @@
"""add date_of_birth column to users table
Revision ID: 0002_add_date_of_birth_to_users
Revises: 0001_initial
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0002_add_date_of_birth_to_users"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add date_of_birth column to users table."""
op.add_column(
"users",
sa.Column("date_of_birth", sa.Date(), nullable=True),
)
def downgrade() -> None:
"""Remove date_of_birth column from users table."""
op.drop_column("users", "date_of_birth")
+1
View File
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_TIMEOUT_SECONDS: float = 2.0
# JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value
+1 -17
View File
@@ -1,7 +1,4 @@
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -15,7 +12,6 @@ class User(Base):
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(120), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -23,15 +19,3 @@ class User(Base):
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
if self.date_of_birth is None:
return False
today = date.today()
age = today.year - self.date_of_birth.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
age -= 1
return age >= 18
+293
View File
@@ -0,0 +1,293 @@
"""Redis-backed token-bucket rate limiting.
This module provides a reusable FastAPI dependency that rate-limits requests
per endpoint scope (optionally per-user) using an atomic Lua script on Redis.
If Redis is unavailable the limiter can either allow the request through
(fail-open) or reject it with a ``503`` (fail-closed), depending on the
policy configured for that limit. Chat send uses fail-closed by default
because flooding/abuse is worse than a temporary outage.
Example
-------
from app.rate_limit import rate_limit, named_limit
chat_send_limit = rate_limit(
named_limit("chat_send", capacity=5, refill_rate=0.2, fail_open=False),
user_id_dependency=Depends(get_current_user_id),
)
@router.post("/messages")
async def send_message(
payload: MessageCreate,
current_user: CurrentUser,
db: DbDep,
_: None = Depends(chat_send_limit),
):
...
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from fastapi import Depends, HTTPException, Request, status
from redis.asyncio import Redis
from redis.exceptions import RedisError
from app.config import settings
# Atomic token-bucket implementation.
#
# KEYS[1] is the bucket key. ARGV[1] is the bucket capacity, ARGV[2] is the
# refill rate in tokens per second, ARGV[3] is the current Unix time in
# seconds (float), and ARGV[4] is the request cost (tokens to take).
#
# The bucket state is stored as a hash with "tokens" and "ts". The script
# refills the bucket up to capacity, then takes the requested tokens. It
# returns an array:
# [0] allowed (1 or 0)
# [1] tokens remaining after the take
# [2] retry-after seconds (0 when allowed)
_TOKEN_BUCKET_LUA = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call("HMGET", key, "tokens", "ts")
local tokens = capacity
local ts = now
if state[1] ~= false then
tokens = tonumber(state[1])
ts = tonumber(state[2]) or now
end
local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
local allowed = 0
local retry_after = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
-- Time until enough tokens for a single request have accrued.
retry_after = math.ceil((cost - tokens) / refill_rate)
end
-- Keep the bucket state small and expire-able. Retaining the timestamp as a
-- float lets partial tokens accumulate smoothly between requests.
redis.call("HSET", key, "tokens", tostring(tokens), "ts", tostring(now))
redis.call("PEXPIRE", key, 600000)
return {allowed, tostring(tokens), retry_after}
"""
class RateLimitExceeded(HTTPException):
"""Raised when a rate-limit bucket has no tokens left."""
def __init__(self, retry_after: int) -> None:
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded",
headers={"Retry-After": str(retry_after)},
)
@dataclass(frozen=True)
class RateLimit:
"""Definition of a named token-bucket rate limit.
``capacity`` is the burst size and ``refill_rate`` is the steady-state
token accumulation rate (tokens/second). For example,
``RateLimit(name="chat_send", capacity=10, refill_rate=1.0)`` allows a
burst of 10 requests and then one request per second on average.
"""
name: str
capacity: int
refill_rate: float
# When Redis is unreachable: ``True`` allows the request (fail-open),
# ``False`` rejects it with 503 (fail-closed).
fail_open: bool = True
def __post_init__(self) -> None:
if not self.name:
raise ValueError("name must be non-empty")
if self.capacity <= 0:
raise ValueError("capacity must be positive")
if self.refill_rate <= 0:
raise ValueError("refill_rate must be positive")
def default_user_scope(request: Request, user_id: int | None) -> str:
"""Scope a bucket per authenticated user, or per client IP if anonymous."""
if user_id is not None:
return f"user:{user_id}"
client = request.client
return f"ip:{client.host if client else 'unknown'}"
@dataclass(frozen=True)
class RateLimitConfig:
"""A named rate limit plus how it is applied to a request."""
limit: RateLimit
# Builds the Redis key fragment for the request. The resolved user id is
# passed as the second argument (or ``None`` for anonymous limits).
scope: Callable[[Request, int | None], str] = default_user_scope
@dataclass(frozen=True)
class RateLimitResult:
"""Outcome of a rate-limit check (used by tests)."""
allowed: bool
remaining: int
retry_after: int
_redis_client: Redis | None = None
def get_redis() -> Redis:
"""Return a shared async Redis client for rate limiting.
The client is created lazily (``Redis.from_url`` does not connect until a
command is issued), so this dependency never blocks or fails just because
Redis is down.
"""
global _redis_client
if _redis_client is None:
_redis_client = Redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
socket_connect_timeout=settings.REDIS_TIMEOUT_SECONDS,
socket_timeout=settings.REDIS_TIMEOUT_SECONDS,
)
return _redis_client
class RateLimiter:
"""Enforce a token bucket in Redis.
Buckets are keyed as ``rate:{limit.name}:{scope_key}`` so multiple
endpoints can share a named limit while remaining independently bucketed.
"""
def __init__(self, config: RateLimitConfig, redis: Redis | None = None) -> None:
self._config = config
self._redis = redis
def _redis_client(self) -> Redis:
return self._redis or get_redis()
def _bucket_key(self, request: Request, user_id: int | None) -> str:
limit = self._config.limit
scope_key = self._config.scope(request, user_id)
return f"rate:{limit.name}:{scope_key}"
async def check(self, request: Request, user_id: int | None = None) -> RateLimitResult:
"""Atomically refill/take a token and return the outcome.
Redis failures are mapped to ``allowed=True`` (fail-open) or
``allowed=False`` (fail-closed, represented with ``retry_after=-1``)
according to the limit's policy, without raising.
"""
limit = self._config.limit
key = self._bucket_key(request, user_id)
try:
result = await self._redis_client().eval(
_TOKEN_BUCKET_LUA,
1,
key,
str(limit.capacity),
str(limit.refill_rate),
str(time.time()),
"1", # each request consumes one token
)
except RedisError:
if limit.fail_open:
return RateLimitResult(allowed=True, remaining=0, retry_after=0)
# Fail-closed: no reliable retry estimate is available.
return RateLimitResult(allowed=False, remaining=0, retry_after=-1)
# Redis may return ints or strings depending on decode_responses.
allowed = bool(int(result[0]))
remaining = int(result[1])
retry_after = int(result[2])
return RateLimitResult(allowed=allowed, remaining=remaining, retry_after=retry_after)
async def __call__(self, request: Request, user_id: int | None = None) -> None:
"""Enforce the rate limit, raising 429 or 503 as appropriate."""
result = await self.check(request, user_id)
if result.allowed:
return
if result.retry_after < 0:
# Redis was unavailable and this limit is fail-closed.
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Rate limiting service unavailable",
)
raise RateLimitExceeded(retry_after=result.retry_after)
def named_limit(
name: str,
*,
capacity: int,
refill_rate: float,
fail_open: bool = True,
) -> RateLimitConfig:
"""Create a named rate-limit config with the default per-user/IP scope.
``capacity`` is the burst size and ``refill_rate`` is tokens/second.
"""
return RateLimitConfig(
limit=RateLimit(
name=name,
capacity=capacity,
refill_rate=refill_rate,
fail_open=fail_open,
)
)
def rate_limit(
config: RateLimitConfig,
*,
user_id_dependency: Any = None,
) -> Callable[[Request, Any], Awaitable[None]]:
"""Build a FastAPI dependency enforcing ``config``.
``user_id_dependency`` optionally injects the authenticated user id, e.g.
``Depends(get_current_user_id)``. If omitted the limiter buckets by client
IP (the ``default_user_scope`` fallback), which is suitable for anonymous
endpoints but should be paired with an auth resolver for per-user limits.
The returned callable is intended for ``Depends(...)``.
"""
async def _anonymous_user_id() -> int | None:
return None
resolver = user_id_dependency if user_id_dependency is not None else Depends(_anonymous_user_id)
async def dependency(
request: Request,
user_id: int | None = resolver,
) -> None:
limiter = RateLimiter(config)
await limiter(request, user_id)
return dependency
+3 -49
View File
@@ -1,10 +1,10 @@
"""Authentication router - login, register, token refresh."""
from datetime import date, datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, field_validator
from pydantic import BaseModel, EmailStr
from jose import jwt
from passlib.context import CryptContext
@@ -32,24 +32,12 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
is_active: bool
created_at: datetime | None = None
@@ -77,44 +65,11 @@ def hash_password(password: str) -> str:
return pwd_context.hash(password)
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
Returns:
Age in whole years as of the reference date.
"""
if today is None:
today = datetime.now(timezone.utc).date()
age = today.year - birth_date.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token.
Requires date_of_birth and rejects users under 18.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
)
"""Register a new user and return an access token."""
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
@@ -135,7 +90,6 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=dob,
)
db.add(user)
await db.commit()
-3
View File
@@ -1,3 +0,0 @@
[pytest]
asyncio_mode = auto
testpaths = tests
View File
-94
View File
@@ -1,94 +0,0 @@
"""Tests for the 18+ age gate on user registration."""
from datetime import date, timedelta
import pytest
from app.routers.auth import calculate_age
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18)
assert calculate_age(dob, today=today) == 18
def test_just_turned_18(self):
"""A person who turned 18 yesterday is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) - timedelta(days=1)
assert calculate_age(dob, today=today) == 18
def test_one_day_short_of_18(self):
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) + timedelta(days=1)
assert calculate_age(dob, today=today) == 17
def test_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"2000/01/01",
"2000-13-01",
"2000-00-10",
"not-a-date",
"",
],
)
def test_invalid_dates_rejected(self, dob):
"""Invalid dates are rejected by the validator."""
from app.routers.auth import RegisterRequest
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
+193
View File
@@ -0,0 +1,193 @@
"""Tests for the Redis-backed token-bucket rate limiter.
These tests use fakeredis (Lua enabled via lupa) so no live Redis server is
required.
"""
import asyncio
import sys
import pytest
from fastapi import Depends, FastAPI, Request
from fastapi.testclient import TestClient
sys.path.insert(0, "/workspace")
import fakeredis.aioredis
from redis.asyncio import Redis
from redis.exceptions import ConnectionError
from app.rate_limit import (
RateLimit,
RateLimitConfig,
RateLimiter,
default_user_scope,
named_limit,
rate_limit,
)
@pytest.fixture()
def fake_redis():
return fakeredis.aioredis.FakeRedis(decode_responses=True)
def make_request(client_ip: str = "127.0.0.1") -> Request:
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"client": (client_ip, 12345),
}
return Request(scope)
@pytest.mark.asyncio
async def test_token_bucket_allows_burst_then_exhausts(fake_redis):
limiter = RateLimiter(
RateLimitConfig(
limit=RateLimit(name="burst", capacity=3, refill_rate=0.1),
),
redis=fake_redis,
)
request = make_request()
results = []
for _ in range(4):
result = await limiter.check(request, user_id=42)
results.append(result)
assert [r.allowed for r in results] == [True, True, True, False]
assert results[0].remaining == 2
assert results[1].remaining == 1
assert results[2].remaining == 0
assert results[3].retry_after > 0
@pytest.mark.asyncio
async def test_retry_after_reflects_refill_rate(fake_redis):
limiter = RateLimiter(
RateLimitConfig(
limit=RateLimit(name="slow", capacity=1, refill_rate=0.5),
),
redis=fake_redis,
)
request = make_request()
assert (await limiter.check(request, user_id=7)).allowed is True
denied = await limiter.check(request, user_id=7)
assert denied.allowed is False
# 1 token / 0.5 tokens per second = 2 seconds.
assert denied.retry_after == 2
@pytest.mark.asyncio
async def test_separate_scopes_get_separate_buckets(fake_redis):
limiter = RateLimiter(
RateLimitConfig(
limit=RateLimit(name="scoped", capacity=1, refill_rate=0.1),
),
redis=fake_redis,
)
request = make_request()
assert (await limiter.check(request, user_id=1)).allowed is True
assert (await limiter.check(request, user_id=2)).allowed is True
assert (await limiter.check(request, user_id=1)).allowed is False
@pytest.mark.asyncio
async def test_fail_open_returns_allowed_when_redis_unavailable():
class BrokenRedis(Redis):
async def eval(self, *args, **kwargs):
raise ConnectionError("down")
limiter = RateLimiter(
RateLimitConfig(
limit=RateLimit(name="open", capacity=1, refill_rate=0.1, fail_open=True),
),
redis=BrokenRedis(),
)
result = await limiter.check(make_request(), user_id=1)
assert result.allowed is True
@pytest.mark.asyncio
async def test_fail_closed_returns_denied_with_negative_retry_after():
class BrokenRedis(Redis):
async def eval(self, *args, **kwargs):
raise ConnectionError("down")
limiter = RateLimiter(
RateLimitConfig(
limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False),
),
redis=BrokenRedis(),
)
result = await limiter.check(make_request(), user_id=1)
assert result.allowed is False
assert result.retry_after == -1
@pytest.mark.asyncio
async def test_fail_closed_call_raises_503(fake_redis):
class BrokenRedis(Redis):
async def eval(self, *args, **kwargs):
raise ConnectionError("down")
limiter = RateLimiter(
RateLimitConfig(
limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False),
),
redis=BrokenRedis(),
)
with pytest.raises(Exception) as exc_info:
await limiter(make_request(), user_id=1)
assert exc_info.value.status_code == 503
def test_named_limit_builds_expected_config():
config = named_limit(
"chat_send",
capacity=5,
refill_rate=0.2,
fail_open=False,
)
assert config.limit.name == "chat_send"
assert config.limit.capacity == 5
assert config.limit.refill_rate == 0.2
assert config.limit.fail_open is False
assert config.scope is default_user_scope
def test_invalid_limit_definition_rejected():
with pytest.raises(ValueError):
named_limit("bad", capacity=0, refill_rate=1.0)
with pytest.raises(ValueError):
named_limit("bad", capacity=1, refill_rate=0)
def test_dependency_integration_returns_429_with_retry_after():
async def fake_user_id() -> int:
return 99
chat_limit = rate_limit(
named_limit("chat_send_test", capacity=2, refill_rate=0.1, fail_open=False),
user_id_dependency=Depends(fake_user_id),
)
app = FastAPI()
@app.get("/probe")
async def probe(_: None = Depends(chat_limit)):
return {"ok": True}
client = TestClient(app)
assert client.get("/probe").status_code == 200
assert client.get("/probe").status_code == 200
denied = client.get("/probe")
assert denied.status_code == 429
assert denied.headers["retry-after"]
assert denied.json() == {"detail": "Rate limit exceeded"}
-150
View File
@@ -1,150 +0,0 @@
# Discovery — Browse, Search & Home — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `discovery`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Discovery** feature into user stories and
right-sized implementation tasks. The feature delivers:
- **Home / discover page** — a default listing of experiences a player lands on.
- **Search** — find worlds by name and by tag.
- **Categories** — a single category per world plus free-form tags, with
filtering.
- **Simple sorting** — newest, most-played, and trending. Trending starts as a
basic **recent-activity heuristic** (weighted recent visits); no real
recommendation model for MVP.
Without this, nothing else built (experiences, creator content) is actually
reachable by a real user. Discovery is the front door.
## 2. Why this matters
The platform's value is *reachable* content. Worlds and creator assets already
exist in the data model (`World`, `Asset`), but there is currently:
- **No public listing** of worlds (`/api/worlds` requires auth and returns only
PUBLIC worlds or your own worlds, ordered by `created_at` only).
- **No search** by name or tag.
- **No category/tag metadata** on worlds at all.
- **No play-count or recency signal** to power "most-played" or "trending".
- **No client UI** for browsing.
Discovery closes that gap with a small, dependency-free metadata layer plus a
read-optimized listing endpoint.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. Discovery **listing** must work both for anonymous
visitors and authenticated users, so it needs an *optional* auth dependency
(not `CurrentUser`, which returns 401).
- **DB:** async SQLAlchemy + Alembic; migration head is `0002_add_date_of_birth_to_users`.
- **Models:** `World` (id, name, description, owner_id, visibility, created_at,
updated_at). `User`, `Asset` (owner_id, is_public) exist. No tags/categories/
play-count fields yet.
- **Routers:** `worlds.py` already owns CRUD under `/api/worlds`. Discovery adds
a new read-focused router rather than overloading it.
- **Redis:** configured (`REDIS_URL`) but unused; **not required** for discovery
MVP (Postgres queries are sufficient at this scale).
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no networking client, no
discovery UI.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Listing transport | REST (`GET /api/discovery/worlds`) — no WebSocket needed for browse |
| Anonymous browsing | Listing/read endpoints accept **optional** auth; visibility filtering treats anonymous users as "PUBLIC only" |
| Categories | Single `category` string column on `worlds` (indexed). Free-form for MVP; a distinct-categories list is derived with a `SELECT DISTINCT` |
| Tags | PostgreSQL `ARRAY(String)` column on `worlds` with a GIN index for `@>` tag containment search |
| Play count | `play_count` integer (default 0) on `worlds`, incremented atomically on visit |
| Recency signal | `last_played_at` timestamp on `worlds` + append-only `world_visits` table for the trending heuristic |
| Trending (MVP) | Weighted score over recent visits: `sum(decay_weight(age))` over the last N days, no ML |
| Sorting | `newest` (created_at desc), `most_played` (play_count desc), `trending` (score desc) |
| Pagination | `skip`/`limit` (limit capped at 100), consistent with existing routers |
| Visibility | Discovery only surfaces `PUBLIC` worlds; never leaks private/friends_only worlds |
## 5. User stories
### US-1 Home / discover feed
As a player, I can open a home/discover page and see a default list of
experiences without searching.
**Acceptance criteria**
- Returns PUBLIC worlds only, newest-first by default.
- Paginated with a stable total count.
- Works anonymously and authenticated.
- Each result includes id, name, description, category, tags, owner, play_count,
and created_at.
### US-2 Search
As a player, I can search experiences by name and by tag.
**Acceptance criteria**
- Case-insensitive substring search on `name`.
- Tag search matches worlds containing *any* of the requested tags.
- Search combines with category filter and sort.
### US-3 Categories & sorting
As a player, I can filter by category and sort by newest, most-played, or
trending.
**Acceptance criteria**
- `category` filter matches exactly (case-insensitive).
- Distinct category list is available for UI dropdowns.
- `newest`, `most_played`, and `trending` sorts are supported.
- `most_played` uses `play_count`; `trending` uses the recent-visits heuristic.
### US-4 Visit tracking
As a player, opening a world records a visit so counts and trending stay fresh.
**Acceptance criteria**
- `POST /api/worlds/{id}/visit` increments `play_count` and updates
`last_played_at`.
- Appends a `world_visits` row (world_id, user_id or anonymous marker,
visited_at).
- Idempotent per request; rate-limitable later.
### US-5 Godot client
As a player, I can browse, search, filter, and sort experiences from the game UI.
**Acceptance criteria**
- An HTTP API client handles the discovery endpoints (JWT + anonymous).
- A discover/home screen lists experiences with search, category filter, and
sort controls.
- Results render name, category, tags, play count, and description.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | World discovery metadata + visits model & Alembic migration | US-2, US-3, US-4 | — |
| 2 | World visit tracking endpoint (play count + last played + visit log) | US-4 | 1 |
| 3 | Browse + search endpoint (name/tag/category, newest + most-played) | US-1, US-2, US-3 | 1, 2 |
| 4 | Trending sort (recent-activity heuristic over `world_visits`) | US-3 | 3 |
| 5 | Godot discovery API client (HTTP + optional JWT) | US-5 | 3 |
| 6 | Godot discover/home UI (feed, search, category filter, sort) | US-5 | 4, 5 |
## 7. Phasing
- **Phase 1 (data + core read API):** tasks 1–3 (metadata, visit tracking,
browse/search).
- **Phase 2 (trending):** task 4 — the only non-trivial query; isolated so it
can be tuned without touching the browse surface.
- **Phase 3 (client):** tasks 5–6, each landing after its backend dependency.
## 8. Out of scope for this feature
- Real recommendation models / personalization (collaborative filtering, ML).
- Full-text search (Postgres FTS); MVP uses ILIKE substring match.
- Faceted search with multiple simultaneous category/tag AND-semantics.
- Featured/editorial curation and admin-curated carousels.
- Search result relevance scoring beyond the three explicit sorts.
- Analytics dashboards; `world_visits` is append-only signal, not a BI surface.