PC3 Phase 1.3: backend avatar loadout persistence
New avatar_loadouts table (one row per user, loadout stored as a single JSONB document matching the client's AvatarLoadout.to_json() shape) and GET/PUT /api/avatar/loadout. The server has no copy of the asset catalog, so it validates only the envelope (JSON object, non-empty base_body, object slots, size cap) -- which-asset-in-which-slot validation stays client-side. New accounts get a server default (base_human_male_01, no slots) with is_default:true so the client can tell "never customised" from "customised back to base". - models/avatar_loadout.py + 0004 migration + User.avatar_loadout relation - routers/avatar.py wired into main.py - tests/test_avatar_loadout.py (7 tests: default, save/read, replace, validation, auth) -- full suite 68 passed - lifted the duplicated _override_db fixture from 3 test files into conftest.py (new test files need it too) - docs/architecture/avatar.md §9a: the persistence design Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
+2
-1
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, users, worlds, assets
|
||||
from app.routers import auth, users, worlds, assets, avatar
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -38,6 +38,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
||||
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
||||
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -2,5 +2,6 @@ from app.models.user import User
|
||||
from app.models.session import Session
|
||||
from app.models.world import World
|
||||
from app.models.asset import Asset
|
||||
from app.models.avatar_loadout import AvatarLoadout
|
||||
|
||||
__all__ = ["User", "Session", "World", "Asset"]
|
||||
__all__ = ["User", "Session", "World", "Asset", "AvatarLoadout"]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
# The default a brand-new account gets before they touch the customization
|
||||
# screen. Kept in sync with assets/avatars/catalog.json (the base_body id) and
|
||||
# with the client's AvatarLoadout.to_json() shape.
|
||||
DEFAULT_LOADOUT: dict = {
|
||||
"base_body": "base_human_male_01",
|
||||
"slots": {},
|
||||
}
|
||||
|
||||
|
||||
class AvatarLoadout(Base):
|
||||
"""A user's equipped avatar composition.
|
||||
|
||||
One row per user. The whole loadout is stored as a single JSON document
|
||||
matching the client's ``AvatarLoadout.to_json()`` shape
|
||||
(``{"base_body": str, "slots": {...}, "skin_tone"?: {...}}``) rather than
|
||||
exploded into columns -- the server has no copy of the asset catalog, so
|
||||
deep validation (does this asset exist, is it valid for this slot) stays
|
||||
on the client. The server only enforces the envelope: valid JSON object
|
||||
with a non-empty ``base_body``.
|
||||
"""
|
||||
|
||||
__tablename__ = "avatar_loadouts"
|
||||
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
data = Column(JSONB, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="avatar_loadout")
|
||||
@@ -24,6 +24,12 @@ class User(Base):
|
||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||
worlds = relationship("World", back_populates="owner")
|
||||
assets = relationship("Asset", back_populates="owner")
|
||||
avatar_loadout = relationship(
|
||||
"AvatarLoadout",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_adult(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Avatar router - the current user's persisted avatar loadout.
|
||||
|
||||
The loadout is a single JSON document matching the client's
|
||||
``AvatarLoadout.to_json()`` shape. The server has no copy of the asset
|
||||
catalog, so it only validates the envelope (JSON object, non-empty
|
||||
``base_body``); which assets are valid for which slot is the client's job.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.avatar_loadout import AvatarLoadout, DEFAULT_LOADOUT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
# A loadout document is small; reject anything absurd outright.
|
||||
MAX_LOADOUT_BYTES = 16 * 1024
|
||||
|
||||
|
||||
class LoadoutResponse(BaseModel):
|
||||
data: dict[str, Any]
|
||||
is_default: bool = Field(
|
||||
description="True when the user has never saved a loadout and this is "
|
||||
"the server default."
|
||||
)
|
||||
|
||||
|
||||
class LoadoutUpdate(BaseModel):
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
def _validate_envelope(data: dict[str, Any]) -> None:
|
||||
base_body = data.get("base_body")
|
||||
if not isinstance(base_body, str) or not base_body.strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="loadout.data must have a non-empty string 'base_body'",
|
||||
)
|
||||
slots = data.get("slots", {})
|
||||
if not isinstance(slots, dict):
|
||||
raise HTTPException(status_code=422, detail="loadout.data.slots must be an object")
|
||||
if len(str(data)) > MAX_LOADOUT_BYTES:
|
||||
raise HTTPException(status_code=413, detail="loadout document too large")
|
||||
|
||||
|
||||
@router.get("/loadout", response_model=LoadoutResponse)
|
||||
async def get_loadout(current_user: CurrentUser, db: DbDep):
|
||||
"""Return the current user's saved loadout, or the server default if they
|
||||
have never saved one."""
|
||||
row = await db.get(AvatarLoadout, current_user.id)
|
||||
if row is None:
|
||||
return LoadoutResponse(data=dict(DEFAULT_LOADOUT), is_default=True)
|
||||
return LoadoutResponse(data=row.data, is_default=False)
|
||||
|
||||
|
||||
@router.put("/loadout", response_model=LoadoutResponse)
|
||||
async def put_loadout(payload: LoadoutUpdate, current_user: CurrentUser, db: DbDep):
|
||||
"""Replace the current user's loadout."""
|
||||
_validate_envelope(payload.data)
|
||||
|
||||
row = await db.get(AvatarLoadout, current_user.id)
|
||||
if row is None:
|
||||
row = AvatarLoadout(user_id=current_user.id, data=payload.data)
|
||||
db.add(row)
|
||||
else:
|
||||
row.data = payload.data
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return LoadoutResponse(data=row.data, is_default=False)
|
||||
Reference in New Issue
Block a user