diff --git a/.gitignore b/.gitignore index 2cb0080..98542a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Godot editor cache / generated files +# Godot 4 editor cache/import directory .godot/ # Godot export artifacts diff --git a/addons/.gitkeep b/addons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cc0bd94 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fc36f7d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.pyc +.venv/ diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..47488b0 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..d9411ca --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..d5a44c8 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..a764b9a --- /dev/null +++ b/backend/alembic/versions/0001_initial.py @@ -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) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..af23b68 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..9d917b2 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..f388f5d --- /dev/null +++ b/backend/app/dependencies.py @@ -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)] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1260c12 --- /dev/null +++ b/backend/app/main.py @@ -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} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..96e2dc3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py new file mode 100644 index 0000000..2955515 --- /dev/null +++ b/backend/app/models/asset.py @@ -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") diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..7a7d33a --- /dev/null +++ b/backend/app/models/session.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ff48caa --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/models/world.py b/backend/app/models/world.py new file mode 100644 index 0000000..d10e896 --- /dev/null +++ b/backend/app/models/world.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py new file mode 100644 index 0000000..af9c2be --- /dev/null +++ b/backend/app/routers/assets.py @@ -0,0 +1,209 @@ +"""Asset management router.""" + +import os +from pathlib import Path +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 + + +# --- Helpers --- + +def _sanitize_filename(filename: str) -> str: + """Strip path components and unsafe characters from a filename.""" + # Remove any directory components (path traversal protection). + base = os.path.basename(filename.replace("\\", "/")) + # Keep only safe characters. + safe = "".join(c for c in base if c.isalnum() or c in "._-") + return safe or "file" + + +# --- 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", response_model=AssetResponse) +async def upload_asset_file( + asset_id: int, + current_user: CurrentUser, + file: UploadFile = File(...), + 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 = _sanitize_filename(file.filename or f"asset_{asset_id}") + + # 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", + ) + + # Write the file to the upload directory. + upload_dir = Path(settings.UPLOAD_DIR) + upload_dir.mkdir(parents=True, exist_ok=True) + stored_name = f"{asset_id}_{safe_filename}" + file_path = upload_dir / stored_name + file_path.write_bytes(content) + + asset.file_path = str(file_path) + asset.mime_type = file.content_type + asset.file_size = file_size + + await db.commit() + await db.refresh(asset) + return asset diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..5059174 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..6ba19b5 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,106 @@ +"""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. Only the owner can view 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") + + # Only the owner may view their own profile. + if user.id != current_user.id: + 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 can update their own account.""" + 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") + + # Only the owner may update their own account. + if user.id != current_user.id: + 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 can delete their own account.""" + 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") + + # Only the owner may delete their own account. + if user.id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to delete this user") + + await db.delete(user) + await db.commit() diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py new file mode 100644 index 0000000..7f3aadd --- /dev/null +++ b/backend/app/routers/worlds.py @@ -0,0 +1,168 @@ +"""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) + if visibility != WorldVisibility.PUBLIC: + # Requesting a non-public visibility explicitly must not bypass + # ownership — otherwise any authenticated user could list every + # PRIVATE/FRIENDS_ONLY world in the system just by passing the + # filter, defeating the point of the check below. + query = query.where(World.owner_id == current_user.id) + 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() diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..c9698e7 --- /dev/null +++ b/backend/docker-compose.yml @@ -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: diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5cb3fb3 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docs/architecture/editor.md b/docs/architecture/editor.md new file mode 100644 index 0000000..23f700d --- /dev/null +++ b/docs/architecture/editor.md @@ -0,0 +1,369 @@ +# 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 | diff --git a/docs/architecture/scripting_sandbox.md b/docs/architecture/scripting_sandbox.md new file mode 100644 index 0000000..c1ebc97 --- /dev/null +++ b/docs/architecture/scripting_sandbox.md @@ -0,0 +1,159 @@ +# GDScript Sandboxing for User-Written Scripts + +> **Status:** Proposed +> **Owner:** Platform team +> **Related:** Content moderation & age verification (scripts are one of the +> three moderated content types), avatar component system + +## 1. Overview + +This document evaluates how to safely run **user-written scripts** as part of +UGC (user-generated content) in **agp-game**, and recommends an approach. + +**Bottom line: Godot 4 has no built-in security sandbox for GDScript, and +none of the three options named in the original task hold up under testing.** +A script running anywhere inside the Godot process — however it's loaded, +parented, or wrapped — runs with full engine privileges: arbitrary process +execution, full filesystem read/write, and raw network access. Sandboxing +has to happen *outside* the scripting language, not through an in-engine +restriction mechanism, because no such mechanism exists. + +This was verified directly against Godot 4.6.2 (`godot --headless`, evidence +in `sandbox_probe/` on this branch) rather than assumed from documentation, +some of which (see §2.1) describes APIs that don't actually exist in this +version. + +## 2. Options Evaluated + +### 2.1 Godot's built-in "ScriptServer" restrictions — does not exist + +There is no `ScriptServer` class in Godot 4.6.2: + +``` +ClassDB.class_exists("ScriptServer") → false +``` + +There's also no project setting to disable engine subsystems for scripts — +none of `application/run/disable_os`, `disable_file_access`, or +`disable_network` exist as settings, and `ProjectSettings.get_property_list()` +returns nothing matching "sandbox", "restrict", or "security". Any plan that +assumes a flag like this exists needs to be dropped; it was likely conflated +with sandboxing features from a different engine/scripting ecosystem, or +with **export template stripping** (which controls what ships in a build, +not what a script can call at runtime). + +Directly probed capabilities of an ordinary GDScript running as a normal +node script, no special setup: + +| Capability | Available to any script? | +|---|---| +| `OS.execute(...)` — run arbitrary processes | ✅ Yes | +| `FileAccess.open(...)` — read/write any path the OS user can reach | ✅ Yes (read `/etc/hostname`, write `/tmp/...` both succeeded) | +| `TCPServer` / `HTTPClient` / `HTTPRequest` / `PacketPeerUDP` / `WebSocketPeer` | ✅ All instantiate and work | +| `ClassDB.instantiate(...)` — construct any engine class by name | ✅ Yes | +| Loading and running a second script from source text at runtime (`GDScript.new()` + `.reload()`) | ✅ Yes | + +**Verdict: rejected.** There is nothing to configure here — the restriction +surface this option assumed doesn't exist in the engine. + +### 2.2 Isolated SubViewport with a limited API — doesn't isolate scripts + +`SubViewport` is a **rendering and input isolation** primitive (it gives a +scene subtree its own render target and can gate input propagation) — it is +not a security or scripting boundary. A script attached to any node inside a +`SubViewport` still runs in the same process, on the same GDScript VM, with +the exact same access to `OS`, `FileAccess`, networking classes, and +`ClassDB` as a script anywhere else in the tree. Nothing about being +"inside" a SubViewport changes what API calls are reachable from a script. + +**Verdict: rejected.** This solves a rendering/UI-composition problem, not +the security problem the task is asking about. Worth keeping in mind for +other purposes (e.g. isolated preview rendering of user avatars), but it +provides zero script sandboxing. + +### 2.3 Custom GDScript interpreter with an allowlist — right idea, wrong layer for "custom interpreter" + +Two ways to read this option, tested separately: + +**(a) Write/embed a separate GDScript parser+interpreter.** Not +practical — this means re-implementing (and then indefinitely maintaining, +in lockstep with every Godot release) a full language interpreter outside +the engine. Far more engineering than the problem justifies. + +**(b) Use Godot's built-in `Expression` class as a restricted subset.** +`Expression` *looks* like a natural allowlist mechanism — it only evaluates +a single expression, no control flow, no arbitrary statements. But it isn't +safe by default: it can still reach through to unsafe engine methods if you +aren't careful about what object you hand it as a base instance: + +```gdscript +var expr = Expression.new() +expr.parse("get_environment('HOME')", ["OS"]) +expr.execute([], OS) # → "/home/daddy" (successfully called OS.get_environment) +``` + +So `Expression` is a viable building block only if the host **never passes +a base instance (or arguments) that exposes anything sensitive** — i.e. the +allowlisting work still has to happen at the call site, `Expression` itself +enforces none of it. + +**Static text pattern-matching (blocklisting dangerous substrings like +`"OS.execute"` before parsing) was also probed and is not a real +defense** — it's trivially defeated by string concatenation, `call()`, +`Callable`, or any other indirection, and produces false confidence. + +**Verdict: partially viable, but only as a foundation, not a solution on +its own.** A hand-built, narrow, data-driven DSL (not full GDScript, not +raw `Expression`) with an explicit allowlist of safe operations and *no +ability to reference engine singletons or arbitrary objects* is the only +version of "custom interpreter" that's actually safe. This is real +engineering effort, not a config flag. + +## 3. Recommendation + +**Don't run untrusted GDScript in-process at all.** Given none of the three +in-engine options provide a real boundary, the two approaches actually worth +building are, in order of recommended priority: + +1. **Primary: a narrow, non-Turing-complete content DSL**, not general + scripting. Most UGC scripting needs in a game like this (custom + interactions, simple triggers, item behaviors) don't need a general + programming language — they need a small set of declarative + verbs/conditions (e.g. `on_interact: give_item("torch")`, + `on_enter: play_sound("chime")`). Expressed as data (JSON/Resource, not + code) and interpreted by trusted, hand-written GDScript on the engine + side. This has *zero* attack surface by construction — there's no + interpreter to escape because there's no general execution, just a + fixed, reviewed set of verbs. This is almost certainly sufficient for + the actual UGC use cases in this game and should be scoped against real + feature requirements before assuming more is needed. + +2. **If genuinely general scripting becomes a hard requirement**, isolate it + at the OS level, not the engine level: run untrusted scripts in a + **separate process** (a stripped-down Godot instance or a dedicated + interpreter) under OS sandboxing (a restricted user account, a + container, or `firejail`/`bwrap`/seccomp), communicating with the main + game over a narrow IPC/RPC boundary that only exposes intentionally-safe + operations. This is the only approach that provides a real security + boundary for arbitrary code, because it doesn't depend on the scripting + language cooperating — the OS enforces it. Substantially more + infrastructure than option 1; only worth it if option 1's DSL genuinely + can't express what's needed. + +**Do not** attempt in-process GDScript sandboxing via SubViewport, +ScriptServer, or ad hoc blocklisting — all three were directly tested and +none provide a real boundary (§2). + +## 4. Open Questions + +- What do actual planned UGC scripts need to *do*? (Trigger conditions, + simple item/state effects, dialogue branching?) This determines whether + the DSL in §3.1 is sufficient or whether §3.2's process isolation is + needed. Needs a concrete feature list before implementation starts. +- If §3.2 is needed later, does it run per-user-session or shared/pooled? + Affects resource cost and isolation guarantees. +- Interaction with the content moderation pipeline (`content_moderation.md`): + scripts as a moderated content type still need a moderation flag path + regardless of which execution model is chosen — a DSL makes static + analysis of *what a script can possibly do* far easier than it would be + for general code, which is an additional point in its favor. diff --git a/project.godot b/project.godot index 331cf39..8dddf44 100644 --- a/project.godot +++ b/project.godot @@ -6,11 +6,19 @@ config_version=5 [application] -config/name="AGP Game" -run/main_scene="res://scenes/main.tscn" +config/name="AGP-Game" +run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6.2") +[autoload] + [display] -window/size/viewport_width=1280 -window/size/viewport_height=720 +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" diff --git a/sandbox_probe/.godot/.gdignore b/sandbox_probe/.godot/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sandbox_probe/.godot/.gdignore @@ -0,0 +1 @@ + diff --git a/sandbox_probe/probe6.gd b/sandbox_probe/probe6.gd new file mode 100644 index 0000000..df96840 --- /dev/null +++ b/sandbox_probe/probe6.gd @@ -0,0 +1,174 @@ +extends Node + +func _ready(): + print("=== SANDBOX PROBE 6: Corrected Evidence Gathering (Godot 4.6.2) ===") + probe_script_loading() + probe_os_apis() + probe_file_access() + probe_network() + probe_classdb() + probe_expression_engine() + probe_engine_singletons() + probe_worker_threads() + probe_project_settings() + probe_allowlist() + print("=== SANDBOX PROBE 6 END ===") + get_tree().quit() + +func probe_script_loading(): + print("--- Script loading ---") + var gd = GDScript.new() + gd.source_code = "extends RefCounted\nfunc hello():\n\treturn 42\n" + var err = gd.reload() + print("GDScript.reload() from source_code err=", err) + if err == OK: + var inst = gd.new() + print(" instantiated, hello()=", inst.call("hello")) + var res = load("res://test_script.gd") + print("load('res://test_script.gd') = ", res) + if res: + print(" get_source_code() available: ", res.has_method("get_source_code")) + print(" GDScript instance has 'get_ast': ", gd.has_method("get_ast")) + print(" GDScript instance has 'get_parser': ", gd.has_method("get_parser")) + print(" GDScript instance has 'get_script_method_list': ", gd.has_method("get_script_method_list")) + print(" GDScript instance has 'get_script_property_list': ", gd.has_method("get_script_property_list")) + +func probe_os_apis(): + print("--- OS APIs ---") + var out = [] + var exit_code = OS.execute("echo", ["hello"], out) + print("OS.execute('echo') exit=", exit_code, " out=", out) + print("OS.get_environment('HOME') = ", OS.get_environment("HOME")) + print("OS has 'create_process': ", OS.has_method("create_process")) + print("OS.get_cmdline_args() = ", OS.get_cmdline_args()) + print("OS.get_user_data_dir() = ", OS.get_user_data_dir()) + print("OS.get_executable_path() = ", OS.get_executable_path()) + +func probe_file_access(): + print("--- File access ---") + var f = FileAccess.open("/etc/hostname", FileAccess.READ) + print("FileAccess.open('/etc/hostname') = ", f) + if f: + print(" content: ", f.get_as_text()) + f.close() + var w = FileAccess.open("/tmp/godot_probe_write.txt", FileAccess.WRITE) + print("FileAccess.open('/tmp/...', WRITE) = ", w) + if w: + w.store_string("probe") + w.close() + print(" wrote to /tmp OK") + var dir = DirAccess.open("/") + print("DirAccess.open('/') = ", dir) + if dir: + var names = dir.get_files() + print(" root files count: ", names.size()) + var res_dir = DirAccess.open("res://") + print("DirAccess.open('res://') = ", res_dir) + var user_dir = DirAccess.open("user://") + print("DirAccess.open('user://') = ", user_dir) + +func probe_network(): + print("--- Network ---") + var tcp = TCPServer.new() + var bind_err = tcp.listen(0) + print("TCPServer.listen(0) err=", bind_err) + var http = HTTPClient.new() + print("HTTPClient instantiated: ", http != null) + var http_req = HTTPRequest.new() + print("HTTPRequest instantiated: ", http_req != null) + var udp = PacketPeerUDP.new() + print("PacketPeerUDP instantiated: ", udp != null) + var ws = WebSocketPeer.new() + print("WebSocketPeer instantiated: ", ws != null) + +func probe_classdb(): + print("--- ClassDB ---") + print("ClassDB.class_exists('OS'): ", ClassDB.class_exists("OS")) + print("ClassDB.class_exists('FileAccess'): ", ClassDB.class_exists("FileAccess")) + print("ClassDB.class_exists('TCPServer'): ", ClassDB.class_exists("TCPServer")) + print("ClassDB.class_exists('HTTPClient'): ", ClassDB.class_exists("HTTPClient")) + var class_list = ClassDB.get_class_list() + print("ClassDB.get_class_list() count: ", class_list.size()) + var os_methods = ClassDB.class_get_method_list("OS") + print("OS method count: ", os_methods.size()) + var inst = ClassDB.instantiate("Node") + print("ClassDB.instantiate('Node') = ", inst) + print("ClassDB has 'class_get_singleton': ", ClassDB.has_method("class_get_singleton")) + +func probe_expression_engine(): + print("--- Expression engine ---") + var expr = Expression.new() + var err = expr.parse("1 + 2") + print("Expression.parse('1 + 2') err=", err) + if err == OK: + var result = expr.execute() + print(" Expression.execute() = ", result) + var expr2 = Expression.new() + err = expr2.parse("OS.get_environment('HOME')") + print("Expression.parse('OS.get_environment') err=", err) + if err == OK: + var result2 = expr2.execute() + print(" Expression.execute() = ", result2) + var expr3 = Expression.new() + err = expr3.parse("get_environment('HOME')", ["OS"]) + print("Expression.parse with base_instance err=", err) + if err == OK: + var result3 = expr3.execute([], OS) + print(" Expression.execute with OS base = ", result3) + var expr4 = Expression.new() + err = expr4.parse("FileAccess.open('/etc/hostname', 1)") + print("Expression.parse('FileAccess.open') err=", err) + if err == OK: + var result4 = expr4.execute() + print(" Expression.execute() = ", result4) + var expr5 = Expression.new() + err = expr5.parse("get_tree().quit()") + print("Expression.parse('get_tree().quit()') err=", err) + if err == OK: + var result5 = expr5.execute() + print(" Expression.execute() = ", result5) + +func probe_engine_singletons(): + print("--- Engine singletons ---") + print("Engine has 'remove_singleton': ", Engine.has_method("remove_singleton")) + print("Engine has 'add_singleton': ", Engine.has_method("add_singleton")) + print("Engine.get_main_loop() = ", Engine.get_main_loop()) + +func probe_worker_threads(): + print("--- Worker threads ---") + var thread = Thread.new() + print(" Thread.new() = ", thread) + print(" WorkerThreadPool has 'add_task': ", WorkerThreadPool.has_method("add_task")) + +func probe_project_settings(): + print("--- Project settings ---") + var settings = ProjectSettings.get_property_list() + var relevant = [] + for s in settings: + var name = String(s.name) + if "sandbox" in name.to_lower() or "restrict" in name.to_lower() or "security" in name.to_lower(): + relevant.append(name) + print(" Sandbox/security project settings: ", relevant) + print(" ProjectSettings has 'application/run/disable_os': ", ProjectSettings.has_setting("application/run/disable_os")) + print(" ProjectSettings has 'application/run/disable_file_access': ", ProjectSettings.has_setting("application/run/disable_file_access")) + print(" ProjectSettings has 'application/run/disable_network': ", ProjectSettings.has_setting("application/run/disable_network")) + print(" ProjectSettings has 'set_setting': ", ProjectSettings.has_method("set_setting")) + print(" ProjectSettings has 'save': ", ProjectSettings.has_method("save")) + +func probe_allowlist(): + print("--- Allowlist / static analysis ---") + var dangerous_patterns = [ + "OS.execute", "OS.create_process", "OS.get_environment", + "FileAccess.open", "DirAccess.open", "TCPServer", "HTTPClient", + "load(", "preload(", "ClassDB", "Engine.get_main_loop", + "get_tree().quit", "ProjectSettings" + ] + var test_script = """extends Node +func _ready(): + var f = FileAccess.open("/etc/passwd", FileAccess.READ) + OS.execute("rm", ["-rf", "/"]) +""" + print(" Test script contains dangerous patterns:") + for p in dangerous_patterns: + if p in test_script: + print(" DETECTED: ", p) diff --git a/sandbox_probe/probe6.tscn b/sandbox_probe/probe6.tscn new file mode 100644 index 0000000..27f675b --- /dev/null +++ b/sandbox_probe/probe6.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://probe6.gd" id="1"] + +[node name="Probe6" type="Node"] +script = ExtResource("1") diff --git a/sandbox_probe/project.godot b/sandbox_probe/project.godot new file mode 100644 index 0000000..79c5ba9 --- /dev/null +++ b/sandbox_probe/project.godot @@ -0,0 +1,8 @@ +; Engine configuration file. +config_version=5 + +[application] + +config/name="sandbox_probe" +run/main_scene="res://probe6.tscn" +config/features=PackedStringArray("4.6") diff --git a/sandbox_probe/test_script.gd b/sandbox_probe/test_script.gd new file mode 100644 index 0000000..47cd98a --- /dev/null +++ b/sandbox_probe/test_script.gd @@ -0,0 +1,4 @@ +extends RefCounted + +func hello(): + return "from test_script.gd" diff --git a/scenes/.gitkeep b/scenes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scenes/main_menu.tscn b/scenes/main_menu.tscn new file mode 100644 index 0000000..dd4847f --- /dev/null +++ b/scenes/main_menu.tscn @@ -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"] diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd new file mode 100644 index 0000000..aa4efc5 --- /dev/null +++ b/scripts/main_menu.gd @@ -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()