Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 747eebaa83 | |||
| 1aa74c9bfe | |||
| 1aae4c00f0 | |||
| aa2f79b125 | |||
| 8551d14591 | |||
| ff610c0466 | |||
| acbc518b4a |
@@ -69,7 +69,8 @@ def upgrade() -> None:
|
|||||||
sa.Column(
|
sa.Column(
|
||||||
"visibility",
|
"visibility",
|
||||||
postgresql.ENUM(
|
postgresql.ENUM(
|
||||||
"public", "private", "friends_only", name="world_visibility"
|
"public", "private", "friends_only", name="world_visibility",
|
||||||
|
create_type=False,
|
||||||
),
|
),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
@@ -89,7 +90,7 @@ def upgrade() -> None:
|
|||||||
"asset_type",
|
"asset_type",
|
||||||
postgresql.ENUM(
|
postgresql.ENUM(
|
||||||
"image", "audio", "video", "document", "model_3d", "other",
|
"image", "audio", "video", "document", "model_3d", "other",
|
||||||
name="asset_type",
|
name="asset_type", create_type=False,
|
||||||
),
|
),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
"""add model_gltf value to asset_type enum
|
|
||||||
|
|
||||||
Revision ID: 0003_add_gltf_asset_type
|
|
||||||
Revises: 0002_add_date_of_birth_to_users
|
|
||||||
Create Date: 2025-06-15 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "0003_add_gltf_asset_type"
|
|
||||||
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Add the 'model_gltf' value to the asset_type enum."""
|
|
||||||
# IF NOT EXISTS is supported on PostgreSQL 12+; the project targets
|
|
||||||
# PostgreSQL 16. Adding an enum value and using it in the same
|
|
||||||
# transaction is restricted until commit, but this migration only adds
|
|
||||||
# the value so it is safe inside Alembic's transaction.
|
|
||||||
op.execute("ALTER TYPE asset_type ADD VALUE IF NOT EXISTS 'model_gltf'")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Remove the 'model_gltf' value from the asset_type enum.
|
|
||||||
|
|
||||||
PostgreSQL does not support dropping an enum value without table scans;
|
|
||||||
this is intentionally a no-op to keep downgrades safe. Re-creating the
|
|
||||||
enum or deleting rows that use this value would be a destructive action
|
|
||||||
better performed manually if ever needed.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
@@ -13,7 +13,6 @@ class AssetType(str, enum.Enum):
|
|||||||
VIDEO = "video"
|
VIDEO = "video"
|
||||||
DOCUMENT = "document"
|
DOCUMENT = "document"
|
||||||
MODEL_3D = "model_3d"
|
MODEL_3D = "model_3d"
|
||||||
MODEL_GLTF = "model_gltf"
|
|
||||||
OTHER = "other"
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Asset management router."""
|
"""Asset management router."""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -65,177 +64,6 @@ def _sanitize_filename(filename: str) -> str:
|
|||||||
return safe or "file"
|
return safe or "file"
|
||||||
|
|
||||||
|
|
||||||
def _validate_gltf_json(content: bytes) -> dict:
|
|
||||||
"""Validate that content is a valid .gltf file (JSON-based glTF).
|
|
||||||
|
|
||||||
Returns the parsed JSON dict on success.
|
|
||||||
Raises ValueError if the file is not valid glTF.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(content)
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
||||||
raise ValueError(f"Invalid JSON in .gltf file: {e}")
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("glTF file root must be a JSON object")
|
|
||||||
|
|
||||||
if "asset" not in data:
|
|
||||||
raise ValueError("Missing required 'asset' property in glTF file")
|
|
||||||
|
|
||||||
if "scenes" not in data and "meshes" not in data and "nodes" not in data:
|
|
||||||
# glTF 2.0 spec: at least one of scenes, meshes, nodes, accessors,
|
|
||||||
# bufferViews, buffers, materials, textures, samplers, skin,
|
|
||||||
# animations, or extensions must be present.
|
|
||||||
# For simplicity, we require at least one of scenes/meshes/nodes.
|
|
||||||
raise ValueError(
|
|
||||||
"glTF file must contain at least one of: scenes, meshes, or nodes"
|
|
||||||
)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_glb(content: bytes) -> dict:
|
|
||||||
"""Validate that content is a valid .glb file (binary glTF).
|
|
||||||
|
|
||||||
Returns the parsed JSON chunk data on success.
|
|
||||||
Raises ValueError if the file is not valid glTF.
|
|
||||||
"""
|
|
||||||
if len(content) < 12:
|
|
||||||
raise ValueError("glb file too short")
|
|
||||||
|
|
||||||
# Check magic bytes: must start with b'glTF'
|
|
||||||
magic = content[:4]
|
|
||||||
if magic != b"glTF":
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid glb magic bytes: expected b'glTF', got {magic!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Version check (we support version 2)
|
|
||||||
version = int.from_bytes(content[4:8], byteorder="little")
|
|
||||||
if version != 2:
|
|
||||||
raise ValueError(f"Unsupported glTF version: {version} (expected 2)")
|
|
||||||
|
|
||||||
# Total length check
|
|
||||||
total_length = int.from_bytes(content[8:12], byteorder="little")
|
|
||||||
if total_length != len(content):
|
|
||||||
raise ValueError(
|
|
||||||
f"glb total length mismatch: header says {total_length}, "
|
|
||||||
f"actual is {len(content)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse JSON chunk
|
|
||||||
# Chunk format: length (4 bytes) + type (4 bytes) + data
|
|
||||||
offset = 12
|
|
||||||
json_chunk_data = None
|
|
||||||
|
|
||||||
while offset < len(content):
|
|
||||||
if offset + 8 > len(content):
|
|
||||||
raise ValueError("Incomplete chunk header in glb file")
|
|
||||||
|
|
||||||
chunk_length = int.from_bytes(content[offset:offset + 4], byteorder="little")
|
|
||||||
chunk_type = content[offset + 4:offset + 8]
|
|
||||||
|
|
||||||
if chunk_length + 8 > len(content) - offset:
|
|
||||||
raise ValueError("Chunk extends beyond file end")
|
|
||||||
|
|
||||||
if chunk_type == b"JSON":
|
|
||||||
json_chunk_data = content[offset + 8:offset + 8 + chunk_length]
|
|
||||||
# JSON chunk may have padding
|
|
||||||
break
|
|
||||||
elif chunk_type == b"BIN":
|
|
||||||
# Skip binary chunk
|
|
||||||
offset += 8 + chunk_length
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Unknown chunk type - skip it
|
|
||||||
offset += 8 + chunk_length
|
|
||||||
continue
|
|
||||||
|
|
||||||
if json_chunk_data is None:
|
|
||||||
raise ValueError("No JSON chunk found in glb file")
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(json_chunk_data)
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
||||||
raise ValueError(f"Invalid JSON in glb chunk: {e}")
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("glTF file root must be a JSON object")
|
|
||||||
|
|
||||||
if "asset" not in data:
|
|
||||||
raise ValueError("Missing required 'asset' property in glTF file")
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_metadata(data: dict) -> dict:
|
|
||||||
"""Extract metadata from parsed glTF data.
|
|
||||||
|
|
||||||
Returns a dict with:
|
|
||||||
- bounding_box: {"min": [...], "max": [...]} or null
|
|
||||||
- vertex_count: int
|
|
||||||
- material_count: int
|
|
||||||
- has_animation: bool
|
|
||||||
"""
|
|
||||||
metadata = {
|
|
||||||
"bounding_box": None,
|
|
||||||
"vertex_count": 0,
|
|
||||||
"material_count": 0,
|
|
||||||
"has_animation": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Count materials
|
|
||||||
if "materials" in data and isinstance(data["materials"], list):
|
|
||||||
metadata["material_count"] = len(data["materials"])
|
|
||||||
|
|
||||||
# Check for animations
|
|
||||||
if "animations" in data and isinstance(data["animations"], list):
|
|
||||||
metadata["has_animation"] = len(data["animations"]) > 0
|
|
||||||
|
|
||||||
# Count vertices from meshes
|
|
||||||
vertex_count = 0
|
|
||||||
bounds_min = None
|
|
||||||
bounds_max = None
|
|
||||||
|
|
||||||
if "meshes" in data and isinstance(data["meshes"], list):
|
|
||||||
for mesh in data["meshes"]:
|
|
||||||
if "primitives" in mesh and isinstance(mesh["primitives"], list):
|
|
||||||
for primitive in mesh["primitives"]:
|
|
||||||
# Accessor index for positions
|
|
||||||
pos_accessor_idx = primitive.get("attributes", {}).get("POSITION")
|
|
||||||
if pos_accessor_idx is not None:
|
|
||||||
accessors = data.get("accessors", [])
|
|
||||||
if isinstance(accessors, list) and 0 <= pos_accessor_idx < len(accessors):
|
|
||||||
accessor = accessors[pos_accessor_idx]
|
|
||||||
count = accessor.get("count", 0)
|
|
||||||
vertex_count += count
|
|
||||||
|
|
||||||
# Extract bounding box from min/max
|
|
||||||
if "min" in accessor and "max" in accessor:
|
|
||||||
acc_min = accessor["min"]
|
|
||||||
acc_max = accessor["max"]
|
|
||||||
if bounds_min is None:
|
|
||||||
bounds_min = acc_min
|
|
||||||
bounds_max = acc_max
|
|
||||||
else:
|
|
||||||
bounds_min = [
|
|
||||||
min(a, b) for a, b in zip(bounds_min, acc_min)
|
|
||||||
]
|
|
||||||
bounds_max = [
|
|
||||||
max(a, b) for a, b in zip(bounds_max, acc_max)
|
|
||||||
]
|
|
||||||
|
|
||||||
metadata["vertex_count"] = vertex_count
|
|
||||||
|
|
||||||
if bounds_min is not None and bounds_max is not None:
|
|
||||||
metadata["bounding_box"] = {
|
|
||||||
"min": bounds_min,
|
|
||||||
"max": bounds_max,
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
@router.post("/", response_model=AssetResponse, status_code=201)
|
||||||
@@ -379,89 +207,3 @@ async def upload_asset_file(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(asset)
|
await db.refresh(asset)
|
||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-gltf", response_model=AssetResponse, status_code=201)
|
|
||||||
async def import_gltf(
|
|
||||||
current_user: CurrentUser,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Import a glTF or glb file as an asset.
|
|
||||||
|
|
||||||
Accepts a .gltf or .glb file, validates it, extracts metadata
|
|
||||||
(bounding box, vertex count, material count, animation flag),
|
|
||||||
stores the file, and returns an AssetResponse with the new asset's ID.
|
|
||||||
"""
|
|
||||||
filename = file.filename or ""
|
|
||||||
lower_filename = filename.lower()
|
|
||||||
|
|
||||||
# Validate file extension
|
|
||||||
if not (lower_filename.endswith(".gltf") or lower_filename.endswith(".glb")):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="File must be a .gltf or .glb file",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate file size against configured maximum.
|
|
||||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
|
||||||
content = await file.read()
|
|
||||||
file_size = len(content)
|
|
||||||
if file_size > max_bytes:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=413,
|
|
||||||
detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate file content based on format
|
|
||||||
try:
|
|
||||||
if lower_filename.endswith(".gltf"):
|
|
||||||
gltf_data = _validate_gltf_json(content)
|
|
||||||
else:
|
|
||||||
gltf_data = _validate_glb(content)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid glTF file: {e}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract metadata
|
|
||||||
metadata = _extract_metadata(gltf_data)
|
|
||||||
|
|
||||||
# Create the asset record
|
|
||||||
asset = Asset(
|
|
||||||
name=Path(filename).stem,
|
|
||||||
description=None,
|
|
||||||
asset_type=AssetType.MODEL_GLTF,
|
|
||||||
file_path="", # Will be set after file storage
|
|
||||||
owner_id=current_user.id,
|
|
||||||
world_id=None,
|
|
||||||
is_public=False,
|
|
||||||
)
|
|
||||||
db.add(asset)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
|
|
||||||
# Sanitize filename for storage.
|
|
||||||
# The original extension was validated above, so we keep exactly one
|
|
||||||
# extension after the sanitized stem.
|
|
||||||
original_stem = Path(filename).stem
|
|
||||||
safe_stem = _sanitize_filename(original_stem)
|
|
||||||
extension = ".gltf" if lower_filename.endswith(".gltf") else ".glb"
|
|
||||||
stored_name = f"{asset.id}_{safe_stem}{extension}"
|
|
||||||
|
|
||||||
# Write the file to the upload directory
|
|
||||||
upload_dir = Path(settings.UPLOAD_DIR)
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path = upload_dir / stored_name
|
|
||||||
file_path.write_bytes(content)
|
|
||||||
|
|
||||||
# Update asset with file info and metadata
|
|
||||||
asset.file_path = str(file_path)
|
|
||||||
asset.file_size = file_size
|
|
||||||
asset.mime_type = "model/gltf+json" if lower_filename.endswith(".gltf") else "model/gltf+binary"
|
|
||||||
asset.metadata_json = json.dumps(metadata)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
return asset
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: agp_password
|
POSTGRES_PASSWORD: agp_password
|
||||||
POSTGRES_DB: agp_db
|
POSTGRES_DB: agp_db
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "${REDIS_HOST_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -1,493 +0,0 @@
|
|||||||
"""Tests for the glTF import pipeline."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import get_db, SessionLocal
|
|
||||||
from app.models.asset import Asset, AssetType
|
|
||||||
from app.models.user import User
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
|
|
||||||
# --- Fixtures ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db_session():
|
|
||||||
"""Provide a test database session with rollback."""
|
|
||||||
async_session = SessionLocal()
|
|
||||||
|
|
||||||
async def override_get_db():
|
|
||||||
return async_session
|
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
|
||||||
|
|
||||||
yield async_session
|
|
||||||
|
|
||||||
async def cleanup():
|
|
||||||
await async_session.rollback()
|
|
||||||
await async_session.close()
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(cleanup())
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def test_user(db_session):
|
|
||||||
"""Create a test user."""
|
|
||||||
user = User(
|
|
||||||
username="test_gltf_user",
|
|
||||||
email="gltf_test@example.com",
|
|
||||||
hashed_password="$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36PQm3iV8jEGPym2fA1Wile", # pass
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
db_session.add(user)
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(db_session.commit())
|
|
||||||
asyncio.get_event_loop().run_until_complete(db_session.refresh(user))
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(db_session, test_user):
|
|
||||||
"""Create a test client with auth."""
|
|
||||||
from jose import jwt
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
token = jwt.encode(
|
|
||||||
{
|
|
||||||
"sub": str(test_user.id),
|
|
||||||
"exp": datetime.now(timezone.utc)
|
|
||||||
+ timedelta(minutes=30),
|
|
||||||
},
|
|
||||||
settings.SECRET_KEY,
|
|
||||||
algorithm=settings.ALGORITHM,
|
|
||||||
)
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
client.headers["Authorization"] = f"Bearer {token}"
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers for building test glTF files ---
|
|
||||||
|
|
||||||
|
|
||||||
def make_gltf_json(
|
|
||||||
scenes=True,
|
|
||||||
meshes=True,
|
|
||||||
nodes=False,
|
|
||||||
materials=0,
|
|
||||||
animations=0,
|
|
||||||
vertex_count=0,
|
|
||||||
has_bounds=True,
|
|
||||||
):
|
|
||||||
"""Build a minimal valid .gltf JSON structure."""
|
|
||||||
data = {
|
|
||||||
"asset": {"version": "2.0", "generator": "test"},
|
|
||||||
}
|
|
||||||
if scenes:
|
|
||||||
data["scenes"] = [{"nodes": [0]}]
|
|
||||||
if nodes:
|
|
||||||
data["nodes"] = [{"name": "root"}]
|
|
||||||
if meshes:
|
|
||||||
accessors = []
|
|
||||||
if vertex_count > 0 and has_bounds:
|
|
||||||
accessors.append(
|
|
||||||
{
|
|
||||||
"count": vertex_count,
|
|
||||||
"componentType": 5126,
|
|
||||||
"type": "VEC3",
|
|
||||||
"min": [0.0, 0.0, 0.0],
|
|
||||||
"max": [1.0, 1.0, 1.0],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif vertex_count > 0:
|
|
||||||
accessors.append(
|
|
||||||
{
|
|
||||||
"count": vertex_count,
|
|
||||||
"componentType": 5126,
|
|
||||||
"type": "VEC3",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
meshes_list = []
|
|
||||||
for _ in range(1):
|
|
||||||
prim = {"attributes": {"POSITION": 0}} if accessors else {}
|
|
||||||
meshes_list.append({"primitives": [prim]})
|
|
||||||
data["meshes"] = meshes_list
|
|
||||||
if accessors:
|
|
||||||
data["accessors"] = accessors
|
|
||||||
if materials > 0:
|
|
||||||
data["materials"] = [{"name": f"mat_{i}"} for i in range(materials)]
|
|
||||||
if animations > 0:
|
|
||||||
data["animations"] = [{"name": f"anim_{i}"} for i in range(animations)]
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def make_glb(data_dict):
|
|
||||||
"""Build a minimal valid .glb binary file from a dict."""
|
|
||||||
json_bytes = json.dumps(data_dict).encode("utf-8")
|
|
||||||
# Pad JSON to 4-byte boundary
|
|
||||||
json_padding = (4 - len(json_bytes) % 4) % 4
|
|
||||||
json_bytes += b" " * json_padding
|
|
||||||
|
|
||||||
json_chunk_length = len(json_bytes)
|
|
||||||
json_chunk_type = b"JSON"
|
|
||||||
|
|
||||||
# No binary chunks for simplicity
|
|
||||||
total_length = 12 + 8 + json_chunk_length + 8 # header + json chunk header + data + BIN padding
|
|
||||||
|
|
||||||
header = b"glTF"
|
|
||||||
header += int.to_bytes(2, 4, "little") # version
|
|
||||||
header += int.to_bytes(total_length, 4, "little") # total length
|
|
||||||
|
|
||||||
chunk_header = int.to_bytes(json_chunk_length, 4, "little")
|
|
||||||
chunk_header += json_chunk_type
|
|
||||||
|
|
||||||
# Add a BIN chunk with zero length to pad
|
|
||||||
bin_chunk_length = 0
|
|
||||||
bin_chunk_type = b"BIN"
|
|
||||||
bin_padding = (4 - bin_chunk_length % 4) % 4
|
|
||||||
|
|
||||||
return (
|
|
||||||
header
|
|
||||||
+ chunk_header
|
|
||||||
+ json_bytes
|
|
||||||
+ bin_chunk_type
|
|
||||||
+ int.to_bytes(bin_chunk_length, 4, "little")
|
|
||||||
+ b" " * bin_padding
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tests: Extension validation ---
|
|
||||||
|
|
||||||
|
|
||||||
class TestImportGltfExtensionValidation:
|
|
||||||
"""Test that only .gltf and .glb files are accepted."""
|
|
||||||
|
|
||||||
def test_rejects_non_gltf_extension(self, client):
|
|
||||||
"""Files without .gltf or .glb extension return 400."""
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("test.png", b"fake png data", "image/png")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert ".gltf" in response.json()["detail"].lower() or ".glb" in response.json()["detail"].lower()
|
|
||||||
|
|
||||||
def test_rejects_txt_extension(self, client):
|
|
||||||
"""A .txt file is rejected."""
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.txt", b"not a gltf", "text/plain")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tests: .gltf validation ---
|
|
||||||
|
|
||||||
|
|
||||||
class TestImportGltfJsonValidation:
|
|
||||||
"""Test validation of .gltf (JSON) files."""
|
|
||||||
|
|
||||||
def test_valid_minimal_gltf(self, client):
|
|
||||||
"""A minimal valid .gltf is accepted."""
|
|
||||||
data = make_gltf_json(vertex_count=24, materials=2, animations=1)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
body = response.json()
|
|
||||||
assert body["asset_type"] == "model_gltf"
|
|
||||||
assert body["file_size"] == len(json_bytes)
|
|
||||||
assert body["mime_type"] == "model/gltf+json"
|
|
||||||
metadata = json.loads(body["metadata_json"])
|
|
||||||
assert metadata["vertex_count"] == 24
|
|
||||||
assert metadata["material_count"] == 2
|
|
||||||
assert metadata["has_animation"] is True
|
|
||||||
|
|
||||||
def test_rejects_invalid_json(self, client):
|
|
||||||
"""Malformed JSON returns 400."""
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("bad.gltf", b"{not valid json", "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Invalid JSON" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_rejects_gltf_missing_asset(self, client):
|
|
||||||
"""glTF without 'asset' property returns 400."""
|
|
||||||
data = {"scenes": [{}]}
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("no_asset.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Missing required 'asset'" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_rejects_gltf_no_meshes_scenes_nodes(self, client):
|
|
||||||
"""glTF with neither scenes, meshes, nor nodes returns 400."""
|
|
||||||
data = {"asset": {"version": "2.0"}, "materials": []}
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("empty.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "scenes, meshes, or nodes" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tests: .glb validation ---
|
|
||||||
|
|
||||||
|
|
||||||
class TestImportGlbValidation:
|
|
||||||
"""Test validation of .glb (binary) files."""
|
|
||||||
|
|
||||||
def test_valid_minimal_glb(self, client):
|
|
||||||
"""A minimal valid .glb is accepted."""
|
|
||||||
data = make_gltf_json(vertex_count=12, materials=1)
|
|
||||||
glb_bytes = make_glb(data)
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.glb", glb_bytes, "model/gltf+binary")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
body = response.json()
|
|
||||||
assert body["asset_type"] == "model_gltf"
|
|
||||||
assert body["file_size"] == len(glb_bytes)
|
|
||||||
assert body["mime_type"] == "model/gltf+binary"
|
|
||||||
metadata = json.loads(body["metadata_json"])
|
|
||||||
assert metadata["vertex_count"] == 12
|
|
||||||
assert metadata["material_count"] == 1
|
|
||||||
|
|
||||||
def test_rejects_invalid_magic_bytes(self, client):
|
|
||||||
"""File not starting with 'glTF' magic returns 400."""
|
|
||||||
bad_glb = b"xxxx" + int.to_bytes(2, 4, "little") + int.to_bytes(16, 4, "little")
|
|
||||||
bad_glb += int.to_bytes(4, 4, "little") + b"JSON" + b"{}" + b"BIN" + int.to_bytes(0, 4, "little")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("bad.glb", bad_glb, "model/gltf+binary")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "magic" in response.json()["detail"].lower()
|
|
||||||
|
|
||||||
def test_rejects_wrong_version(self, client):
|
|
||||||
"""glTF version 1 returns 400."""
|
|
||||||
data = {"asset": {"version": "1.0"}}
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
json_padding = (4 - len(json_bytes) % 4) % 4
|
|
||||||
json_bytes += b" " * json_padding
|
|
||||||
json_len = len(json_bytes)
|
|
||||||
|
|
||||||
header = b"glTF" + int.to_bytes(1, 4, "little") + int.to_bytes(12 + 8 + json_len + 8, 4, "little")
|
|
||||||
chunk = int.to_bytes(json_len, 4, "little") + b"JSON"
|
|
||||||
bin_pad = b"BIN" + int.to_bytes(0, 4, "little")
|
|
||||||
|
|
||||||
glb_bytes = header + chunk + json_bytes + bin_pad
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("v1.glb", glb_bytes, "model/gltf+binary")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Unsupported glTF version" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_rejects_too_short(self, client):
|
|
||||||
"""A file shorter than 12 bytes returns 400."""
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("tiny.glb", b"glTF123", "model/gltf+binary")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "too short" in response.json()["detail"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tests: File size validation ---
|
|
||||||
|
|
||||||
|
|
||||||
class TestImportGltfFileSize:
|
|
||||||
"""Test file size limits."""
|
|
||||||
|
|
||||||
def test_rejects_oversized_file(self, client):
|
|
||||||
"""Files exceeding MAX_UPLOAD_SIZE_MB return 413."""
|
|
||||||
# Create a file larger than the default 100 MB limit
|
|
||||||
large_content = b"X" * (settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + 1)
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("big.gltf", large_content, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 413
|
|
||||||
assert "exceeds maximum" in response.json()["detail"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tests: Metadata extraction ---
|
|
||||||
|
|
||||||
|
|
||||||
class TestMetadataExtraction:
|
|
||||||
"""Test that metadata is correctly extracted from glTF data."""
|
|
||||||
|
|
||||||
def test_vertex_count_from_meshes(self, client):
|
|
||||||
"""Vertex count is extracted from mesh primitives."""
|
|
||||||
data = make_gltf_json(vertex_count=100, materials=0, animations=0)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
metadata = json.loads(response.json()["metadata_json"])
|
|
||||||
assert metadata["vertex_count"] == 100
|
|
||||||
|
|
||||||
def test_material_count(self, client):
|
|
||||||
"""Material count is extracted from materials array."""
|
|
||||||
data = make_gltf_json(materials=5, vertex_count=0)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
metadata = json.loads(response.json()["metadata_json"])
|
|
||||||
assert metadata["material_count"] == 5
|
|
||||||
|
|
||||||
def test_has_animation_flag(self, client):
|
|
||||||
"""has_animation is True when animations exist."""
|
|
||||||
data = make_gltf_json(animations=3, vertex_count=0)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
metadata = json.loads(response.json()["metadata_json"])
|
|
||||||
assert metadata["has_animation"] is True
|
|
||||||
|
|
||||||
def test_no_animation_flag(self, client):
|
|
||||||
"""has_animation is False when no animations exist."""
|
|
||||||
data = make_gltf_json(animations=0, vertex_count=0)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
metadata = json.loads(response.json()["metadata_json"])
|
|
||||||
assert metadata["has_animation"] is False
|
|
||||||
|
|
||||||
def test_bounding_box_extraction(self, client):
|
|
||||||
"""Bounding box is extracted from accessor min/max."""
|
|
||||||
data = make_gltf_json(vertex_count=8, has_bounds=True)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
metadata = json.loads(response.json()["metadata_json"])
|
|
||||||
assert metadata["bounding_box"] is not None
|
|
||||||
assert "min" in metadata["bounding_box"]
|
|
||||||
assert "max" in metadata["bounding_box"]
|
|
||||||
|
|
||||||
def test_no_bounding_box_without_accessor_bounds(self, client):
|
|
||||||
"""Bounding box is None when accessor has no min/max."""
|
|
||||||
data = make_gltf_json(vertex_count=8, has_bounds=False)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
metadata = json.loads(response.json()["metadata_json"])
|
|
||||||
assert metadata["bounding_box"] is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tests: Database persistence ---
|
|
||||||
|
|
||||||
|
|
||||||
class TestDatabasePersistence:
|
|
||||||
"""Test that assets are correctly stored in the database."""
|
|
||||||
|
|
||||||
def test_asset_created_in_db(self, client, db_session, test_user):
|
|
||||||
"""An asset record is created in the database."""
|
|
||||||
data = make_gltf_json(vertex_count=10)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
# Verify in database
|
|
||||||
result = db_session.execute(select(Asset).where(Asset.id == response.json()["id"]))
|
|
||||||
asset = result.scalar_one_or_none()
|
|
||||||
assert asset is not None
|
|
||||||
assert asset.asset_type == AssetType.MODEL_GLTF
|
|
||||||
assert asset.owner_id == test_user.id
|
|
||||||
assert asset.file_size == len(json_bytes)
|
|
||||||
assert asset.metadata_json is not None
|
|
||||||
|
|
||||||
def test_file_stored_on_disk(self, client, db_session, test_user):
|
|
||||||
"""The file is stored in the upload directory."""
|
|
||||||
data = make_gltf_json(vertex_count=10)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
asset_id = response.json()["id"]
|
|
||||||
upload_dir = Path(settings.UPLOAD_DIR)
|
|
||||||
# The stored file should exist
|
|
||||||
found = False
|
|
||||||
for f in upload_dir.iterdir():
|
|
||||||
if f.name.startswith(f"{asset_id}_"):
|
|
||||||
found = True
|
|
||||||
assert f.read_bytes() == json_bytes
|
|
||||||
break
|
|
||||||
assert found, f"File not found in {upload_dir}"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tests: Filename sanitization ---
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilenameSanitization:
|
|
||||||
"""Test that filenames are properly sanitized."""
|
|
||||||
|
|
||||||
def test_sanitizes_special_chars(self, client):
|
|
||||||
"""Special characters in filename are stripped."""
|
|
||||||
data = make_gltf_json(vertex_count=10)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("my model (1).gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
# The name should be the stem without special chars
|
|
||||||
assert response.json()["name"] == "my model 1"
|
|
||||||
|
|
||||||
def test_preserves_extension(self, client):
|
|
||||||
"""The .gltf extension is preserved in the stored file."""
|
|
||||||
data = make_gltf_json(vertex_count=10)
|
|
||||||
json_bytes = json.dumps(data).encode("utf-8")
|
|
||||||
response = client.post(
|
|
||||||
"/api/assets/import-gltf",
|
|
||||||
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
upload_dir = Path(settings.UPLOAD_DIR)
|
|
||||||
for f in upload_dir.iterdir():
|
|
||||||
if f.name.endswith(".gltf"):
|
|
||||||
assert f.read_bytes() == json_bytes
|
|
||||||
break
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# MVP Vertical Slice: Client-Server Wiring — Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Prove the real Godot client and the real backend can talk: **a user can register/log in
|
||||||
|
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
|
||||||
|
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
|
||||||
|
explicitly out of scope.
|
||||||
|
|
||||||
|
## Verified backend contract (as of this writing)
|
||||||
|
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
|
||||||
|
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
|
||||||
|
Rejects under-18 with `400`.
|
||||||
|
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
|
||||||
|
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
|
||||||
|
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
|
||||||
|
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
|
||||||
|
public-worlds path, so the worlds list screen can only be shown after login/register.
|
||||||
|
- `GET /api/health` — unauthenticated, `{status, app}`.
|
||||||
|
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
|
||||||
|
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
|
||||||
|
|
||||||
|
## Task breakdown
|
||||||
|
| # | Type | Task | Depends on |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||||
|
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
|
||||||
|
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
|
||||||
|
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
|
||||||
|
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
|
||||||
|
|
||||||
|
## Design decisions captured (resolved in task 1)
|
||||||
|
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||||
|
open but not required yet (no re-login on restart in this slice).
|
||||||
|
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
|
||||||
|
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
||||||
|
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
||||||
|
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Creator Studio — Extended Authoring Tools
|
||||||
|
|
||||||
|
> **Feature:** Creator Studio — Extended Authoring Tools
|
||||||
|
> **Status:** Planning
|
||||||
|
> **Owner:** Platform team
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
Extends the in-game world editor (M6) toward a real authoring tool with:
|
||||||
|
1. **Asset import pipeline** (glTF models/textures)
|
||||||
|
2. **Script editor** with sandboxed API surface exposed clearly
|
||||||
|
3. **Experience publishing flow** (draft → review → live)
|
||||||
|
4. **Basic analytics** for creators (plays, session length)
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### US-1: Asset Import Pipeline
|
||||||
|
_As a creator, I can import glTF models and textures into my experience so that I can use custom 3D assets._
|
||||||
|
|
||||||
|
### US-2: Script Editor with Sandboxed API
|
||||||
|
_As a creator, I can write and test scripts in the editor with autocomplete/docs for the available API surface._
|
||||||
|
|
||||||
|
### US-3: Experience Publishing Flow
|
||||||
|
_As a creator, I can publish my experience through a draft → review → live pipeline so that players can discover and play it._
|
||||||
|
|
||||||
|
### US-4: Creator Analytics
|
||||||
|
_As a creator, I can see how many plays and average session length my experiences receive so that I can understand player engagement._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
### US-1: Asset Import Pipeline
|
||||||
|
|
||||||
|
#### T1.1: Backend — glTF Asset Upload with Metadata Extraction
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `backend/app/routers/assets.py` (extend upload endpoint), `backend/app/models/asset.py` (add glTF-specific metadata fields)
|
||||||
|
- **Scope:** Extend the existing `upload_asset_file` endpoint in `assets.py` to detect `.glb`/`.gltf` files and extract glTF metadata (mesh_count, vertex_count, has_animation, texture_refs, bounding_box) from the embedded JSON. Store extracted metadata in `metadata_json`. Add a `has_gltf_metadata` computed property. The Asset model already has `asset_type = MODEL_3D` and `metadata_json` — this task adds the extraction logic.
|
||||||
|
- **Acceptance:** POST `/api/assets/{id}/upload` with a `.glb` file returns asset with `metadata_json` containing `mesh_count`, `vertex_count`, `has_animation`, `texture_refs`. GET lists include these fields. File stored on disk. Non-glTF files are unaffected.
|
||||||
|
- **Dependencies:** None (builds on existing asset infrastructure)
|
||||||
|
|
||||||
|
#### T1.2: Backend — Public Asset Catalog Endpoint
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `backend/app/routers/assets.py` (new endpoint)
|
||||||
|
- **Scope:** Add `GET /api/assets/catalog` endpoint that returns all public glTF assets with their metadata (name, thumbnail_url, mesh_count, vertex_count, tags). Add filtering by tags and pagination. This is what the editor's asset palette queries. Uses the existing `Asset.is_public` field.
|
||||||
|
- **Acceptance:** Catalog endpoint returns paginated list of public assets; filtering by tag works; pagination params respected; returns 401 without auth.
|
||||||
|
- **Dependencies:** T1.1
|
||||||
|
|
||||||
|
#### T1.3: Godot — glTF Import Utility (Editor-Side)
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `scripts/gltf_importer.gd` (new), `scripts/asset_catalog.gd` (new)
|
||||||
|
- **Scope:** Godot-side utility that connects to the catalog API, downloads glTF files, and converts them to Godot-compatible resources (`.res` files with embedded mesh data). Provides `import_gltf(url: String) -> Resource` function. Handles texture extraction and mesh merging. Uses the ApiClient autoload (from client-server-wiring plan) for HTTP.
|
||||||
|
- **Acceptance:** `import_gltf()` downloads a glTF from the catalog, converts mesh to `Mesh` resource, returns a ready-to-use Godot resource. Texture files stored in `user://assets/`.
|
||||||
|
- **Dependencies:** T1.2, client-server-wiring ApiClient (task 1 from that plan)
|
||||||
|
|
||||||
|
### US-2: Script Editor with Sandboxed API
|
||||||
|
|
||||||
|
> **Design note from `scripting_sandbox.md`:** Godot 4 has no built-in GDScript sandbox. The recommended approach is a **data-driven DSL** (JSON/Resource) interpreted by trusted GDScript, not arbitrary code execution. This task follows that recommendation.
|
||||||
|
|
||||||
|
#### T2.1: Backend — Script Model & CRUD Endpoints
|
||||||
|
- **Type:** architecture
|
||||||
|
- **Files:** `backend/app/models/script.py` (new), `backend/app/routers/scripts.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||||
|
- **Scope:** Create `Script` model (id, name, world_id, owner_id, source_code TEXT, version INT, script_type ENUM[gdscript, dsl], dsl_payload JSON, created_at, updated_at). Add full CRUD endpoints: `POST /api/scripts/` (create), `GET /api/scripts/` (list), `GET /api/scripts/{id}` (get), `PUT /api/scripts/{id}` (update), `DELETE /api/scripts/{id}` (delete). Scripts are owned by users and associated with a world. `dsl_payload` stores the JSON/Resource data for the DSL approach; `source_code` stores raw GDScript if used.
|
||||||
|
- **Acceptance:** All CRUD endpoints work; scripts are owned by creator; list endpoint filters by owner/world; update increments version; DSL and gdscript types supported.
|
||||||
|
- **Dependencies:** None (new model + router)
|
||||||
|
|
||||||
|
#### T2.2: Godot — Script API Surface Definition
|
||||||
|
- **Type:** architecture
|
||||||
|
- **Files:** `scripts/editor_script_api.gd` (new)
|
||||||
|
- **Scope:** Define the public API surface available to creator scripts. This is a `RefCounted` class that wraps the Godot engine's capabilities into a safe, documented interface. Includes: `WorldAPI` (get_tile, get_object, set_property), `PlayerAPI` (get_position, get_health), `AudioAPI` (play_sound, play_music), `NetworkAPI` (send_event, broadcast). Each method has documented parameters and return types. Self-documenting via `##` doc comments for autocomplete.
|
||||||
|
- **Acceptance:** `EditorScriptAPI` class exposes all documented methods; methods are callable from user scripts; API surface is self-documenting.
|
||||||
|
- **Dependencies:** None (standalone definition)
|
||||||
|
|
||||||
|
#### T2.3: Godot — In-Game Script Editor UI
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `scenes/editor_script_editor.tscn` (new), `scripts/editor_script_editor.gd` (new)
|
||||||
|
- **Scope:** A `Control`-based UI panel that provides a script editor within the game. Features: code editor with syntax highlighting (basic), autocomplete dropdown, run button (calls backend `/run` endpoint), output console (shows stdout/stderr), API reference panel (shows available methods). Integrates with the existing editor toolbar.
|
||||||
|
- **Acceptance:** Editor panel opens from toolbar; code editor accepts input; autocomplete shows API methods; run button executes script and shows output; API reference panel is browsable.
|
||||||
|
- **Dependencies:** T2.1, T2.2
|
||||||
|
|
||||||
|
### US-3: Experience Publishing Flow
|
||||||
|
|
||||||
|
#### T3.1: Backend — Experience Model & Status Transitions
|
||||||
|
- **Type:** architecture
|
||||||
|
- **Files:** `backend/app/models/experience.py` (new), `backend/app/routers/experiences.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||||
|
- **Scope:** Create `Experience` model (id, world_id FK→worlds, title, description, status ENUM[DRAFT,SUBMITTED,IN_REVIEW,APPROVED,REJECTED,LIVE], published_at, review_notes, reviewer_id FK→users). Status transitions enforced: DRAFT→SUBMITTED→IN_REVIEW→APPROVED→LIVE; REJECTED→DRAFT (resubmit). Add endpoints: `POST /api/experiences/` (create draft), `POST /api/experiences/{id}/submit` (submit for review), `POST /api/experiences/{id}/approve` (approve), `POST /api/experiences/{id}/reject` (reject), `GET /api/experiences/` (list with status filter). An experience is a **publishing wrapper** around a World — the world's visibility is set to PUBLIC when experience goes LIVE.
|
||||||
|
- **Acceptance:** All CRUD + transition endpoints work; status transitions enforced; rejected can be resubmitted; list filters by status; approve sets world visibility to PUBLIC; only world owner can submit; only moderators can approve/reject.
|
||||||
|
- **Dependencies:** None (new model + router)
|
||||||
|
|
||||||
|
#### T3.2: Backend — Review Queue & Moderation Integration
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `backend/app/routers/experiences.py` (add endpoints)
|
||||||
|
- **Scope:** Add review queue endpoint `GET /api/experiences/review-queue` (returns experiences in IN_REVIEW status). Add `POST /api/experiences/{id}/review` endpoint for moderators to add review notes and change status. Integrates with content moderation: when an experience is submitted, its world's content flags are computed and attached to the review.
|
||||||
|
- **Acceptance:** Review queue returns IN_REVIEW experiences; review endpoint accepts notes and status change; content flags attached on submit; only moderators can review.
|
||||||
|
- **Dependencies:** T3.1
|
||||||
|
|
||||||
|
#### T3.3: Godot — Publishing UI Panel
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `scenes/editor_publish_panel.tscn` (new), `scripts/editor_publish_panel.gd` (new)
|
||||||
|
- **Scope:** A `Control`-based panel in the editor that lets creators manage their experience's publishing state. Features: title/description editor, status badge (DRAFT/SUBMITTED/LIVE), submit button, review notes display, publish history timeline. Integrates with the existing editor toolbar.
|
||||||
|
- **Acceptance:** Panel shows current status; submit button calls backend; review notes displayed; publish history shown; status badge updates on refresh.
|
||||||
|
- **Dependencies:** T3.1, T3.2
|
||||||
|
|
||||||
|
#### T3.4: Godot — Experience List & Discovery
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `scenes/experience_list.tscn` (new), `scripts/experience_list.gd` (new)
|
||||||
|
- **Scope:** A UI panel that lists all experiences available to the player (LIVE status). Features: search/filter, experience cards (title, description, creator, play count), join button. Queries `GET /api/experiences/?status=LIVE` and displays results.
|
||||||
|
- **Acceptance:** Lists LIVE experiences; search filters by title/description; cards show metadata; join button loads the experience's world.
|
||||||
|
- **Dependencies:** T3.1
|
||||||
|
|
||||||
|
### US-4: Creator Analytics
|
||||||
|
|
||||||
|
#### T4.1: Backend — Session & Play Tracking Models
|
||||||
|
- **Type:** architecture
|
||||||
|
- **Files:** `backend/app/models/analytics.py` (new), `backend/app/routers/analytics.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||||
|
- **Scope:** Create `PlaySession` model (id, experience_id FK→experiences, player_id FK→users, started_at, ended_at, duration_seconds, chunk_loads INT, interactions_count INT). Create `PlayEvent` model (id, experience_id FK→experiences, player_id FK→users, event_type, payload JSON, created_at). Add endpoints: `POST /api/analytics/session/start` (start session), `POST /api/analytics/session/end` (end session), `POST /api/analytics/event` (log event). Note: `sessions` table is taken by auth tokens; this uses `play_sessions` table name.
|
||||||
|
- **Acceptance:** Session start/end endpoints work; events logged; duration calculated correctly; endpoints accept valid experience/player IDs.
|
||||||
|
- **Dependencies:** None (new models + routers)
|
||||||
|
|
||||||
|
#### T4.2: Backend — Creator Dashboard Analytics Endpoint
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `backend/app/routers/analytics.py` (add endpoint)
|
||||||
|
- **Scope:** Add `GET /api/analytics/experience/{id}` endpoint that returns aggregated analytics for an experience: total_plays, unique_players, avg_session_length, total_play_time, plays_over_time (daily breakdown), top_chunks_loaded, top_interactions. Data is aggregated from PlaySession and PlayEvent tables.
|
||||||
|
- **Acceptance:** Returns all aggregated metrics; daily breakdown covers last 30 days; only owner or moderator can view; empty experience returns zeros.
|
||||||
|
- **Dependencies:** T4.1
|
||||||
|
|
||||||
|
#### T4.3: Godot — Analytics Tracking in Game
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `scripts/analytics_tracker.gd` (new)
|
||||||
|
- **Scope:** A singleton-like service that tracks player sessions and events during gameplay. Automatically starts session on world load, tracks chunk loads, interactions, and time spent. Sends periodic heartbeat events to backend. Handles session end on world unload or disconnect.
|
||||||
|
- **Acceptance:** Session starts on world load; events logged during play; heartbeat sends every 60s; session ends on world unload; handles disconnect gracefully.
|
||||||
|
- **Dependencies:** T4.1
|
||||||
|
|
||||||
|
#### T4.4: Godot — Creator Analytics Dashboard UI
|
||||||
|
- **Type:** implementation
|
||||||
|
- **Files:** `scenes/creator_analytics_panel.tscn` (new), `scripts/creator_analytics_panel.gd` (new)
|
||||||
|
- **Scope:** A `Control`-based panel in the editor that displays analytics for the current experience. Features: total plays counter, avg session length, plays over time chart (simple bar chart), top chunks, top interactions. Queries `GET /api/analytics/experience/{id}` and renders data.
|
||||||
|
- **Acceptance:** Panel shows all metrics; chart renders bar chart of daily plays; data refreshes on button press; handles empty data gracefully.
|
||||||
|
- **Dependencies:** T4.2, T4.3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migration Plan
|
||||||
|
|
||||||
|
New tables needed (in order):
|
||||||
|
1. `scripts` — script storage
|
||||||
|
2. `experiences` — publishing state (wraps worlds)
|
||||||
|
3. `play_sessions` — session tracking (note: `sessions` table already exists for auth tokens)
|
||||||
|
4. `play_events` — granular events
|
||||||
|
|
||||||
|
Migration file: `backend/alembic/versions/0003_creator_studio.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Dependencies Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
T1.1 (glTF upload) ──→ T1.2 (catalog API) ──→ T1.3 (Godot importer)
|
||||||
|
T2.1 (script model) ──→ T2.3 (editor UI)
|
||||||
|
T2.2 (API surface) ──→ T2.3 (editor UI)
|
||||||
|
T3.1 (experience model) ──→ T3.2 (review queue) ──→ T3.3 (publish UI)
|
||||||
|
T3.1 ──→ T3.4 (discovery list)
|
||||||
|
T4.1 (tracking model) ──→ T4.2 (dashboard endpoint) ──→ T4.4 (analytics UI)
|
||||||
|
T4.1 ──→ T4.3 (game tracker) ──→ T4.4 (analytics UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order Recommendation
|
||||||
|
|
||||||
|
1. **T1.1** — Foundation: asset upload with glTF metadata extraction
|
||||||
|
2. **T2.1** — Foundation: script model + CRUD
|
||||||
|
3. **T3.1** — Foundation: experience model + status transitions
|
||||||
|
4. **T4.1** — Foundation: analytics tracking model
|
||||||
|
5. **T1.2** — Asset catalog for editor
|
||||||
|
6. **T2.2** — Script API surface definition
|
||||||
|
7. **T3.2** — Review queue integration
|
||||||
|
8. **T4.2** — Analytics dashboard endpoint
|
||||||
|
9. **T1.3** — Godot glTF importer (depends on ApiClient from client-server-wiring)
|
||||||
|
10. **T2.3** — Script editor UI
|
||||||
|
11. **T3.3** — Publishing UI panel
|
||||||
|
12. **T3.4** — Experience discovery list
|
||||||
|
13. **T4.3** — Analytics tracker in game
|
||||||
|
14. **T4.4** — Analytics dashboard UI
|
||||||
Reference in New Issue
Block a user