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:
2026-09-05 21:42:10 +00:00
parent d6cd3293ae
commit af85048deb
10 changed files with 331 additions and 5 deletions
+31
View File
@@ -449,6 +449,37 @@ The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
---
## 9a. Loadout Persistence (implemented, PC3 Phase 1)
**Storage.** One row per user in `avatar_loadouts` (`user_id` PK/FK,
`data` JSONB, timestamps). The entire loadout is stored as a single JSON
document in the exact shape the client's `AvatarLoadout.to_json()` emits
(`{"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}`) rather than
exploded into columns.
**Why one JSON blob, not columns.** The server has no copy of the asset
catalog (`assets/avatars/catalog.json` ships with the client). It therefore
cannot meaningfully validate *which* asset belongs in *which* slot, whether
an asset exists, or slot-exclusivity rules — that validation stays on the
client (`AvatarLoadout.validate()` against the loaded `AvatarCatalog`). The
server enforces only the envelope: a JSON object with a non-empty string
`base_body`, an object `slots`, and a sane size cap. Keeping the schema
opaque to the server also means catalog/loadout-shape changes don't need a
migration.
**Endpoints** (`/api/avatar`, bearer auth):
| Method | Path | Behaviour |
|---|---|---|
| `GET` | `/loadout` | The user's saved loadout, or the server default (`{"base_body": "base_human_male_01", "slots": {}}`, `is_default: true`) if they have never saved one. |
| `PUT` | `/loadout` | Replace the user's loadout (full document, not a patch). |
**Defaults.** A brand-new account has no row; `GET` returns
`models.avatar_loadout.DEFAULT_LOADOUT` with `is_default: true` so the client
can distinguish "never customised" from "customised back to the base look".
---
## 10. Summary
| Concern | Decision |