Files
daddy af85048deb 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
2026-09-05 21:42:10 +00:00

42 lines
1.5 KiB
Python

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")