Compare commits

..

28 Commits

Author SHA1 Message Date
coder-bot 48a43c44a5 WIP checkpoint: Address review feedback: Scaffold FastAPI backend project structure 2026-08-17 23:38:24 +00:00
coder-bot 48dd177fa2 WIP checkpoint: Address review feedback: Scaffold FastAPI backend project structure 2026-08-17 23:36:31 +00:00
coder-bot 1f85ecc812 WIP checkpoint: Address review feedback: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot b547ca5785 Add missing asyncpg dependency for async postgres driver
config.py's DATABASE_URL uses postgresql+asyncpg but requirements.txt
only listed the sync psycopg2-binary driver, causing ModuleNotFoundError
on app startup.
2026-08-17 23:34:54 +00:00
coder-bot 3f707bf6b0 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot d7d77474f9 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot 2fe6d5dbf0 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot 534da614dd WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot dc09f5ae37 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot 295c44522c WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot 33d2ef7f8a WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot b51c90c360 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot 4df839ccd8 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot 2b1786558a Recovered orphaned WIP before switching to a new task 2026-08-17 23:34:54 +00:00
coder-bot 7a6658798f Recovered orphaned WIP before switching to a new task 2026-08-17 23:34:54 +00:00
coder-bot 46a2ff672c WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
coder-bot f3c679faab WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
architect-bot 5837fad5df WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
agent-bot f659fd4d2a Recovered orphaned WIP before switching to a new task 2026-08-17 23:34:54 +00:00
agent-bot fa6e9064dc Recovered orphaned WIP before switching to a new task 2026-08-17 23:34:54 +00:00
agent-bot 3e030f8d45 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
agent-bot 7594039fd0 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
agent-bot 857cf08402 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
agent-bot 49d608d357 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 23:34:54 +00:00
agent-bot aa8a497abc chore(backend): add env config template
Scaffolding only — FastAPI app, SQLAlchemy models, Alembic migrations,
and docker-compose from the original task description are still not
implemented. Committing what exists so it isn't lost; task ec0074121ca3
reset to pending to finish the rest.
2026-08-17 23:34:54 +00:00
reviewer-bot ebf56dcece Merge pull request 'Address review feedback: Initialize Godot 4 project structure' (#14) from agent/ecd7c0ce8744 into main 2026-08-17 21:30:03 +00:00
coder-bot 5090341591 Bring in Godot project + address review feedback (this branch had none of it)
This branch never actually had the Godot scaffold — a workspace-reuse bug
(see agp source repo) let leftover backend/**/__pycache__/*.pyc files from
an unrelated task get swept into this branch's only prior commit, which
was checkpointed under this task's title despite containing zero Godot
files. Removing that garbage and bringing over the real project from
agent/997c429bc6b4 (the original PR's branch) with the review feedback
addressed:

- Play button no longer references the nonexistent scenes/game.tscn.
- Wired PlayButton/QuitButton [connection] entries in main_menu.tscn.
- Removed the invalid uid="uid://main_menu".
- Added .gitignore for .godot/.
- Removed the non-standard [export]/[export_options] sections from
  project.godot (Godot 4 uses export_presets.cfg, not this).
2026-08-17 21:24:43 +00:00
coder-bot 4ef6196091 WIP checkpoint: Address review feedback: Initialize Godot 4 project structure 2026-08-17 20:52:39 +00:00
33 changed files with 1321 additions and 369 deletions
+2
View File
@@ -0,0 +1,2 @@
# Godot 4 editor cache/import directory
.godot/
View File
View File
+20
View File
@@ -0,0 +1,20 @@
# Database (async driver)
DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT
SECRET_KEY=change-me-to-a-secure-random-string
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# App
APP_NAME=AGP Backend
APP_VERSION=0.1.0
DEBUG=true
CORS_ORIGINS=http://localhost:8080,http://localhost:3000
# File uploads
UPLOAD_DIR=uploads
MAX_UPLOAD_SIZE_MB=100
+4
View File
@@ -0,0 +1,4 @@
.env
__pycache__/
*.pyc
.venv/
View File
+42
View File
@@ -0,0 +1,42 @@
# Alembic configuration file.
# Run: alembic upgrade head (to apply migrations)
# Run: alembic revision --autogenerate -m "description" (to generate migration)
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+65
View File
@@ -0,0 +1,65 @@
"""Alembic environment configuration."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
"""Run migrations in 'online' mode with async engine."""
connectable = create_async_engine(settings.DATABASE_URL, echo=False)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade database schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade database schema."""
${downgrades if downgrades else "pass"}
+126
View File
@@ -0,0 +1,126 @@
"""initial schema
Revision ID: 0001_initial
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade database schema."""
# Create ENUM types
world_visibility = postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility"
)
world_visibility.create(op.get_bind(), checkfirst=True)
asset_type = postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other", name="asset_type"
)
asset_type.create(op.get_bind(), checkfirst=True)
# Create users table
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(length=50), nullable=False),
sa.Column("email", sa.String(length=120), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
# Create sessions table
op.create_table(
"sessions",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index(op.f("ix_sessions_token"), "sessions", ["token"], unique=True)
# Create worlds table
op.create_table(
"worlds",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column(
"visibility",
postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility"
),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
)
op.create_index(op.f("ix_worlds_id"), "worlds", ["id"], unique=False)
# Create assets table
op.create_table(
"assets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"asset_type",
postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other",
name="asset_type",
),
nullable=False,
),
sa.Column("file_path", sa.String(length=512), nullable=False),
sa.Column("file_size", sa.Integer(), nullable=True),
sa.Column("mime_type", sa.String(length=128), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=True),
sa.Column("world_id", sa.Integer(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"]),
)
op.create_index(op.f("ix_assets_id"), "assets", ["id"], unique=False)
def downgrade() -> None:
"""Downgrade database schema."""
op.drop_index(op.f("ix_assets_id"), table_name="assets")
op.drop_table("assets")
op.drop_index(op.f("ix_worlds_id"), table_name="worlds")
op.drop_table("worlds")
op.drop_index(op.f("ix_sessions_token"), table_name="sessions")
op.drop_table("sessions")
op.drop_index(op.f("ix_users_username"), table_name="users")
op.drop_index(op.f("ix_users_id"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")
# Drop ENUM types
postgresql.ENUM(name="asset_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="world_visibility").drop(op.get_bind(), checkfirst=True)
View File
+52
View File
@@ -0,0 +1,52 @@
"""Application configuration via pydantic-settings."""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# App
APP_NAME: str = "AGP Backend"
APP_VERSION: str = "0.1.0"
DEBUG: bool = True
# Database (async)
DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
# File uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
)
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@lru_cache()
def get_settings() -> Settings:
"""Return cached settings instance."""
return Settings()
# Convenience singleton
settings = get_settings()
+42
View File
@@ -0,0 +1,42 @@
"""Async database engine and session factory."""
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
"""Dependency that yields an async database session.
Handlers are responsible for committing or rolling back their own
transactions. This avoids the double-commit problem where the
session is committed both here and in the handler.
"""
async with async_session_factory() as session:
try:
yield session
except Exception:
await session.rollback()
raise
+55
View File
@@ -0,0 +1,55 @@
"""Shared FastAPI dependencies, including JWT-based auth."""
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Resolve the JWT bearer token to a User, or raise 401."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
try:
user_id_int = int(user_id)
except (ValueError, TypeError):
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id_int))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive",
)
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
+46
View File
@@ -0,0 +1,46 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown events."""
# Startup: initialize database, connect to Redis, etc.
yield
# Shutdown: close connections, cleanup
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Agentic Game Platform - Backend API",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
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.get("/api/health")
async def health_check():
"""Simple health check endpoint."""
return {"status": "ok", "app": settings.APP_NAME}
+6
View File
@@ -0,0 +1,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
__all__ = ["User", "Session", "World", "Asset"]
+40
View File
@@ -0,0 +1,40 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class AssetType(str, enum.Enum):
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
DOCUMENT = "document"
MODEL_3D = "model_3d"
OTHER = "other"
class Asset(Base):
__tablename__ = "assets"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
asset_type = Column(
ENUM(AssetType, name="asset_type", create_type=False),
nullable=False,
)
file_path = Column(String(512), nullable=False)
file_size = Column(Integer, nullable=True)
mime_type = Column(String(128), nullable=True)
metadata_json = Column(Text, nullable=True)
world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
is_public = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
owner = relationship("User", back_populates="assets")
world = relationship("World", back_populates="assets")
+20
View File
@@ -0,0 +1,20 @@
import uuid
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Session(Base):
__tablename__ = "sessions"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token = Column(String, unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions")
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
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)
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())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class WorldVisibility(enum.Enum):
PUBLIC = "public"
PRIVATE = "private"
FRIENDS_ONLY = "friends_only"
class World(Base):
__tablename__ = "worlds"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
visibility = Column(
ENUM(WorldVisibility, name="world_visibility", create_type=False),
default=WorldVisibility.PUBLIC,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
owner = relationship("User", back_populates="worlds")
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
View File
+192
View File
@@ -0,0 +1,192 @@
"""Asset management router."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType
from app.models.world import World
from pydantic import BaseModel, Field
router = APIRouter()
# --- Pydantic schemas ---
class AssetCreate(BaseModel):
"""Schema for creating an asset reference."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
asset_type: AssetType
world_id: Optional[int] = None
class AssetResponse(BaseModel):
"""Schema for asset responses."""
id: int
name: str
description: Optional[str]
asset_type: AssetType
file_path: str
file_size: Optional[int]
mime_type: Optional[str]
world_id: Optional[int]
owner_id: int
is_public: bool
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class AssetUpdate(BaseModel):
"""Schema for updating an asset."""
name: Optional[str] = None
description: Optional[str] = None
is_public: Optional[bool] = None
# --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201)
async def create_asset(
payload: AssetCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Create a new asset reference."""
# Verify world ownership if world_id is provided
if payload.world_id is not None:
world = await db.get(World, payload.world_id)
if world is None:
raise HTTPException(status_code=404, detail="World not found")
if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world")
asset = Asset(
name=payload.name,
description=payload.description,
asset_type=payload.asset_type,
file_path="", # Will be set after upload
owner_id=current_user.id,
world_id=payload.world_id,
is_public=False,
)
db.add(asset)
await db.commit()
await db.refresh(asset)
return asset
@router.get("/", response_model=list[AssetResponse])
async def list_assets(
current_user: CurrentUser,
world_id: Optional[int] = Query(None, description="Filter by world"),
asset_type: Optional[AssetType] = Query(None),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
"""List assets with optional filters."""
stmt = select(Asset).where(Asset.owner_id == current_user.id)
if world_id is not None:
stmt = stmt.where(Asset.world_id == world_id)
if asset_type is not None:
stmt = stmt.where(Asset.asset_type == asset_type)
stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
assets = result.scalars().all()
return assets
@router.get("/{asset_id}", response_model=AssetResponse)
async def get_asset(
asset_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Get a single asset by ID."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.patch("/{asset_id}", response_model=AssetResponse)
async def update_asset(
asset_id: int,
payload: AssetUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Update an asset."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(asset, field, value)
await db.commit()
await db.refresh(asset)
return asset
@router.delete("/{asset_id}", status_code=204)
async def delete_asset(
asset_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Delete an asset."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
await db.delete(asset)
await db.commit()
@router.post("/{asset_id}/upload")
async def upload_asset_file(
asset_id: int,
file: UploadFile = File(...),
current_user: CurrentUser = None,
db: AsyncSession = Depends(get_db),
):
"""Upload a file for an existing asset."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
# Sanitize filename to prevent path traversal
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else f"asset_{asset_id}"
file_path = f"uploads/{asset_id}_{safe_filename}"
# 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",
)
# TODO: Implement actual file write logic (S3, local filesystem, etc.)
asset.file_path = file_path
asset.mime_type = file.content_type
asset.file_size = file_size
await db.commit()
await db.refresh(asset)
return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path}
+131
View File
@@ -0,0 +1,131 @@
"""Authentication router - login, register, token refresh."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
from jose import jwt
from passlib.context import CryptContext
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
# --- Pydantic schemas ---
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
class UserResponse(BaseModel):
id: int
username: str
email: str
is_active: bool
created_at: datetime | None = None
model_config = {"from_attributes": True}
# --- Helpers ---
def _create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
# --- 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."""
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already registered",
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == req.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
user = User(
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
@router.post("/login", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token.
Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
"""
result = await db.execute(select(User).where(User.username == form.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive",
)
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
@router.get("/me", response_model=UserResponse)
async def get_current_user(current_user: CurrentUser):
"""Get current user profile from the JWT token."""
return current_user
+105
View File
@@ -0,0 +1,105 @@
"""User management router."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User
router = APIRouter()
# ─── Pydantic schemas ────────────────────────────────────────────────────────
class UserResponse(BaseModel):
id: int
username: str
email: str
is_active: bool
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
email: EmailStr | None = None
is_active: bool | None = None
# ─── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(
current_user: CurrentUser,
):
"""Get the currently authenticated user's profile."""
return current_user
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a user by ID. Owners and admins can see any user; others see
only their own profile."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Allow viewing own profile or if user is active (admins always allowed)
if user.id != current_user.id and not current_user.is_active:
raise HTTPException(status_code=403, detail="Not authorized to view this user")
return user
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_update: UserUpdate,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a user. Only the owner (or an active admin) can update."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id != current_user.id and not current_user.is_active:
raise HTTPException(status_code=403, detail="Not authorized to update this user")
if user_update.email is not None:
user.email = user_update.email
if user_update.is_active is not None:
user.is_active = user_update.is_active
await db.commit()
await db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Delete a user. Only the owner (or an active admin) can delete."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id != current_user.id and not current_user.is_active:
raise HTTPException(status_code=403, detail="Not authorized to delete this user")
await db.delete(user)
await db.commit()
+162
View File
@@ -0,0 +1,162 @@
"""Worlds router - CRUD for game worlds."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.world import World, WorldVisibility
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# --- Pydantic schemas ---
class WorldCreate(BaseModel):
"""Schema for creating a world."""
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility = WorldVisibility.PUBLIC
class WorldUpdate(BaseModel):
"""Schema for updating a world."""
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility | None = None
class WorldResponse(BaseModel):
"""Schema for world responses."""
id: int
name: str
description: str | None
visibility: WorldVisibility
owner_id: int
created_at: str | None = None
model_config = {"from_attributes": True}
# --- Endpoints ---
@router.get("/", response_model=list[WorldResponse])
async def list_worlds(
current_user: CurrentUser,
db: DbDep,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
visibility: WorldVisibility | None = None,
):
"""List worlds visible to the current user.
PUBLIC worlds are visible to everyone.
PRIVATE/FRIENDS_ONLY worlds are visible to their owner.
"""
query = select(World).options(selectinload(World.owner))
# Filter by visibility if specified
if visibility:
query = query.where(World.visibility == visibility)
else:
# Show PUBLIC worlds + owner's own worlds
query = query.where(
(World.visibility == WorldVisibility.PUBLIC)
| (World.owner_id == current_user.id)
)
query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
result = await db.execute(query)
worlds = result.scalars().all()
return worlds
@router.get("/{world_id}", response_model=WorldResponse)
async def get_world(
world_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Get a single world by ID, respecting visibility."""
result = await db.execute(
select(World).where(World.id == world_id).options(selectinload(World.owner))
)
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
# Allow access if world is PUBLIC or owned by current user
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="World not found")
return world
@router.post("/", response_model=WorldResponse, status_code=201)
async def create_world(
world_data: WorldCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new world."""
world = World(
name=world_data.name,
description=world_data.description,
owner_id=current_user.id,
visibility=world_data.visibility,
)
db.add(world)
await db.commit()
await db.refresh(world)
return world
@router.put("/{world_id}", response_model=WorldResponse)
async def update_world(
world_id: int,
world_data: WorldUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update an existing world."""
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world")
update_data = world_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(world, field, value)
await db.commit()
await db.refresh(world)
return world
@router.delete("/{world_id}", status_code=204)
async def delete_world(
world_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Delete a world."""
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world")
await db.delete(world)
await db.commit()
+34
View File
@@ -0,0 +1,34 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: agp_user
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agp_user -d agp_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
+17
View File
@@ -0,0 +1,17 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg2-binary==2.9.10
asyncpg==0.31.0
redis==5.2.1
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
email-validator==2.2.0
httpx==0.28.1
pytest==8.3.4
pytest-asyncio==0.25.0
pytest-cov==6.0.0
-369
View File
@@ -1,369 +0,0 @@
# Editor Architecture: Godot Editor Plugin vs. In-Game Editor
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** World chunk data format & persistence, server authority + client
> prediction, content moderation & age verification, avatar component system
> **Godot version:** 4.6.2 stable
## 1. Overview
This document evaluates the two primary ways to build the **world/level
authoring tool** for **agp-game**:
- **(a) Godot editor plugin** — extend the Godot editor itself with custom
panels, dock widgets, and tools that operate on the project's scenes and
resources.
- **(b) In-game editor** — a fully custom editor built as game UI (Godot
`Control` nodes) that runs inside the running game, editing the game's own
world data.
The goal is to pick the approach that best fits the platform's constraints:
a **server-authoritative** multiplayer game where the **world is stored as
versioned chunks** (see `world.md`), content is **moderation-flagged** (see
`content_moderation.md`), and the client is a **shipped binary** that players
run (not a dev tool).
This document is a **decision record**: it lays out the trade-offs, gives a
clear recommendation, and defines the resulting architecture boundaries.
---
## 2. Context & Constraints
Before comparing the two approaches, we must fix the constraints that drive the
decision. These come from the existing architecture docs.
### 2.1 The World Is Data, Not Scenes
`world.md` defines the world as **chunk data** (JSON for authoring, binary for
runtime/network), persisted in SQLite on the server. A chunk is the atomic unit
of load, save, and stream. The **server is the only writer** to the store;
clients submit **deltas** against a `base_revision` and the server validates and
applies them.
**Implication:** The editor's output is **chunk payloads**, not `.tscn` scene
files. Whatever editor we build must serialize to the chunk contract, not to
Godot scene resources.
### 2.2 Server-Authoritative & Moderation-Aware
- The server validates every edit (schema, bounds, moderation) before
persisting (`world.md` §5.4).
- Content carries **content flags** aggregated from referenced assets/scripts
(`content_moderation.md` §3). Mature content is gated behind age verification.
- The client receives a **content manifest** and never loads content not in it.
**Implication:** An editor that runs on a **player's machine** cannot be the
authority on what gets published. Editing must go through the server's
validation pipeline, and the editor must surface moderation flags.
### 2.3 The Client Is a Shipped Binary
The game is a multiplayer product. Players run a **release build**. The Godot
editor is a **development tool** that players do not have.
**Implication:** If authoring is a first-class *player* feature (user-generated
content), the editor must ship inside the game binary. If authoring is a
*developer/platform* feature, it can live in the editor.
### 2.4 Two Distinct Audiences
The evaluation must separate two audiences, because they have opposite needs:
| Audience | Who | Where they run | What they build |
|----------|-----|----------------|-----------------|
| **Platform/dev** | The team building the game | Godot editor (dev machines) | Base worlds, rooms, templates, test levels |
| **Players (UGC)** | End users | The shipped game | Their own rooms/worlds, published to the server |
The two approaches map cleanly onto these audiences, and the right answer is
likely **both** — but with a clear primary recommendation.
---
## 3. Approach (a): Godot Editor Plugin
### 3.1 What It Is
A Godot editor plugin is a set of scripts (and optionally scenes) that extend
the editor via the `EditorPlugin` API. It can add:
- **Dock panels** (`add_control_to_dock`) — e.g., a chunk/tile palette panel.
- **Toolbar buttons** (`add_control_to_container`).
- **Inspector plugins** (`EditorInspectorPlugin`) — custom property editors.
- **Import plugins** (`EditorImportPlugin`) — custom asset importers.
- **Scene/Node plugins** (`EditorNode3DGizmoPlugin`) — custom gizmos.
- **Menu entries** (`add_tool_menu_item`).
Plugins are enabled per-project via `project.godot` (`[editor_plugins]`).
### 3.2 Pros
- **Full editor power for free:** You get the scene tree, inspector, viewport
navigation, undo/redo, gizmos, and asset pipeline — all battle-tested.
- **Native Godot editing:** Editing `.tscn` scenes, materials, and nodes is
exactly what the editor is built for. Great for authoring **base content**
(rooms, templates, avatar base bodies).
- **Fast iteration for devs:** The platform team can build and test levels
directly in the tool they already use.
- **Undo/redo, selection, snapping** come from the editor's `EditorUndoRedoManager`.
- **No runtime footprint:** Editor-only code is stripped from export builds
(`@tool` scripts and editor plugins are not shipped to players).
### 3.3 Cons
- **Not available to players.** Players don't run the Godot editor. Any
player-facing UGC authoring cannot use this approach.
- **Tied to the editor's UX.** You inherit the editor's look and interaction
model; deep customization is limited by what the editor API exposes.
- **Editor API churn.** `EditorPlugin` APIs change between Godot versions;
plugins are more brittle across upgrades than game code.
- **Scene-centric, not data-centric.** The editor is optimized for scenes and
resources. Our world is **chunk data** (`world.md`). We'd have to build a
translation layer (scene ↔ chunk) and keep it in sync — a real source of
drift.
- **Single-user, local.** The editor edits local files. It does not natively
talk to our server-authoritative chunk store or the moderation pipeline.
- **Headless/CI friction.** Validating plugin behavior in CI requires running
the editor headless, which is heavier than testing game code.
### 3.4 Fit for Our Constraints
- **Platform/dev authoring:** Excellent. This is the natural home for building
base worlds, rooms, and templates.
- **Player UGC:** Not applicable. Players cannot run the editor.
- **Server-authoritative chunk store:** Poor fit natively. The editor edits
local scenes; we'd bolt on a chunk export/import and a server sync layer.
---
## 4. Approach (b): In-Game Editor
### 4.1 What It Is
A fully custom editor built as **game UI** — Godot `Control` nodes (panels,
buttons, toolbars) that run inside the running game. It edits the game's own
world data model (chunks) and submits edits through the normal server pipeline.
### 4.2 Pros
- **Ships to players.** The editor is part of the game binary, so **player UGC
authoring is possible** — the core requirement for a user-generated-content
platform.
- **Data-native.** The editor operates directly on the **chunk model**
(`world.md`), so there is no scene↔chunk translation layer and no drift.
- **Server-authoritative by construction.** Edits go through the same
`request_chunk_edit` RPCs as gameplay, so validation, revisioning, and
moderation apply uniformly. The editor cannot bypass the server.
- **Moderation-aware.** Because edits flow through the server, content flags are
computed and enforced at the boundary (`content_moderation.md` §4). The editor
can surface flags and gate mature content.
- **One codebase, one UX.** The editor uses the same UI toolkit and art style as
the game; no separate editor theme to maintain.
- **Testable in CI.** The editor is ordinary game code; it can be exercised
headlessly and unit-tested like any other system.
### 4.3 Cons
- **We build everything.** No free scene tree, inspector, gizmos, or undo/redo.
We must implement selection, snapping, undo/redo, viewport navigation, and
property editing ourselves.
- **Runtime footprint.** Editor UI ships in the binary (though it can be gated
behind a feature flag / separate scene that is only instantiated on demand).
- **More upfront work.** A usable in-game editor is a large feature; it needs
its own architecture (tool modes, gizmos, palette, inspector).
- **Not ideal for deep asset authoring.** Modeling a mesh or authoring a
material is better done in the Godot editor / DCC tools. The in-game editor
should focus on **world composition** (placing tiles and objects), not asset
creation.
### 4.4 Fit for Our Constraints
- **Platform/dev authoring:** Workable but not ideal for deep asset work.
- **Player UGC:** The **only** viable approach. Players can only author through
the shipped game.
- **Server-authoritative chunk store:** Excellent fit. The editor is a client
that submits validated deltas.
---
## 5. Comparison Matrix
| Criterion | (a) Editor plugin | (b) In-game editor |
|-----------|-------------------|--------------------|
| Ships to players | ❌ No | ✅ Yes |
| Player UGC authoring | ❌ Impossible | ✅ Core capability |
| World data model fit | ⚠️ Scene-centric; needs translation layer | ✅ Chunk-native |
| Server-authoritative edits | ⚠️ Bolted on | ✅ Native (same RPCs) |
| Moderation enforcement | ⚠️ Manual | ✅ At the boundary |
| Free editor features (undo, gizmos, inspector) | ✅ Rich | ❌ Build ourselves |
| Runtime footprint | ✅ None (stripped from export) | ⚠️ Ships in binary (gated) |
| Dev iteration speed | ✅ Fast | ⚠️ Slower to build |
| Deep asset authoring (mesh/material) | ✅ Native | ❌ Out of scope |
| CI testability | ⚠️ Heavier (headless editor) | ✅ Ordinary game code |
| Editor API churn risk | ⚠️ Higher | ✅ None (game code) |
---
## 6. Recommendation
**Primary recommendation: build the in-game editor (b) as the platform's
authoring surface, and use a lightweight Godot editor plugin (a) only as an
internal dev convenience.**
### 6.1 Rationale
1. **Player UGC is the deciding factor.** The platform's value proposition is
user-generated content (rooms, worlds, avatars). Players cannot run the Godot
editor, so **only an in-game editor can deliver player authoring**. This is a
hard requirement that the editor-plugin approach cannot meet.
2. **The world is data, and the in-game editor is data-native.** `world.md`
defines chunks as the atomic unit. An in-game editor edits chunks directly
and submits them through the server pipeline. An editor plugin would force a
scene↔chunk translation layer that is a constant source of drift and bugs.
3. **Server authority and moderation fall out naturally.** Because in-game
edits use the same `request_chunk_edit` RPCs as gameplay, validation,
revisioning, optimistic concurrency, and content-flag enforcement
(`content_moderation.md`) apply uniformly. The editor cannot bypass the
server — which is exactly what a moderation-aware UGC platform needs.
4. **The editor-plugin's strengths are not what we need.** Its free features
(scene tree, inspector, gizmos) are for editing Godot scenes and resources.
Our authoring is world composition against a chunk contract, not scene
editing. The plugin's main win — deep asset authoring — is better served by
DCC tools and the Godot editor for the *platform team*, not by a plugin we
must maintain.
### 6.2 The Role of the Editor Plugin (Dev Convenience Only)
We still recommend a **small, optional** editor plugin for the **platform team**
to author **base content** (base rooms, templates, avatar base bodies, test
levels) directly in the Godot editor. This is a dev-time convenience, not a
product feature:
- It can export a scene/resource as a **chunk** (reusing the same encoder as the
in-game editor) so dev-authored content flows into the same pipeline.
- It is **not shipped** to players and is **not** the authoring surface for UGC.
- It should be kept thin to minimize editor-API churn risk.
### 6.3 Scope Boundary
To keep the in-game editor tractable, define its scope clearly:
- **In scope (world composition):** placing tiles, placing static/dynamic
objects, setting transforms, editing object properties, saving/loading chunks,
submitting edits to the server, showing moderation flags.
- **Out of scope (asset creation):** modeling meshes, authoring materials,
rigging avatars. These are done in DCC tools / the Godot editor and imported
as catalog assets (see `avatar.md` §7). The in-game editor **references**
catalog assets by id; it does not create them.
---
## 7. Resulting Architecture
### 7.1 Shared Editor Core
Both the in-game editor and the (optional) dev plugin should share a single
**editor core** that operates on the chunk model. This avoids two divergent
implementations.
```
EditorCore (shared, data-native)
├── ChunkEditorModel # in-memory chunk being edited (tiles, objects, flags)
├── ChunkEncoder # JSON/binary encode/decode (world.md §3)
├── EditSession # base_revision tracking, delta generation
├── EditSubmitter # request_chunk_edit RPC wrapper + conflict handling
├── UndoRedoStack # command pattern over chunk mutations
├── SelectionManager # selected tiles/objects
├── ToolRegistry # brush, object-place, select, erase, etc.
└── ModerationView # surfaces content flags; gates mature content
```
### 7.2 In-Game Editor (Primary)
```
InGameEditor (Control-based UI, shipped in binary, gated by feature flag)
├── EditorViewport # custom 2D/3D viewport with camera navigation
├── Toolbar # tool mode switcher
├── PalettePanel # tile/object palette (from catalog)
├── InspectorPanel # property editor for selected object
├── ChunkBrowser # load/save chunks, list rooms
└── EditorCore # shared core (above)
```
- Instantiated only when the player enters "build mode" (a separate scene /
feature flag), so the runtime footprint is negligible during normal play.
- Submits edits via `request_chunk_edit`; handles `chunk_edit_result` conflicts
by re-fetching and re-applying (`world.md` §5.4).
### 7.3 Dev Editor Plugin (Optional, Thin)
```
DevChunkPlugin (EditorPlugin, dev-only, not shipped)
├── Dock panel: "Chunk Export"
├── Reads the current scene / selected nodes
├── Maps scene content → chunk model (via EditorCore.ChunkEncoder)
└── Exports a .chunk.json / submits to a dev server
```
Kept thin to minimize editor-API churn. Its only job is to let the platform team
author base content in the editor and push it into the same chunk pipeline.
### 7.4 Data Flow
```
[In-game editor] --request_chunk_edit(delta, base_revision)--> [Server]
^ | validate
| | apply
+--chunk_edit_result(ok/new_revision | conflict)-----------| persist
v
SQLite (world.db)
[Dev plugin] --export chunk JSON--> [EditorCore.ChunkEncoder] --> [Server]
```
Both paths converge on the **same server validation pipeline**, so moderation
and revisioning are uniform regardless of authoring surface.
---
## 8. Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| In-game editor is a large build | Scope it to world composition (§6.3); reuse EditorCore; ship behind a feature flag |
| Custom viewport/gizmos are hard | Start with a 2D tile editor (matches `CHUNK_SIZE_Z=1` default); add 3D later |
| Undo/redo complexity | Use a command pattern over chunk mutations; test headlessly |
| Editor-plugin API churn | Keep the plugin thin; treat it as dev-only convenience, not a product surface |
| Drift between dev plugin and in-game editor | Both use the same `EditorCore`/`ChunkEncoder` |
| Players bypass moderation | Impossible by construction: edits go through server validation |
---
## 9. Open Questions / Future Work
- **3D editing:** The default world is flat (`CHUNK_SIZE_Z=1`). A 3D in-game
editor (volumetric chunks) is future work (`world.md` §8).
- **Collaborative editing:** Multiple players editing the same chunk concurrently
— the optimistic-concurrency model already supports it, but the UX (live
cursors, conflict resolution) is unspecified.
- **Template library:** A curated set of room templates players can start from.
- **Publish/review flow:** How a player's authored room enters the moderation
review queue (`content_moderation.md` §6) and becomes publicly joinable.
---
## 10. Summary
| Concern | Decision |
|---------|----------|
| Primary authoring surface | **In-game editor** (ships to players, enables UGC) |
| Dev authoring convenience | Thin, optional **editor plugin** (not shipped) |
| World data model | Both operate on the **chunk model** via a shared `EditorCore` |
| Server authority | Edits go through `request_chunk_edit`; server validates |
| Moderation | Enforced at the server boundary for both paths |
| In-game editor scope | World composition only; asset creation is out of scope |
| Runtime footprint | Editor gated behind a feature flag / separate scene |
+24
View File
@@ -0,0 +1,24 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; but it can also be edited by hand.
config_version=5
[application]
config/name="AGP-Game"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6.2")
[autoload]
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
View File
+43
View File
@@ -0,0 +1,43 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
[node name="MainMenu" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_main")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
layout_mode = 1
theme_override_constants/separation = 16
size_flags_horizontal = 4
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "AGP Game"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 72
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Play"
size_flags_horizontal = 4
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Quit"
size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
View File
+14
View File
@@ -0,0 +1,14 @@
extends Control
func _ready():
pass
func _on_play_button_pressed():
# No gameplay scene exists yet — wire this to the real scene once one lands.
print("Play pressed — no gameplay scene yet")
func _on_quit_button_pressed():
get_tree().quit()