Compare commits

..

1 Commits

78 changed files with 3 additions and 6618 deletions
-13
View File
@@ -1,13 +0,0 @@
# Godot 4 editor cache/import directory
.godot/
# Godot export artifacts
export/
*.tmp
# OS
.DS_Store
Thumbs.db
# Godot UID files (regenerated from path)
*.uid
View File
View File
-20
View File
@@ -1,20 +0,0 @@
# 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
@@ -1,4 +0,0 @@
.env
__pycache__/
*.pyc
.venv/
View File
-42
View File
@@ -1,42 +0,0 @@
# 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
@@ -1,65 +0,0 @@
"""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
@@ -1,27 +0,0 @@
"""${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"}
-127
View File
@@ -1,127 +0,0 @@
"""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",
create_type=False,
),
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", create_type=False,
),
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)
@@ -1,30 +0,0 @@
"""add date_of_birth column to users table
Revision ID: 0002_add_date_of_birth_to_users
Revises: 0001_initial
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0002_add_date_of_birth_to_users"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add date_of_birth column to users table."""
op.add_column(
"users",
sa.Column("date_of_birth", sa.Date(), nullable=True),
)
def downgrade() -> None:
"""Remove date_of_birth column from users table."""
op.drop_column("users", "date_of_birth")
View File
-53
View File
@@ -1,53 +0,0 @@
"""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
GLTF_MAX_FILE_SIZE_MB: int = 200
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
@@ -1,42 +0,0 @@
"""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
@@ -1,55 +0,0 @@
"""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
@@ -1,46 +0,0 @@
"""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
@@ -1,6 +0,0 @@
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
@@ -1,40 +0,0 @@
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
@@ -1,20 +0,0 @@
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")
-37
View File
@@ -1,37 +0,0 @@
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
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)
date_of_birth = Column(Date, nullable=True)
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")
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
if self.date_of_birth is None:
return False
today = date.today()
age = today.year - self.date_of_birth.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
age -= 1
return age >= 18
-31
View File
@@ -1,31 +0,0 @@
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
-243
View File
@@ -1,243 +0,0 @@
"""Asset management router."""
import json
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 app.services import gltf as gltf_service
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"
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
"""Detect the asset type from filename and content."""
if filename and gltf_service.looks_like_gltf(content, filename):
return AssetType.MODEL_3D
# Fallback: let the caller decide based on mime_type or default.
return AssetType.OTHER
# --- 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}")
# Read the file content.
content = await file.read()
file_size = len(content)
# Determine which size limit to apply.
if gltf_service.looks_like_gltf(content, file.filename):
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
else:
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
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)
# Detect asset type from content/filename.
detected_type = _detect_asset_type(file.filename, content)
# Update asset record.
asset.file_path = str(file_path)
asset.mime_type = file.content_type
asset.file_size = file_size
# If this is a glTF file, extract and store metadata.
if detected_type == AssetType.MODEL_3D:
try:
metadata = gltf_service.extract_gltf_metadata(content)
asset.metadata_json = json.dumps(metadata)
# Also set the asset type to model_3d if it was auto-detected.
if asset.asset_type == AssetType.OTHER:
asset.asset_type = AssetType.MODEL_3D
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f"Invalid glTF file: {exc}",
)
await db.commit()
await db.refresh(asset)
return asset
-177
View File
@@ -1,177 +0,0 @@
"""Authentication router - login, register, token refresh."""
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, field_validator
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
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
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)
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
Returns:
Age in whole years as of the reference date.
"""
if today is None:
today = datetime.now(timezone.utc).date()
age = today.year - birth_date.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age
# --- 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.
Requires date_of_birth and rejects users under 18.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
)
# 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),
date_of_birth=dob,
)
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
-106
View File
@@ -1,106 +0,0 @@
"""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()
-168
View File
@@ -1,168 +0,0 @@
"""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()
View File
-197
View File
@@ -1,197 +0,0 @@
"""Lightweight glTF / GLB metadata extraction.
This module intentionally avoids heavyweight dependencies such as trimesh or
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
vertex counts, animation presence, texture references, and the bounding box)
a small amount of glTF-specific parsing is sufficient and keeps the backend
dependency-free.
"""
from __future__ import annotations
import json
import struct
from typing import Any, Optional
# GLB binary header magic bytes ("glTF").
_GLB_MAGIC = b"glTF"
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
# but be lenient with null bytes and other whitespace that real exporters emit.
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
if content.startswith(_GLB_MAGIC):
return True
if filename:
lowered = filename.lower()
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
return True
# A bare JSON buffer (e.g. uploaded without an extension) that has the
# glTF asset marker is also a glTF file.
stripped = content.lstrip()
if stripped.startswith(b"{"):
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return False
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
return False
def _parse_glb(content: bytes) -> dict[str, Any]:
"""Parse the JSON chunk of a GLB binary container."""
if len(content) < 12:
raise ValueError("GLB file is too short")
magic, _version, _length = struct.unpack_from("<III", content, 0)
if magic != _GLB_MAGIC_INT:
raise ValueError("invalid GLB magic")
offset = 12
json_chunk: Optional[dict[str, Any]] = None
while offset + 8 <= len(content):
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
offset += 8
chunk_end = offset + chunk_length
if chunk_end > len(content):
raise ValueError("GLB chunk overruns file boundary")
chunk_data = content[offset:chunk_end]
if chunk_type == _GLB_JSON_CHUNK_TYPE:
try:
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
offset = chunk_end
if json_chunk is None:
raise ValueError("GLB file does not contain a JSON chunk")
return json_chunk
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
"""Parse a plain .gltf JSON file."""
stripped = content.lstrip()
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid glTF JSON: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError("glTF JSON root must be an object")
return payload
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
"""Return POSITION accessor counts for every mesh primitive."""
counts: list[int] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
count = accessor.get("count")
if isinstance(count, int):
counts.append(count)
return counts
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
"""Return min/max bounds for every POSITION accessor that declares them."""
bounds: list[list[float]] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
minimum = accessor.get("min")
maximum = accessor.get("max")
if isinstance(minimum, list) and isinstance(maximum, list):
bounds.append([float(x) for x in minimum])
bounds.append([float(x) for x in maximum])
return bounds
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
"""Collect external texture source references from the image array."""
refs: list[str] = []
for index, image in enumerate(gltf.get("images", [])):
uri = image.get("uri")
if isinstance(uri, str) and not uri.startswith("data:"):
refs.append(uri)
continue
name = image.get("name")
if isinstance(name, str) and name:
refs.append(name)
continue
if isinstance(uri, str):
# Embedded data URI; keep a stable, non-huge reference.
refs.append(f"data:{index}")
continue
buffer_view = image.get("bufferView")
if buffer_view is not None:
refs.append(f"bufferView:{buffer_view}")
continue
refs.append(f"image:{index}")
return refs
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
"""Extract upload-relevant metadata from glTF/GLB bytes.
Raises ValueError for malformed glTF content. The returned dict contains:
mesh_count, vertex_count, has_animation, texture_refs,
bounding_box_min, bounding_box_max.
"""
if content.startswith(_GLB_MAGIC):
gltf = _parse_glb(content)
else:
gltf = _parse_gltf_json(content)
position_counts = _position_accessor_counts(gltf)
bounds = _position_bounds(gltf)
if bounds:
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
else:
bounding_box_min = [0.0, 0.0, 0.0]
bounding_box_max = [0.0, 0.0, 0.0]
return {
"mesh_count": len(gltf.get("meshes", [])),
"vertex_count": sum(position_counts),
"has_animation": bool(gltf.get("animations")),
"texture_refs": _texture_refs(gltf),
"bounding_box_min": bounding_box_min,
"bounding_box_max": bounding_box_max,
}
-34
View File
@@ -1,34 +0,0 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: agp_user
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "${POSTGRES_HOST_PORT:-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:
- "${REDIS_HOST_PORT:-6379}:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
-3
View File
@@ -1,3 +0,0 @@
[pytest]
asyncio_mode = auto
testpaths = tests
-17
View File
@@ -1,17 +0,0 @@
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
View File
-94
View File
@@ -1,94 +0,0 @@
"""Tests for the 18+ age gate on user registration."""
from datetime import date, timedelta
import pytest
from app.routers.auth import calculate_age
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18)
assert calculate_age(dob, today=today) == 18
def test_just_turned_18(self):
"""A person who turned 18 yesterday is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) - timedelta(days=1)
assert calculate_age(dob, today=today) == 18
def test_one_day_short_of_18(self):
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) + timedelta(days=1)
assert calculate_age(dob, today=today) == 17
def test_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"2000/01/01",
"2000-13-01",
"2000-00-10",
"not-a-date",
"",
],
)
def test_invalid_dates_rejected(self, dob):
"""Invalid dates are rejected by the validator."""
from app.routers.auth import RegisterRequest
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
-220
View File
@@ -1,220 +0,0 @@
"""Tests for the lightweight glTF metadata extraction service."""
import json
import struct
import pytest
from app.services.gltf import (
extract_gltf_metadata,
looks_like_gltf,
_parse_glb,
_parse_gltf_json,
)
# ---------------------------------------------------------------------------
# Test data helpers
# ---------------------------------------------------------------------------
def _make_gltf_json(
meshes: int = 1,
vertices_per_mesh: int = 24,
has_animations: bool = False,
images: list[dict] | None = None,
) -> dict:
"""Build a minimal glTF JSON structure."""
accessors = []
meshes_data = []
for i in range(meshes):
acc_index = len(accessors)
accessors.append({
"bufferView": 0,
"componentType": 5126, # FLOAT
"count": vertices_per_mesh,
"type": "VEC3",
"max": [1.0, 1.0, 1.0],
"min": [-1.0, -1.0, -1.0],
})
meshes_data.append({
"primitives": [{
"attributes": {"POSITION": acc_index},
}],
})
gltf = {
"asset": {"version": "2.0"},
"accessors": accessors,
"meshes": meshes_data,
}
if has_animations:
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
if images:
gltf["images"] = images
return gltf
def _make_glb(gltf_json: dict) -> bytes:
"""Wrap *gltf_json* in a minimal GLB binary container."""
json_bytes = json.dumps(gltf_json).encode("utf-8")
json_length = len(json_bytes)
# Pad JSON chunk to 4-byte boundary.
json_padding = (4 - json_length % 4) % 4
json_chunk = json_bytes + b"\x00" * json_padding
json_chunk_type = 0x4E4F534A # "JSON"
# No BIN chunk.
bin_length = 0
bin_chunk_type = 0x004E4942
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
if bin_length > 0:
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
else:
bin_header = b""
return header + json_header + json_chunk + bin_header
# ---------------------------------------------------------------------------
# looks_like_gltf
# ---------------------------------------------------------------------------
class TestLooksLikeGltf:
def test_glb_magic(self):
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
def test_gltf_extension(self):
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
def test_glb_extension(self):
assert looks_like_gltf(b"some binary", "model.glb") is True
def test_json_with_asset_version(self):
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
assert looks_like_gltf(payload) is True
def test_plain_json_no_asset(self):
payload = json.dumps({"foo": "bar"}).encode()
assert looks_like_gltf(payload) is False
def test_non_json_bytes(self):
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
# ---------------------------------------------------------------------------
# _parse_glb
# ---------------------------------------------------------------------------
class TestParseGlb:
def test_parses_valid_glb(self):
gltf_json = _make_gltf_json()
glb = _make_glb(gltf_json)
result = _parse_glb(glb)
assert result["asset"]["version"] == "2.0"
assert len(result["meshes"]) == 1
def test_rejects_too_short(self):
with pytest.raises(ValueError, match="too short"):
_parse_glb(b"glTF\x00\x00")
def test_rejects_invalid_magic(self):
with pytest.raises(ValueError, match="invalid GLB magic"):
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
# ---------------------------------------------------------------------------
# _parse_gltf_json
# ---------------------------------------------------------------------------
class TestParseGltfJson:
def test_parses_valid_gltf(self):
gltf_json = _make_gltf_json()
result = _parse_gltf_json(json.dumps(gltf_json).encode())
assert result["asset"]["version"] == "2.0"
def test_rejects_non_object(self):
with pytest.raises(ValueError, match="root must be an object"):
_parse_gltf_json(b"[1, 2, 3]")
# ---------------------------------------------------------------------------
# extract_gltf_metadata (GLB path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGlb:
def test_basic_glb(self):
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 1
assert meta["vertex_count"] == 24
assert meta["has_animation"] is False
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
assert meta["texture_refs"] == []
def test_multiple_meshes(self):
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 3
assert meta["vertex_count"] == 36
def test_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["has_animation"] is True
def test_with_external_texture(self):
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["textures/rock.png"]
def test_with_named_texture(self):
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["my_texture"]
def test_empty_meshes(self):
gltf_json = _make_gltf_json(meshes=0)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 0
assert meta["vertex_count"] == 0
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
# ---------------------------------------------------------------------------
# extract_gltf_metadata (plain .gltf JSON path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGltfJson:
def test_plain_gltf(self):
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["mesh_count"] == 2
assert meta["vertex_count"] == 16
assert meta["has_animation"] is False
def test_plain_gltf_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["has_animation"] is True
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataErrors:
def test_invalid_glb(self):
with pytest.raises(ValueError):
extract_gltf_metadata(b"not a glb")
def test_invalid_gltf_json(self):
with pytest.raises(ValueError):
_parse_gltf_json(b"[1, 2, 3]")
-462
View File
@@ -1,462 +0,0 @@
# Avatar Component System & glTF Attachment Spec
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Server authority + client prediction, world chunk data format
## 1. Overview
This document specifies the **avatar component system** for **agp-game**, a
Godot-based multiplayer game. It defines:
- How an avatar is decomposed into **body parts** and **attachment slots**.
- How **cosmetic attachments** (clothing, accessories, gear) mount onto the
base body.
- The **glTF/GLB format requirements** that all avatar assets must satisfy so
they can be loaded, skinned, and networked consistently.
The goal is a **modular, data-driven avatar pipeline**: a single base body rig
plus swappable attachments, all authored in glTF and consumed by a shared Godot
runtime. This enables player customization, cosmetic monetization, and
server-authoritative validation of what a player is wearing.
### 1.1 Design Goals
- **Modularity:** Body parts and attachments are independent assets that can be
combined at runtime without re-exporting.
- **Consistency:** A single, well-defined skeleton and slot convention so any
attachment fits any base body.
- **Networkability:** The avatar's composition is expressed as a compact,
serializable **loadout** that the server can validate and replicate.
- **Performance:** Attachments are skinned to the same skeleton and share
materials where possible to minimize draw calls and skinning cost.
- **Tooling-friendly:** Assets are authored in standard glTF 2.0 and validated
by a build-time checker.
---
## 2. Avatar Decomposition
### 2.1 The Base Body
Every avatar is built on a **base body** — a single glTF asset containing:
- A **skeleton** (the canonical rig, see §3).
- A **base mesh** for the skin/body (head, torso, arms, legs).
- **Attachment points** (empty nodes) that define where accessories mount.
The base body is the only asset that carries the skeleton. Attachments reference
the skeleton by **bone name** and are skinned to it; they never carry their own
rig.
### 2.2 Body Parts vs. Attachments
Two distinct concepts:
| Concept | Definition | Example |
|----------------|-------------------------------------------------------------------------|----------------------------------|
| **Body part** | A mesh that replaces part of the base body's skin (e.g., a different head, a tail, wings). | Alternate head, cybernetic arm |
| **Attachment** | A mesh that mounts *on top of* the body without replacing it (clothing, accessories). | Shirt, hat, glasses, backpack |
The distinction matters for **slot exclusivity** and **layering**:
- **Body parts** are *exclusive* per slot — only one head can be equipped.
- **Attachments** are *layered* — a hat and glasses can coexist, and both sit
above the head mesh.
---
## 3. Skeleton & Slot Convention
### 3.1 Canonical Skeleton
All avatars share a **canonical skeleton** with a fixed set of bone names. This
is the contract that lets any attachment fit any body. The skeleton follows a
humanoid convention (Godot's `Humanoid` profile / glTF VRM-style naming).
Required bones (subset of the full rig):
```
Root
├── Hips
│ ├── Spine
│ │ ├── Chest
│ │ │ ├── Neck
│ │ │ │ └── Head
│ │ │ │ ├── HeadTop # hat / hair mount
│ │ │ │ ├── Jaw # face accessories
│ │ │ │ └── LeftEye / RightEye
│ │ │ ├── LeftShoulder
│ │ │ │ └── LeftUpperArm
│ │ │ │ └── LeftLowerArm
│ │ │ │ └── LeftHand
│ │ │ └── RightShoulder
│ │ │ └── RightUpperArm
│ │ │ └── RightLowerArm
│ │ │ └── RightHand
│ │ └── (Chest children for torso attachments)
│ ├── LeftUpperLeg
│ │ └── LeftLowerLeg
│ │ └── LeftFoot
│ └── RightUpperLeg
│ └── RightLowerLeg
│ └── RightFoot
```
**Rules:**
- Bone names are **case-sensitive** and must match exactly.
- The skeleton is **T-pose** at rest for consistent skinning.
- Attachments must be skinned to **at least** the bones they deform; static
accessories may be parented to a single bone (e.g., a hat parented to `Head`).
### 3.2 Attachment Slots
Slots are the **logical mount points** for body parts and attachments. Each slot
maps to one or more bones and defines what can be equipped there.
| Slot ID | Category | Mount bone(s) | Exclusivity | Example assets |
|----------------|--------------|--------------------|-------------|-------------------------------|
| `head` | body part | `Head` | exclusive | alternate head, face |
| `hair` | attachment | `HeadTop` | exclusive | hairstyles, hats |
| `face` | attachment | `Jaw` | exclusive | glasses, masks, beards |
| `torso` | body part | `Chest` | exclusive | alternate torso |
| `chest` | attachment | `Chest` | exclusive | shirts, armor, chest gear |
| `back` | attachment | `Chest` (back) | exclusive | backpacks, wings, capes |
| `left_arm` | body part | `LeftUpperArm` | exclusive | alternate arm |
| `right_arm` | body part | `RightUpperArm` | exclusive | alternate arm |
| `left_hand` | attachment | `LeftHand` | exclusive | gloves, weapons (held) |
| `right_hand` | attachment | `RightHand` | exclusive | gloves, weapons (held) |
| `legs` | body part | `Hips` | exclusive | alternate legs |
| `pants` | attachment | `Hips` | exclusive | pants, skirts |
| `left_foot` | attachment | `LeftFoot` | exclusive | shoes |
| `right_foot` | attachment | `RightFoot` | exclusive | shoes |
| `accessory` | attachment | `Root` (free) | multiple | belts, jewelry, floating items|
**Slot semantics:**
- **`exclusive`** slots allow at most one equipped item at a time.
- The **`accessory`** slot is *multi-slot* — multiple accessories may be equipped
simultaneously (bounded by a configurable limit, e.g., 8).
- **Body-part slots** (`head`, `torso`, `legs`, `left_arm`, `right_arm`) replace
the corresponding base mesh. When a body part is equipped, the base mesh for
that region is hidden.
- **Attachment slots** layer on top of the base mesh and never hide it.
### 3.3 Slot → Bone Mapping
Each slot declares a **primary mount bone** and an optional **secondary bone
list** for skinning. The runtime uses this to:
1. Parent static accessories to the mount bone.
2. Skin skinned attachments to the union of the slot's bones.
---
## 4. glTF/GLB Format Requirements
All avatar assets (base bodies, body parts, attachments) are distributed as
**glTF 2.0** (`.gltf` + external buffers/textures) or **GLB** (`.glb`, binary
container). GLB is the preferred distribution format for runtime loading.
### 4.1 Required Conventions
| Concern | Requirement |
|--------------------|------------------------------------------------------------------------------|
| **Format** | glTF 2.0 (`.gltf` or `.glb`). GLB preferred for runtime. |
| **Coordinate system** | glTF standard: **+Y up, +Z forward, right-handed**. Godot converts to its own convention on import. |
| **Units** | Meters. Avatar height ~1.8 m at the `Root` bone. |
| **Skeleton** | Single skeleton per asset; bone names per §3.1. |
| **Skinning** | `skins` + `nodes` with `JOINTS_0` / `WEIGHTS_0` accessors. |
| **Pose** | T-pose at rest. |
| **Materials** | PBR metallic-roughness. Use `KHR_materials_unlit` for emissive/UI-only items. |
| **Textures** | Max 2048×2048; power-of-two preferred. Use `KHR_texture_basisu` (BasisU) for runtime. |
| **Animation** | Optional. If present, must not rename or remove canonical bones. |
### 4.2 Asset Metadata (extras)
Each asset **must** declare its slot and identity in the glTF `asset.extras`
object. This is the machine-readable contract consumed by the build pipeline and
runtime.
```json
{
"asset": {
"version": "2.0",
"extras": {
"agp": {
"type": "attachment", // "base_body" | "body_part" | "attachment"
"slot": "chest", // slot ID from §3.2
"id": "shirt_bandit_01", // globally unique asset id
"display_name": "Bandit Shirt",
"version": "1.0.0",
"mount_bone": "Chest", // primary mount bone
"skin_bones": ["Chest", "Spine", "LeftUpperArm", "RightUpperArm"],
"exclusive": true, // matches slot exclusivity
"tags": ["cloth", "common"],
"author": "platform-team"
}
}
}
}
```
**Validation rules for `extras.agp`:**
- `type` must be one of `base_body`, `body_part`, `attachment`.
- `slot` must be a known slot ID (§3.2).
- `mount_bone` must exist in the canonical skeleton.
- `skin_bones` must all exist in the canonical skeleton.
- `exclusive` must match the slot's declared exclusivity.
- `id` must be unique across the asset catalog.
### 4.3 Node Naming Convention
To make assets self-describing and mergeable, nodes follow a convention:
- **Base body:** the mesh node is named `Body`; the skeleton root is `Root`.
- **Body part:** the mesh node is named `<SlotID>_Mesh` (e.g., `head_Mesh`).
- **Attachment:** the mesh node is named `<SlotID>_<AssetID>` (e.g.,
`chest_shirt_bandit_01`).
- **Attachment points** (empty nodes) on the base body are named
`attach_<slot>` (e.g., `attach_head`, `attach_left_hand`).
### 4.4 Material & Texture Requirements
- All materials use the **metallic-roughness** PBR model.
- Textures are embedded in the GLB or referenced as external files; external
references must be relative paths within the asset package.
- **Color customization** is supported via a `baseColorFactor` tint applied at
runtime (multiplied over the albedo). Assets should use a neutral albedo
(white/gray) where tinting is intended.
- **Masked/transparent** materials must set the correct alpha mode
(`MASK` or `BLEND`) and `alphaCutoff`.
### 4.5 Skeleton Merging at Runtime
When an attachment is loaded, the runtime **merges** it onto the base body:
1. Load the base body (skeleton + base meshes).
2. Load each equipped attachment.
3. For each attachment, **re-target its skin** to the base body's skeleton by
bone name (no re-export needed).
4. Parent static accessories to their `mount_bone`.
5. Hide base meshes for any equipped body-part slots.
6. Apply material tints and merge into the render tree.
The merge is **name-based** — bone names must match exactly (§3.1). This is the
core reason for the strict naming contract.
---
## 5. Loadout Model (Network & Persistence)
The avatar's composition is serialized as a **loadout** — a compact, ordered
list of equipped asset IDs. This is what the server validates, persists, and
replicates.
### 5.1 Loadout JSON
```json
{
"base_body": "base_human_male_01",
"slots": {
"head": "head_cyber_01",
"hair": "hair_mohawk_01",
"face": null,
"torso": null,
"chest": "chest_bandit_01",
"back": "backpack_01",
"left_arm": null,
"right_arm": null,
"left_hand": "glove_leather_01",
"right_hand": "glove_leather_01",
"legs": null,
"pants": "pants_cargo_01",
"left_foot": "shoe_boot_01",
"right_foot": "shoe_boot_01",
"accessory": ["belt_01", "necklace_01"]
}
}
```
**Rules:**
- `base_body` is required and must reference a valid `base_body` asset.
- Each exclusive slot holds **at most one** asset id (or `null`).
- `accessory` is an array (0..N, bounded).
- The server validates that every id exists in the catalog and that slot
exclusivity is respected.
### 5.2 Network Replication
The loadout is replicated as part of the player's spawn/state data:
```
spawn_entity:
entity_id: uint32
type: player
transform: { x, y, rot }
props:
loadout_hash: uint32 # hash of the loadout for change detection
loadout: { ... } # full loadout on spawn / on change
```
- The **full loadout** is sent on spawn and whenever it changes (reliable).
- A **`loadout_hash`** lets clients skip re-resolving assets if unchanged.
- The server is the **authority** on the loadout; clients send *requests* to
change equipment, and the server validates and broadcasts the result.
### 5.3 Server-Side Validation
The server rejects a loadout if:
- `base_body` is missing or unknown.
- Any slot references an unknown asset id.
- An exclusive slot has more than one item.
- An asset's declared `slot` does not match the slot it is placed in.
- The accessory list exceeds the configured limit.
- The asset is not owned/unlocked by the player (for gated cosmetics).
---
## 6. Godot Runtime Architecture
### 6.1 Node Structure
```
Avatar (Node3D)
├── Skeleton3D # canonical skeleton (from base body)
├── BodyMeshes # base body meshes (skinned to Skeleton3D)
│ ├── head_Mesh
│ ├── torso_Mesh
│ └── ...
├── Attachments # equipped attachments
│ ├── chest_shirt_bandit_01
│ ├── hair_mohawk_01
│ └── ...
└── AttachmentPoints # empty nodes for dynamic mounting
├── attach_head
├── attach_left_hand
└── ...
```
### 6.2 Core Classes
| Class | Responsibility |
|--------------------------|-------------------------------------------------------------|
| `AvatarSystem` | Loads base body + attachments, resolves loadouts, owns the avatar node. |
| `AvatarLoader` | glTF/GLB import, skeleton merge, skin re-targeting. |
| `AvatarSlotRegistry` | Static registry of slot definitions (id, bones, exclusivity).|
| `AvatarLoadout` | Serializable loadout model + validation. |
| `AvatarCatalog` | Asset catalog (id → asset metadata + resource path). |
| `AvatarVisualizer` | Builds the render tree from a loadout. |
### 6.3 Loading Pipeline
```
loadout JSON
│
▼
AvatarCatalog.resolve(loadout) # id → asset metadata + GLB path
│
▼
AvatarLoader.load_base_body() # import GLB, extract skeleton + base meshes
│
▼
for each equipped slot:
AvatarLoader.load_attachment()# import GLB, re-target skin to skeleton
│
▼
AvatarVisualizer.assemble() # parent, hide replaced parts, apply tints
│
▼
Avatar node ready for render
```
### 6.4 Performance Considerations
- **Shared skeleton:** All meshes skin to one `Skeleton3D`, so skinning is
computed once per frame.
- **Material batching:** Attachments that share a material are merged into a
single mesh where possible (static accessories).
- **LOD:** Optional LOD variants per asset; the runtime selects based on
distance.
- **Caching:** Resolved loadouts and imported GLB resources are cached by
`AvatarCatalog` to avoid re-importing on respawn or re-join.
---
## 7. Asset Pipeline & Validation
### 7.1 Build-Time Validation
A build-time checker validates every asset before it enters the catalog:
1. **Format check:** valid glTF 2.0 / GLB.
2. **Metadata check:** `extras.agp` present and valid (§4.2).
3. **Skeleton check:** all `skin_bones` exist in the canonical skeleton.
4. **Slot check:** `slot` is known and `exclusive` matches.
5. **Mesh check:** meshes are skinned (or static accessories parented to a bone).
6. **Texture check:** sizes, formats, and alpha modes are valid.
7. **Uniqueness check:** `id` is unique.
### 7.2 Catalog Manifest
The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
```json
{
"assets": {
"base_human_male_01": {
"type": "base_body",
"path": "res://assets/avatars/base_human_male_01.glb",
"version": "1.0.0"
},
"chest_bandit_01": {
"type": "attachment",
"slot": "chest",
"path": "res://assets/avatars/chest_bandit_01.glb",
"version": "1.0.0"
}
}
}
```
---
## 8. Security & Anti-Cheat
- **Server-authoritative loadouts:** Clients only *request* equipment changes;
the server validates against the catalog and the player's owned items.
- **No client asset injection:** Clients cannot send arbitrary GLB data; they
reference catalog ids only.
- **Rate limiting:** Equipment-change requests are rate-limited per peer.
- **Hash verification:** The `loadout_hash` lets the server detect tampered or
stale loadout state.
---
## 9. Open Questions / Future Work
- **Morph targets / blendshapes:** Support for facial expressions and body
morphs (e.g., height, muscle) via glTF morph targets.
- **Physics attachments:** Cloth simulation for capes/skirts (Godot cloth).
- **Dynamic tinting UI:** A color-picker that writes `baseColorFactor` tints.
- **VRM compatibility:** Consider supporting VRM (a glTF extension for
humanoid avatars) as an import source.
- **Attachment LOD streaming:** Streaming high-res attachments on demand.
---
## 10. Summary
| Concern | Decision |
|------------------------|-------------------------------------------------------|
| Format | glTF 2.0 / GLB (GLB preferred for runtime) |
| Skeleton | Single canonical humanoid skeleton, name-based merge |
| Slots | Fixed slot registry; exclusive body parts + layered attachments |
| Loadout | Compact JSON; server-authoritative and validated |
| Replication | Full loadout on spawn/change + `loadout_hash` |
| Runtime | Godot `Skeleton3D` + name-based skin re-targeting |
| Validation | Build-time asset checker + runtime catalog validation |
-426
View File
@@ -1,426 +0,0 @@
# Content Moderation & Age Verification Architecture
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, backend auth (JWT)
## 1. Overview
This document specifies the **content moderation** and **age verification**
architecture for **agp-game**, a Godot-based multiplayer game built by
autonomous agents. It defines:
- How **content flags** propagate through the system across the three primary
content types: **assets**, **rooms**, and **scripts**.
- How flags are **evaluated, aggregated, and enforced** at load time, at
runtime, and at the network boundary.
- The **age verification flow**, combining a lightweight **self-declaration**
with an optional **ID check hook** for higher-assurance verification.
The goal is a **single, consistent moderation pipeline** that applies the same
rules to user-generated content (UGC) regardless of whether it is a 3D asset, a
room definition, or a script, and that gates access to mature content behind a
verifiable age gate.
### 1.1 Design Goals
- **Uniformity:** One flag vocabulary and one propagation mechanism across
assets, rooms, and scripts.
- **Composability:** Flags on a room aggregate the flags of the assets and
scripts it contains; flags on a world aggregate the flags of its rooms.
- **Enforcement at the boundary:** The server is authoritative for what content
a client may load or receive, mirroring the server-authoritative networking
model.
- **Privacy-preserving age checks:** Self-declaration requires no PII; the ID
check hook is optional, pluggable, and never stores raw identity documents.
- **Auditability:** Every flag decision is logged with a reason, an actor, and
a timestamp so moderation actions are reviewable.
---
## 2. Content Flag Vocabulary
All content carries a set of **content flags**. A flag is a typed, immutable
label with a **severity** and a **scope**. Flags are the atomic unit of
moderation; everything else (aggregation, enforcement, gating) operates on
flags.
### 2.1 Flag Definition
A flag is a structured record:
| Field | Type | Description |
|------------|-----------|--------------------------------------------------------------------------|
| `id` | `string` | Stable machine-readable identifier, e.g. `violence.gore`. |
| `label` | `string` | Human-readable name, e.g. "Graphic Gore". |
| `category` | `enum` | One of `violence`, `sexual`, `language`, `substance`, `gambling`, `horror`, `hate`, `custom`. |
| `severity` | `enum` | One of `none`, `mild`, `moderate`, `severe`. |
| `scope` | `enum` | One of `visual`, `audio`, `text`, `behavior`, `interaction`. |
| `mature` | `bool` | Whether this flag triggers the age gate (see §5). |
| `reason` | `string` | Free-text justification supplied by the flagger. |
| `source` | `enum` | One of `author`, `moderator`, `automated`, `community`. |
### 2.2 Canonical Flag Set
The platform defines a **canonical registry** of flags so that different
content types and different flaggers use the same vocabulary. Examples:
| id | category | severity | scope | mature |
|-----------------------------|------------|-----------|--------------|--------|
| `violence.none` | violence | none | visual | false |
| `violence.mild` | violence | mild | visual | false |
| `violence.gore` | violence | severe | visual | true |
| `sexual.suggestive` | sexual | mild | visual | true |
| `sexual.explicit` | sexual | severe | visual | true |
| `language.mild` | language | mild | text | false |
| `language.profanity` | language | moderate | text | false |
| `substance.references` | substance | mild | text | false |
| `gambling.simulated` | gambling | moderate | behavior | true |
| `horror.jumpscare` | horror | mild | audio/visual | false |
| `hate.hateful` | hate | severe | text | true |
The registry is **data-driven** (a JSON/YAML manifest shipped with the server)
so new flags can be added without code changes.
---
## 3. Flag Propagation Model
Flags propagate through the system along the **content dependency graph**. The
three content types form a hierarchy:
```
World
└── Room
├── Asset (mesh, texture, audio, animation, material)
└── Script (behavior, dialogue, event logic)
```
### 3.1 Propagation Rules
1. **Explicit flags** are set directly on a content item by its author or a
moderator.
2. **Inherited flags** flow upward: a room's effective flag set is the **union**
of its own explicit flags and the flags of every asset and script it
references.
3. **Aggregation** uses the **maximum severity** per category across all
contributing items. A room containing one `violence.gore` asset and one
`violence.none` asset is `violence.severe`.
4. **Maturity is monotonic:** if any contributing item is `mature`, the
containing room and world are `mature`.
5. **Cycles are impossible** by construction: content references form a DAG
(an asset cannot contain a room; a room cannot contain itself).
### 3.2 Propagation Through the Pipeline
Flags are attached at **authoring time** and carried through every stage:
```
Authoring ──► Upload ──► Validation ──► Storage ──► Distribution ──► Runtime
│ │ │ │ │ │
│ flag │ flag │ automated │ flag │ flag │ flag
│ declared │ preserved │ scan adds │ indexed │ re-checked │ enforced
│ │ │ flags │ │ │
```
| Stage | Responsibility |
|--------------|-------------------------------------------------------------------------|
| **Authoring**| Author declares flags in the editor/upload UI. |
| **Upload** | Flags are validated against the canonical registry; unknown flags rejected. |
| **Validation**| Automated scanner (hash-based + heuristic) adds flags for detected content. |
| **Storage** | Flags are stored alongside the content record and indexed for queries. |
| **Distribution**| Flags are re-checked against the current registry before serving. |
| **Runtime** | The client enforces flags before loading content (see §4). |
### 3.3 Data Model
Flags are stored as a **normalized join table** so they can be queried and
aggregated efficiently:
```
content_item (id, type, owner_id, ...)
content_flag (content_id, flag_id, source, reason, created_at, actor_id)
flag_registry (id, category, severity, scope, mature, ...)
```
Aggregation is computed by a SQL view / query that joins a content item's
transitive dependencies. Because the graph is a DAG, this is a bounded
recursive query (PostgreSQL `WITH RECURSIVE`).
---
## 4. Enforcement
Enforcement happens at three layers, in order:
### 4.1 Load-Time Enforcement (Server)
Before a client is allowed to **request** content, the server evaluates the
content's effective flag set against the requesting account's **content
preferences** and **age verification status**:
- If the content is `mature` and the account is **not age-verified**, the
request is **rejected** with a `403 MATURE_CONTENT_REQUIRES_VERIFICATION`.
- If the content's flags exceed the account's **content filter** (a per-account
severity threshold per category), the request is rejected or the offending
content is **stripped** (e.g., audio muted, mesh hidden, script not loaded).
### 4.2 Runtime Enforcement (Client)
The client receives a **manifest** of allowed content from the server. It never
loads content that is not in the manifest. This prevents a malicious or buggy
client from bypassing the server's filter by loading content directly.
- Assets not in the manifest are **not instantiated**.
- Scripts not in the manifest are **not executed**.
- Rooms not in the manifest are **not entered**.
### 4.3 Network Boundary Enforcement
The server **filters replication** based on flags:
- Mature or filtered content is **not replicated** to clients that cannot
receive it.
- A room's flag set is broadcast in the room metadata so clients can show a
warning before entry.
### 4.4 Enforcement Decision Flow
```
Client requests content C for account A
│
▼
Compute effective flags F(C) [union + max-severity aggregation]
│
▼
Is A age-verified? ──no──► Is F(C).mature? ──yes──► REJECT (403)
│ yes │ no
▼ ▼
Does F(C) exceed A's filter? ──► yes ──► REJECT or STRIP
│ no
▼
Serve content + manifest
```
---
## 5. Age Verification Flow
Age verification is a **two-tier** system:
- **Tier 1 — Self-declaration:** The user asserts their date of birth. This is
fast, requires no PII beyond a birthdate, and is sufficient for most content.
- **Tier 2 — ID check (optional hook):** A pluggable identity-verification
provider confirms the user is an adult. This raises the account's assurance
level and unlocks the highest maturity tier.
### 5.1 Verification States
An account has an **age verification status**:
| Status | Meaning |
|-------------------|----------------------------------------------------------------------|
| `unverified` | No declaration on file. |
| `self_declared` | User declared a birthdate; **not** independently confirmed. |
| `id_verified` | User passed the ID check hook; independently confirmed adult. |
| `rejected` | ID check failed or declaration was inconsistent. |
### 5.2 Tier 1 — Self-Declaration Flow
```
User submits { date_of_birth }
│
▼
Server validates format & plausibility (age 13–120, not in future)
│
▼
Compute age from DOB
│
▼
age >= 18 ──► status = self_declared (adult)
age >= 13 ──► status = self_declared (minor, restricted)
age < 13 ──► status = rejected (below minimum platform age)
```
- The DOB is stored **encrypted at rest** and is **never exposed** to other
players.
- A user may **upgrade** from `self_declared` to `id_verified` at any time via
Tier 2.
### 5.3 Tier 2 — ID Check Hook
The ID check is an **optional, pluggable hook**. The platform defines an
interface; concrete providers (e.g., a third-party KYC service) implement it.
```
User requests ID verification
│
▼
Server creates a verification session (idempotent, expiring)
│
▼
Server calls provider hook: verify(identity_token) -> { adult: bool, ref: string }
│
▼
adult == true ──► status = id_verified, store provider ref (not raw doc)
adult == false ─► status = rejected
provider error ──► status unchanged, retry allowed
```
**Privacy guarantees:**
- The platform **never stores** the raw identity document or image.
- Only the provider's **opaque reference** and the boolean `adult` result are
persisted.
- The hook is **asynchronous and idempotent**; a session can be retried without
double-charging or double-verifying.
### 5.4 Hook Interface
The ID check hook is defined as a backend interface (FastAPI dependency /
abstract service):
```python
class IdentityVerificationProvider(Protocol):
def create_session(self, user_id: str) -> VerificationSession: ...
def verify(self, session_id: str, identity_token: str) -> VerificationResult: ...
class VerificationResult:
adult: bool
provider_ref: str | None
error: str | None
```
A default **no-op provider** returns `adult=False` with a clear error, so the
system runs without any third-party integration until one is configured.
### 5.5 Age Gate Enforcement
The age gate is enforced at **load time** (see §4.1):
| Verification status | Can access `mature` content? |
|---------------------|------------------------------|
| `unverified` | No |
| `self_declared` | Yes (adult) / No (minor) |
| `id_verified` | Yes |
| `rejected` | No |
---
## 6. Moderation Workflow
### 6.1 Flagging Sources
Content can be flagged by:
- **Author** (self-declared at upload).
- **Automated scanner** (hash-based detection, heuristic analysis).
- **Community** (players report content; reports become flags after review).
- **Moderator** (manual review and override).
### 6.2 Review Queue
Community and automated flags enter a **review queue**. A moderator either:
- **Approves** the flag (it becomes authoritative), or
- **Rejects** the flag (it is discarded), or
- **Escalates** to a senior moderator for severe/hate flags.
### 6.3 Actions
Once a flag is authoritative, a moderator can take actions:
| Action | Effect |
|-----------------|---------------------------------------------------------------|
| `warn` | Notify the author; content remains. |
| `restrict` | Content hidden from users below a filter threshold. |
| `age_gate` | Content marked `mature`; requires age verification. |
| `remove` | Content taken down; not served. |
| `ban_author` | Author's upload privileges suspended. |
### 6.4 Audit Log
Every flag and action is written to an **audit log**:
```
audit_event (id, content_id, actor_id, action, flag_id, reason, created_at)
```
This log is append-only and used for transparency and dispute resolution.
---
## 7. Client-Side Integration (Godot)
### 7.1 Content Manifest
The server sends each client a **content manifest** — the set of content the
client is permitted to load, derived from the account's verification status and
filter. The Godot client:
- Loads only manifest-listed assets.
- Executes only manifest-listed scripts.
- Shows a **maturity warning** dialog before entering a room whose effective
flags include `mature` content.
### 7.2 Flag Metadata on Content
Each content resource carries a `content_flags` metadata field (a Godot
`Dictionary` or custom `Resource`). The client uses this for local UI decisions
(e.g., showing a warning icon) but **never** as the authority for whether to
load — the server manifest is authoritative.
### 7.3 Age Verification UI
The client exposes an **age verification screen**:
- Tier 1: a date-of-birth picker.
- Tier 2: a button that opens the ID check provider flow (deep link / webview).
The client calls the backend endpoints (§8) and reflects the resulting status.
---
## 8. Backend API Surface
| Method | Path | Description |
|--------|-----------------------------------|------------------------------------------------|
| `POST` | `/v1/age/declare` | Submit self-declared DOB (Tier 1). |
| `POST` | `/v1/age/verify/session` | Create an ID-check session (Tier 2). |
| `POST` | `/v1/age/verify/confirm` | Confirm an ID-check session with a token. |
| `GET` | `/v1/age/status` | Return the account's verification status. |
| `GET` | `/v1/content/{id}/flags` | Return a content item's effective flags. |
| `POST` | `/v1/content/{id}/flags` | Add a flag (author/community). |
| `POST` | `/v1/moderation/review/{flag_id}` | Moderator approve/reject/escalate. |
| `POST` | `/v1/moderation/action` | Apply a moderation action. |
| `GET` | `/v1/manifest` | Return the client's permitted content manifest.|
All endpoints require a valid **JWT** (matching the existing auth stack). The
age endpoints additionally require the account to be in a non-`rejected` state.
---
## 9. Security & Privacy Considerations
- **DOB encryption:** Self-declared birthdates are encrypted at rest; only the
derived age and verification status are used in authorization decisions.
- **No raw identity storage:** The ID check hook persists only a provider
reference and a boolean result.
- **Server authority:** The client manifest prevents bypass; the client never
trusts its own flag metadata for access decisions.
- **Rate limiting:** Age-declaration and ID-check endpoints are rate-limited to
prevent abuse and enumeration.
- **Auditability:** All flag and verification events are logged append-only.
---
## 10. Open Questions / Future Work
- **Regional age thresholds:** Different jurisdictions may require different
minimum ages; the threshold should be configurable per region.
- **Parental controls:** A future feature may let a parent set a content filter
on a minor's account.
- **Automated scanner accuracy:** The heuristic scanner may produce false
positives; a confidence score should be attached to automated flags.
- **Offline / local content:** How flags apply to locally-authored content that
is never uploaded remains to be defined.
-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 |
-286
View File
@@ -1,286 +0,0 @@
# Multiplayer Networking Architecture
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Server authority + client prediction
## 1. Overview
This document specifies the multiplayer networking architecture for **agp-game**,
a Godot-based game built by autonomous agents. The architecture follows a
**server-authoritative model with client-side prediction and reconciliation**,
which is the industry standard for fast-paced, competitive multiplayer games.
The goals of this architecture are:
- **Fairness & anti-cheat:** The server is the single source of truth for all
gameplay state. Clients never directly mutate world state.
- **Responsiveness:** Client-side prediction hides network latency so local input
feels immediate.
- **Determinism:** The server runs a fixed-timestep simulation so that state is
reproducible and testable.
- **Scalability:** A clear separation between transport, simulation, and
replication layers allows the server to be tuned independently.
---
## 2. Transport Choice: ENet over WebSocket
### 2.1 Decision
We choose **ENet** as the primary transport for gameplay traffic.
### 2.2 Justification
| Criterion | ENet | WebSocket (TCP) |
|----------------------|----------------------------------------|-------------------------------------|
| **Reliability model**| Reliable + unreliable channels | Reliable only (TCP) |
| **Latency** | Low; unreliable packets skip retransmit| Head-of-line blocking on loss |
| **Packet ordering** | Per-channel ordering | Global strict ordering |
| **Congestion control**| Lightweight, game-oriented | TCP-style (slow start, backoff) |
| **Godot support** | First-class (`ENetConnection`, `MultiplayerPeerENet`) | Supported but TCP-based |
| **Use case fit** | Real-time action game | Chat, lobby, HTTP-style messaging |
**Rationale:**
- **Movement & state replication** are latency-sensitive and tolerate occasional
packet loss. ENet's *unreliable* channel lets us send position snapshots at high
frequency without head-of-line blocking.
- **RPCs that must be delivered** (e.g., interactions, spawns) use ENet's
*reliable* channel, giving us exactly the semantics we need without TCP's
retransmission stalls.
- Godot's `MultiplayerPeerENet` is mature and integrates directly with the
high-level multiplayer API (`MultiplayerAPI`, `rpc()`).
### 2.3 WebSocket as a Secondary Transport
WebSocket remains available as a **secondary transport** for:
- **Lobby / matchmaking** messaging (low-frequency, must-be-reliable).
- **Browser builds** where raw UDP/ENet is unavailable.
- **Admin / telemetry** channels.
The transport layer is abstracted behind an interface so gameplay code does not
depend on the underlying protocol.
---
## 3. Server-Side Game Loop
### 3.1 Tick Rate
- **Simulation tick rate:** **30 Hz** (33.33 ms per tick).
- **Replication / snapshot rate:** **30 Hz**, aligned with the simulation tick.
**Justification:**
- 30 Hz is sufficient for smooth movement and interaction in a top-down / 2D
action game while keeping CPU and bandwidth costs predictable.
- A fixed timestep (rather than delta-time) makes the simulation deterministic
and testable.
- The tick rate is a **configuration constant** so it can be raised (e.g., to
60 Hz) for competitive modes without architectural changes.
### 3.2 Authority Model
- **Server is authoritative** for:
- Player position, velocity, and physics state.
- Health, damage, and death.
- Item pickups, inventory, and world-object state.
- Spawning and despawning of entities.
- Game rules, scoring, and win/lose conditions.
- **Client is authoritative** for:
- Its own *input* (which keys/buttons are pressed).
- Local camera and UI.
- Cosmetic / non-gameplay presentation.
### 3.3 Server Loop Pseudocode
```
while server_running:
process_network_inbound() # read client inputs & RPCs
for _ in range(ticks_to_advance):
simulate_tick() # fixed 30 Hz step
broadcast_snapshots() # replicate authoritative state
process_network_outbound() # flush reliable/unreliable channels
```
The server accumulates real elapsed time and advances the simulation in fixed
`TICK_DURATION` increments, decoupling simulation from frame rate.
---
## 4. RPC Call Patterns
RPCs are grouped by direction and reliability. All gameplay RPCs are routed
through the high-level `MultiplayerAPI` with explicit `rpc_id` targeting.
### 4.1 Client → Server (Input & Requests)
| RPC | Channel | Payload | Notes |
|------------------------------|-----------|-------------------------------------------|-------|
| `request_move_input` | unreliable| `{ seq, input_state, timestamp }` | Batched per tick; drives prediction |
| `request_interact` | reliable | `{ target_id }` | Open door, pick up item, use object |
| `request_attack` | reliable | `{ target_id, aim_dir }` | Combat action |
| `request_join` | reliable | `{ player_name, profile }` | Session setup |
| `request_respawn` | reliable | `{}` | After death |
**Design notes:**
- **Input is batched** per client tick and sent with a monotonically increasing
`seq` so the server can detect gaps and the client can reconcile.
- **Interactions are reliable** because they are discrete, must-happen events
(opening a door, picking up an item). Losing them would corrupt world state.
### 4.2 Server → Client (State & Events)
| RPC | Channel | Payload | Notes |
|------------------------------|-----------|-------------------------------------------|-------|
| `snapshot_state` | unreliable| `{ tick, entities[], player_states[] }` | Periodic authoritative state |
| `spawn_entity` | reliable | `{ entity_id, type, transform, props }` | New entity enters world |
| `despawn_entity` | reliable | `{ entity_id, reason }` | Entity removed |
| `interact_result` | reliable | `{ request_seq, success, result }` | Acknowledges interaction |
| `damage_event` | reliable | `{ target_id, amount, source_id }` | Combat feedback |
| `game_event` | reliable | `{ type, payload }` | Score, win, round start/end |
### 4.3 Broadcast vs. Targeted
- **Broadcast (all clients):** `snapshot_state`, `spawn_entity`,
`despawn_entity`, `game_event`.
- **Targeted (single client):** `interact_result`, `damage_event` (to affected
players), and any player-private data.
---
## 5. Client-Side Prediction & Reconciliation
### 5.1 Prediction
- The client runs a **local copy of the movement simulation** using the same
physics constants as the server.
- When the player provides input, the client:
1. Applies the input to its local player immediately.
2. Renders the predicted position.
3. Sends the input to the server (batched, with `seq`).
### 5.2 Reconciliation
- The server sends authoritative `snapshot_state` at 30 Hz containing the
server's tick number and authoritative player positions.
- The client keeps a **ring buffer of predicted states** keyed by input `seq`.
- On receiving a snapshot:
1. Find the last predicted state that matches the server's tick.
2. Compute the **error** between predicted and authoritative position.
3. If error exceeds a threshold, **snap or smoothly interpolate** the local
player to the authoritative position (error correction).
4. Re-simulate any inputs that occurred after the server's tick (rollback).
### 5.3 Interpolation of Remote Entities
- **Local player:** predicted + reconciled (see above).
- **Remote players / entities:** rendered with **interpolation delay** of
`2 × TICK_DURATION` (≈ 66 ms) to smooth out snapshot jitter.
- The client buffers the last N snapshots and interpolates between them.
### 5.4 Prediction Buffer Sizing
- Buffer size is adaptive based on measured RTT.
- Default: hold up to `ceil(RTT / TICK_DURATION) + 2` predicted states.
---
## 6. Entity & World State Replication
### 6.1 Entity Model
Every networked entity has:
- A globally unique `entity_id` (server-assigned).
- An `EntityType` (player, enemy, item, projectile, door, ...).
- A `Transform` (position, rotation).
- A typed `props` dictionary of gameplay state (health, state machine, etc.).
### 6.2 Snapshot Format
```
snapshot_state:
tick: uint32
entities:
- entity_id: uint32
type: uint8
transform: { x, y, rot }
props: { ... } # delta-encoded where possible
player_states:
- peer_id: int
entity_id: uint32
health: int
state: uint8
```
### 6.3 Delta Compression
- Only **changed** entity fields are sent per snapshot (dirty-flag per entity).
- Static or unchanged entities are omitted from the snapshot.
- This keeps bandwidth proportional to *activity*, not world size.
---
## 7. Connection & Session Lifecycle
1. **Connect:** Client opens an ENet connection to the server.
2. **Authenticate / Join:** Client sends `request_join`; server validates and
assigns a `peer_id` and spawns the player entity.
3. **Spawn:** Server broadcasts `spawn_entity` for the new player to all clients.
4. **Play:** Input → prediction → server simulation → snapshot → reconciliation.
5. **Disconnect:** Server removes the player entity, broadcasts `despawn_entity`,
and cleans up session state.
---
## 8. Security & Anti-Cheat
- **No client-authoritative gameplay values.** Clients only send *inputs* and
*requests*; the server computes all outcomes.
- **Input validation:** Server clamps movement speed, rejects out-of-bounds
positions, and validates interaction targets.
- **Rate limiting:** Per-peer input and RPC rate limits prevent flooding.
- **Sequence validation:** Gaps or replays in input `seq` are detected and
rejected.
---
## 9. Testing & Determinism
- The simulation is a **pure function of (state, inputs, tick)** so it can be
unit-tested headlessly.
- A **replay system** records inputs and can re-simulate to verify server and
client produce identical results.
- Network code is tested with a **mock transport** that can inject latency,
jitter, and packet loss.
---
## 10. Open Questions / Future Work
- **Interest management / spatial partitioning:** For large worlds, restrict
snapshots to entities near each client (AOI culling).
- **Server-side physics:** Decide whether to use Godot's physics server on the
host or a custom deterministic physics step.
- **Dedicated server vs. listen server:** The architecture supports both; a
dedicated headless server is the target for production.
- **WebSocket fallback:** Implement the transport abstraction for browser builds.
---
## 11. Summary
| Concern | Decision |
|------------------------|-------------------------------------------------|
| Transport | ENet (primary), WebSocket (secondary) |
| Tick rate | 30 Hz fixed timestep |
| Authority | Server-authoritative; client sends input only |
| Prediction | Client-side prediction + reconciliation |
| Remote interpolation | 2-tick interpolation delay |
| Replication | Delta-compressed snapshots at 30 Hz |
| Interaction RPCs | Reliable channel |
| Movement input | Unreliable channel, batched with sequence |
-159
View File
@@ -1,159 +0,0 @@
# 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.
+3 -57
View File
@@ -252,11 +252,6 @@ logical model serialized directly.
}
```
> **Note on the example checksum:** The value shown above is illustrative only.
> In practice the `checksum` field is the SHA-256 of the canonical binary
> payload body (see §3.6.3), not of the JSON text. The value here is a
> placeholder for documentation purposes.
### 3.6 Binary Encoding
The binary encoding is the **runtime and network** representation. It is compact
@@ -486,16 +481,6 @@ CREATE INDEX idx_chunk_objects_type ON chunk_objects(type);
CREATE INDEX idx_chunk_objects_asset ON chunk_objects(asset_id);
```
**Sync mechanism:** `chunk_objects` is maintained **explicitly by the write
path** — it is not a SQL trigger. Whenever `put_chunk` (or `delete_chunk`)
commits a chunk, the service layer performs a **delete-and-reinsert** of that
chunk's object rows in the same transaction: it deletes all rows for
`chunk_key`, then inserts fresh rows parsed from the new payload. This keeps the
index transactionally consistent with the payload and avoids the complexity and
fragility of SQL triggers over a binary BLOB. Because the index is derived, it
can always be rebuilt from `chunks.payload` if it ever drifts (a `rebuild_index`
utility is provided for this).
### 4.6 `content_flags`
Persists the aggregated moderation flags per chunk (and optionally per object).
@@ -505,7 +490,7 @@ payloads.
```sql
CREATE TABLE content_flags (
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
object_id TEXT NOT NULL DEFAULT '', -- '' = chunk-level flag (sentinel)
object_id TEXT, -- NULL = chunk-level flag
violence INTEGER NOT NULL DEFAULT 0,
sexual INTEGER NOT NULL DEFAULT 0,
language INTEGER NOT NULL DEFAULT 0,
@@ -523,16 +508,6 @@ CREATE INDEX idx_content_flags_mature ON content_flags(mature);
Severity is stored as `0=none, 1=mild, 2=moderate, 3=severe`.
**Chunk-level vs. object-level flags:** Chunk-level flags use the **sentinel
value `''` (empty string)** in `object_id` rather than `NULL`. This is
deliberate: in SQLite, `NULL` values in a composite primary key are **not**
unique-constrained, so two rows with the same `chunk_key` and `object_id = NULL`
could be inserted accidentally. Using `''` as the sentinel means the composite
`PRIMARY KEY (chunk_key, object_id)` enforces that there is **exactly one**
chunk-level flag row per chunk. Object-level flags use the object's actual `id`
(which is never empty). The application layer treats `object_id = ''` as
"chunk-level" and any non-empty value as "object-level".
### 4.7 `revisions`
An append-only audit log of chunk writes. Enables rollback, auditing, and
@@ -541,31 +516,16 @@ replication of changes.
```sql
CREATE TABLE revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chunk_key TEXT NOT NULL, -- NOT a FK: survives chunk deletion
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
revision INTEGER NOT NULL,
actor TEXT NOT NULL, -- user / agent id
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
checksum TEXT NOT NULL, -- SHA-256 of payload
payload BLOB, -- full binary payload at this revision
checksum TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_revisions_chunk ON revisions(chunk_key);
```
**Rollback support:** The `payload` BLOB stores the **full binary chunk payload**
at each revision. This is what makes rollback possible: to restore a chunk to a
previous revision, the server reads the `payload` of the target revision and
writes it back to `chunks.payload` (incrementing `revision` and appending a new
`revisions` row). Without the payload, only the checksum would be available,
which is insufficient to reconstruct the chunk.
**Audit trail preservation:** `chunk_key` is **not** a foreign key to `chunks`.
This means deleting a chunk does **not** cascade-delete its revision history —
the audit trail is preserved even after a chunk is removed. (The `chunk_key`
string is still stored so the history can be correlated to a chunk that existed
at that time.) If a chunk is later recreated with the same key, its history is
still intact.
### 4.8 Schema Versioning & Migrations
The database carries a schema version in a `meta` table:
@@ -606,8 +566,6 @@ class WorldStore:
bounds: Bounds | None = None) -> list[str]: ...
def get_chunk_flags(self, key: str) -> ContentFlags: ...
def flush_dirty(self) -> int: ...
def rollback_chunk(self, key: str, revision: int, actor: str) -> Chunk: ...
def rebuild_index(self, chunk_key: str | None = None) -> int: ...
def migrate(self) -> None: ...
```
@@ -670,18 +628,6 @@ memory and flushes them to SQLite in batches:
This keeps the hot path (in-memory chunk access) fast while guaranteeing
durability at shutdown and on a configurable interval.
**Recovery after unclean shutdown:** The `dirty` column in the `chunks` table
exists specifically to support recovery if the process crashes before a flush
completes. On startup, the server scans for rows where `dirty = 1` and treats
them as **unflushed in-memory state that was lost** — it re-reads the last
persisted payload from `chunks.payload` (the last durable state) and clears the
`dirty` flag. In other words, `dirty = 1` at startup means "the last write was
not durable; discard the in-memory delta and fall back to the persisted
payload." This guarantees that the on-disk state is always a consistent,
previously-committed snapshot, never a partially-written chunk. (The in-memory
dirty set is authoritative during normal operation; the `dirty` column is only a
recovery marker.)
### 5.6 Streaming & Caching
- The server streams only the chunks a client needs (those within the client's
-37
View File
@@ -1,37 +0,0 @@
# MVP Vertical Slice: Client-Server Wiring — Plan
## Goal
Prove the real Godot client and the real backend can talk: **a user can register/log in
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
explicitly out of scope.
## Verified backend contract (as of this writing)
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
Rejects under-18 with `400`.
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
public-worlds path, so the worlds list screen can only be shown after login/register.
- `GET /api/health` — unauthenticated, `{status, app}`.
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
## Task breakdown
| # | Type | Task | Depends on |
|---|------|------|-----------|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
## Design decisions captured (resolved in task 1)
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
open but not required yet (no re-login on restart in this slice).
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
- Login is form-encoded; register is JSON — the client helper must support both content types.
-295
View File
@@ -1,295 +0,0 @@
# Economy — Currency, Marketplace & Creator Payouts — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `economy`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Economy** feature into user stories and right-sized
implementation tasks. The feature delivers:
- **Virtual currency ("Credits")** — a single, server-authoritative currency
with an immutable, append-only transaction ledger.
- **Premium purchase** — buying Credits with real money through a pluggable
payment-provider hook.
- **Marketplace** — user-generated content (UGC) listings where creators sell
items (assets, avatar items, experience passes) to players for Credits.
- **Creator payouts** — accumulating creator earnings and paying them out to
external accounts through a pluggable payout-provider hook.
- **Godot client** — wallet/transaction history, marketplace browse/buy/sell,
and creator payout screens.
The economy is the monetization backbone of the platform. Because it moves
virtual value between real people, correctness (no double-spend, no lost funds,
no balance drift) and safety (fraud prevention, moderation, audit) are treated
as **blocking requirements**, not add-ons.
## 2. Why correctness is the differentiator
The platform is a creator economy (players buy UGC from other players). The
economy is the highest-risk surface after chat: a single arithmetic or
concurrency bug can mint or destroy value and irreversibly erode trust.
Correctness is therefore designed in from the start:
- **One currency, integer units.** Credits are whole-number integers. There are
no floats, no rounding, and no client-computed totals.
- **Append-only ledger.** Every balance change is a row in a `transactions`
table with a signed amount, a `balance_after` snapshot, a reference to the
event that caused it, and an **idempotency key** so retries cannot double
credit or double debit.
- **Atomic, locked mutations.** Balance changes run in a single database
transaction with row locking (`SELECT ... FOR UPDATE`) so concurrent
purchases/payouts cannot race.
- **Escrow hold.** Marketplace proceeds are held in `pending_escrow` for a
configurable window before becoming available to the seller, so fraudulent
or moderated sales can be reversed without clawing back paid-out funds.
- **Server-authoritative fees.** The platform commission is computed
server-side from a configured rate; the client never supplies the fee or the
seller's net.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. All economy endpoints reuse this.
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
Existing tables: `users`, `sessions`, `worlds`, `assets`.
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
idempotency short-caching and rate limiting.
- **Models:** `User` (owner_id), `Asset` (owner_id, is_public, asset_type).
Marketplace items reference existing `assets` rows and/or avatar items.
- **Content moderation:** `docs/architecture/content_moderation.md` defines
flag vocabulary, age gate, and moderation actions. Marketplace items and
listings must inherit and enforce these rules (see §7).
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no economy UI yet.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Currency model | Single virtual currency **"Credits" (CR)**, integer units. One wallet row per user; no fractional amounts. |
| Ledger | Append-only `transactions` table: signed `amount`, `balance_after`, `reference_type`/`reference_id`, unique `idempotency_key`. |
| Concurrency | `SELECT ... FOR UPDATE` on the wallet row inside the mutation transaction; never read-modify-write from stale data. |
| Payments | Pluggable `PaymentProvider` interface with a **no-op default** that simulates a confirmed payment for dev/testing (clearly flagged). Stripe/PayPal later. |
| Marketplace items | `catalog_items` (what is sold) + `marketplace_listings` (a seller's offer with a price/status). One item can have multiple listings over time. |
| Purchase flow | Atomic transaction: debit buyer → hold funds in escrow (`pending_escrow`) → grant item. Platform fee computed server-side. |
| Escrow | Configurable hold window (default 72h) before `pending_escrow` becomes available seller earnings. Refunds during the window reverse the sale. |
| Fees | Single configurable `MARKETPLACE_FEE_RATE` (default `0.30`). `seller_net = floor(price * (1 - fee_rate))`. |
| Creator balance | `creator_balances` table: `available` (earned, payable), `pending` (inside an open payout request), `lifetime_earned`. Updated in the same transaction as the sale completion. |
| Payouts | Pluggable `PayoutProvider.pay()` with a no-op default (simulated external ref). Minimum payout threshold, admin review for large payouts. |
| Idempotency | Every money-moving request carries a client-generated `Idempotency-Key`; duplicate keys return the original result. |
| Rate limiting | Redis token-bucket dependency reused from the social feature for purchase/listing/payout endpoints. |
| Moderation | Listings enforce content flags + age gate; moderators can `delist`/`remove`; fraud/chargeback reversal is an audited admin action. |
## 5. User stories
### US-1 Currency & wallet
As a player, I can see my Credit balance and transaction history, and the system
credits/debits my wallet only through server-authoritative events.
**Acceptance criteria**
- Exactly one wallet per user, created lazily on first use.
- Balance is always a non-negative integer; no operation can make it negative.
- Every mutation appends an immutable ledger row with amount, resulting balance,
reference, and idempotency key.
- Retrying a request with the same idempotency key does not double-apply.
- `GET` endpoints expose current balance and paged transaction history.
### US-2 Premium purchase (buy Credits)
As a player, I can purchase Credits with real money and have them credited only
after the payment is confirmed.
**Acceptance criteria**
- A purchase session is created through the payment provider (or the no-op
default) and is idempotent.
- Credits are granted exactly once, only after a verified provider confirmation
(webhook/confirm endpoint), never on client claim.
- Purchase amounts are bounded and server-defined; clients cannot pick arbitrary
prices.
- Confirmation is idempotent and audited (provider reference recorded).
### US-3 Marketplace listing (sell)
As a creator, I can list a catalog item for sale at a Credit price and
manage/remove my listings.
**Acceptance criteria**
- A listing references a valid `catalog_item` owned by the seller (or an
authorized seller).
- Price is bounded (min/max) and set server-side; status transitions are
explicit (`active`, `sold`, `delisted`, `removed_by_moderation`).
- Only the seller (or a moderator) can update/remove a listing.
- Listings are searchable/filterable (item type, price range, owner).
### US-4 Marketplace purchase (buy)
As a player, I can buy a listed item. The purchase is atomic, holds funds in
escrow, grants the item, and splits the payment between platform and seller.
**Acceptance criteria**
- A single transaction debits the buyer, holds funds in escrow, and grants the
item — no partial states on failure.
- Platform fee and seller net are computed server-side from the configured rate.
- After the escrow hold window, funds move from escrow to the seller's
available balance.
- Refunds during the hold window reverse the sale and return funds to the buyer.
- Concurrent purchases of a one-of-a-kind listing cannot both succeed.
### US-5 Creator payouts
As a creator, I can view my earned balance, request a payout, and track its
status; admins can review and approve/reject payouts.
**Acceptance criteria**
- Available balance is derived only from completed (escrow-released) sales and
completed payouts; it never goes negative.
- Payout requests are bounded (min threshold, max per period) and rate-limited.
- Large payouts require admin review; all payout requests carry an audit trail
(requested by, reviewed by, provider reference, status).
- The payout provider is called exactly once; idempotent on retry.
### US-6 Godot client
As a player, I can manage Credits, browse/buy/sell marketplace items, and manage
payouts from the game UI.
**Acceptance criteria**
- An API client handles JWT auth, HTTP, and idempotency keys for all economy
calls.
- Dedicated screens: wallet + transaction history, marketplace browse/buy,
sell-listing, and creator payout.
- The client never computes balances, fees, or seller nets — it only displays
server responses.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
are sequential for dependency chaining.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Wallet & ledger models + Alembic migration (`wallets`, `transactions`) | US-1 | — |
| 2 | Wallet service (atomic credit/debit, idempotency, locking) + wallet/transactions API | US-1 | 1 |
| 3 | Premium purchase: `PaymentProvider` interface + purchase/confirm endpoints | US-2 | 1 |
| 4 | Catalog item & listing models + migration (`catalog_items`, `marketplace_listings`, `creator_balances`) | US-3 | 1 |
| 5 | Marketplace listing API (create/list/search/update/remove) | US-3 | 4 |
| 6 | Marketplace purchase service + API (atomic escrow, fee split, item grant) | US-4 | 2, 4, 5 |
| 7 | Creator payout request models + API (`payout_requests`) | US-5 | 2, 6 |
| 8 | Payout admin review + `PayoutProvider` + audit | US-5 | 7 |
| 9 | Marketplace moderation + content-flag/age-gate enforcement | cross-cutting | 5, 6 |
| 10 | Godot economy API client (auth + HTTP + idempotency) | US-6 | 2 |
| 11 | Godot wallet & transaction history UI | US-6 | 10 |
| 12 | Godot marketplace browse/buy UI | US-6 | 6, 10 |
| 13 | Godot selling & payout UI | US-6 | 5, 7, 10 |
## 7. Phasing
- **Phase 1 (currency + payments):** tasks 1–3. The foundation — ledger and
money movement must be rock-solid before anything spends Credits.
- **Phase 2 (marketplace):** tasks 4–6. Listing and atomic purchase with escrow.
- **Phase 3 (payouts):** tasks 7–8. Turn released escrow into creator payouts.
- **Phase 4 (moderation):** task 9. Lock down the marketplace against mature
content and abuse.
- **Phase 5 (client):** tasks 10–13, each landing after its backend dependency.
## 8. Data model sketch
```text
wallets
id bigint PK
user_id int UNIQUE FK -> users.id
balance bigint NOT NULL DEFAULT 0 CHECK (balance >= 0)
created_at timestamptz
updated_at timestamptz
transactions (append-only ledger)
id bigint PK
wallet_id bigint FK -> wallets.id
type enum(credit_purchase, marketplace_purchase,
marketplace_sale, escrow_release, payout,
refund, admin_adjustment)
amount bigint NOT NULL (signed)
balance_after bigint NOT NULL
reference_type text (e.g. "purchase", "sale", "payout_request")
reference_id text
idempotency_key text UNIQUE
metadata_json jsonb
created_at timestamptz
catalog_items
id bigint PK
owner_id int FK -> users.id
asset_id int FK -> assets.id (nullable; null for non-asset virtual items)
item_type enum(asset, avatar_item, experience_pass)
name text
description text
metadata_json jsonb
created_at timestamptz
marketplace_listings
id bigint PK
catalog_item_id bigint FK -> catalog_items.id
seller_id int FK -> users.id
price bigint NOT NULL CHECK (price > 0)
status enum(active, sold, delisted, removed_by_moderation)
created_at timestamptz
updated_at timestamptz
purchases
id bigint PK
listing_id bigint FK -> marketplace_listings.id
buyer_id int FK -> users.id
seller_id int FK -> users.id
price bigint NOT NULL
platform_fee bigint NOT NULL
seller_net bigint NOT NULL
status enum(pending_escrow, completed, refunded, reversed)
escrow_hold_until timestamptz
created_at timestamptz
creator_balances
id bigint PK
user_id int UNIQUE FK -> users.id
available bigint NOT NULL DEFAULT 0
pending bigint NOT NULL DEFAULT 0
lifetime_earned bigint NOT NULL DEFAULT 0
updated_at timestamptz
payout_requests
id bigint PK
creator_id int FK -> users.id
amount bigint NOT NULL CHECK (amount > 0)
status enum(pending, approved, paid, rejected)
external_ref text
provider_ref text
requested_by int FK -> users.id
reviewed_by int FK -> users.id (nullable)
reviewed_at timestamptz
created_at timestamptz
```
## 9. Security, anti-fraud & audit
- **Server-authoritative balances/fees:** clients never compute totals; amounts
are validated server-side against bounds and the current listing.
- **Row locking:** wallet and creator-balance mutations use `SELECT ... FOR
UPDATE` within one transaction.
- **Idempotency:** every money-moving endpoint requires an `Idempotency-Key`;
duplicate keys return the stored original outcome.
- **Rate limiting:** purchase, listing, and payout endpoints are token-bucket
rate-limited per user.
- **Escrow & reversibility:** funds are not payable until the hold window
elapses; chargebacks/refunds reverse before release.
- **Moderation:** listings carry the item's effective content flags; mature
items require age verification (per `content_moderation.md`); moderators can
`delist`/`remove` and trigger audited reversals.
- **Audit:** the ledger plus `payout_requests` review fields form an append-only
audit trail for every movement of value.
## 10. Out of scope for this feature
- Real Stripe/PayPal/KYC integrations (provider interfaces leave the hooks; the
defaults are deterministic no-ops).
- Subscriptions, recurring billing, or premium memberships.
- Gift cards, promotional codes, or user-to-user Credit transfers.
- Partial refunds, chargeback disputes, and seller tax/VAT handling.
- In-world vending machines / scripted economy triggers (future, built on this
ledger via the scripting sandbox).
-170
View File
@@ -1,170 +0,0 @@
# Social — Friends, Chat & Groups — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `social`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Social** feature into user stories and right-sized
implementation tasks. The feature delivers:
- **Friends & following** — a friend request/accept model plus a one-way follow
relationship, with block lists.
- **Presence & status** — online/offline, custom status message, last-seen.
- **Chat** — direct messages (DM) and in-experience (world/room) chat with
**first-class safety**: server-side text filtering, report-message, and rate
limiting.
- **Groups** — named groups with roles (owner/admin/member) and a simple group
page.
- **Public profiles** — avatar, bio, favorite experiences, and a friends list
that respects privacy settings.
## 2. Why chat safety is the differentiator
The platform is explicitly meant to pull adults away from a youth-oriented
competitor. Chat is the highest-risk surface: it is real-time, user-generated,
and where abuse happens fastest. Safety is therefore treated as a **blocking
requirement**, not an add-on:
- Every message passes a **server-side text filter** before it is stored or
delivered. The client filter is cosmetic only; the server is authoritative.
- Every message can be **reported**; reports flow into a moderation queue with
actor + reason + audit trail (mirroring `content_moderation.md`).
- Sending is **rate limited** per user (Redis token bucket) to stop flooding and
harassment-by-volume.
- Report and block are always reachable from a message context in the UI.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. All social endpoints reuse this.
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
presence and rate limiting.
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). Avatars
and favorite-experience references point at existing rows.
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no social UI yet.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Message transport | REST for send/history; **WebSocket** gateway for real-time delivery + presence (matches `networking.md`'s "WebSocket as secondary transport" for chat) |
| Presence store | Redis (TTL heartbeat) + persisted `last_seen_at` on the user record |
| Text filtering | Pluggable `TextFilter` interface with a deterministic default (profanity list + normalization) so it runs without external services; third-party hook later |
| Rate limiting | Redis token-bucket dependency, reusable across all social endpoints |
| Reports | `message_reports` table + moderation endpoints (list/review/resolve) with audit fields |
| Friends vs. follow | Friends = mutual (request→accept). Follow = one-way (no approval). Block = hard deny that also hides content and prevents DMs |
| Privacy | Per-user settings JSON (`profile_visibility`, `friends_list_visibility`, `favorites_visibility`) enforced server-side on every read |
| Groups | `groups` + `group_members` (role: owner/admin/member) + group page aggregate endpoint |
| Real-time | One WebSocket endpoint multiplexing presence, DM, and world-chat messages via Redis pub/sub |
## 5. User stories
### US-1 Friends & following
As a player, I can send a friend request, accept/decline incoming requests,
unfriend, follow/unfollow other players, and block users so they can't interact
with me.
**Acceptance criteria**
- Friend requests are one-directional until accepted; accepted friendships are mutual.
- Follow is one-way and requires no approval.
- Block prevents DMs, hides the blocked user's profile/friends interactions, and
removes any existing friendship/follow between the two.
- Duplicate/self requests are rejected idempotently.
### US-2 Presence & status
As a player, I can see whether my friends are online, their custom status, and
when they were last seen.
**Acceptance criteria**
- Online/offline is derived from a server-side heartbeat (Redis TTL), not client claims.
- `last_seen_at` persists and is visible (subject to privacy settings).
- Custom status text is optional and length-limited.
### US-3 Public profiles
As a player, I can view a public profile page showing avatar, bio, favorite
experiences, and a friends list that respects the owner's privacy settings.
**Acceptance criteria**
- Avatar and bio are editable by the owner only.
- Favorite experiences reference existing `World` rows.
- A viewer sees only the sections the owner's privacy settings allow; blocked
users see a restricted view.
### US-4 Chat (DM + in-experience) with safety
As a player, I can send direct messages and chat inside a world/room. Messages
are filtered, rate limited, and reportable.
**Acceptance criteria**
- Server filters message text before storing/delivering; filtered content is either
rejected (blocking) or redacted (configurable per filter policy).
- Send rate is limited per user (token bucket); excess sends get `429`.
- Every message has a stable id, author, timestamp, and conversation/world scope.
- Any message can be reported with a reason; reports are stored and reviewable.
- Real-time delivery over WebSocket with REST fallback for history.
### US-5 Groups
As a player, I can create a group, join/leave groups, and (as owner/admin)
manage roles and view a simple group page.
**Acceptance criteria**
- Each group has exactly one owner and any number of admins/members.
- Role transitions are authorized (only owner can transfer ownership or promote/demote admins).
- Group page lists name, description, member list with roles, and member count.
### US-6 Godot client
As a player, I can access friends, presence, chat, profiles, and groups from the
game UI.
**Acceptance criteria**
- An API client handles JWT auth, HTTP, and WebSocket for all social calls.
- Dedicated screens/panels: friends+presence, DM & world chat (with report),
profile page, group page.
- Client-side filtering is cosmetic; server responses remain authoritative.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
are sequential for dependency chaining.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Friends/follow/block models + Alembic migration | US-1 | — |
| 2 | Friends & follow API router | US-1 | 1 |
| 3 | Presence & status model + endpoints (Redis heartbeat) | US-2 | — |
| 4 | Profile model + migration + own-profile endpoints | US-3 | 1 |
| 5 | Public profile view + favorites + privacy enforcement | US-3 | 2, 4 |
| 6 | Chat data models + migration (conversations, messages, reports) | US-4 | — |
| 7 | Text filtering service + profanity config | US-4 | — |
| 8 | Redis rate-limiting dependency | US-4 | — |
| 9 | DM chat send/list endpoints (filter + rate limit) | US-4 | 6, 7, 8 |
| 10 | Report-message endpoint + moderation queue | US-4 | 6, 9 |
| 11 | In-experience (world/room) chat endpoints | US-4 | 9 |
| 12 | Real-time WebSocket gateway (presence + DM + world chat) | US-4 | 3, 9, 11 |
| 13 | Group + membership models + migration | US-5 | — |
| 14 | Groups API (CRUD, join/leave, roles, group page) | US-5 | 13 |
| 15 | Godot API client (auth + HTTP + WebSocket) | US-6 | 2 |
| 16 | Godot friends & presence UI | US-6 | 3, 15 |
| 17 | Godot chat UI (DM + world chat + report) | US-6 | 12, 15 |
| 18 | Godot profile page UI | US-6 | 5, 15 |
| 19 | Godot groups UI + group page | US-6 | 14, 15 |
## 7. Phasing
- **Phase 1 (data + core APIs):** tasks 1–5 (friends, presence, profiles).
- **Phase 2 (chat safety — the differentiator):** tasks 6–12 (chat + safety +
realtime). This is the critical path and highest priority.
- **Phase 3 (groups):** tasks 13–14.
- **Phase 4 (client):** tasks 15–19, each landing after its backend dependency.
## 8. Out of scope for this feature
- Age verification / maturity gating (covered by `content_moderation.md`).
- Voice chat, media/image messages, message editing/deletion history.
- Third-party profanity/toxicity vendors (the `TextFilter` interface leaves a
hook, but the default is deterministic and dependency-free).
- Mobile push notifications.
-27
View File
@@ -1,27 +0,0 @@
; 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")
agp/api_base_url="http://127.0.0.1:8000"
[autoload]
ApiClient="*res://scripts/api_client.gd"
[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"
-1
View File
@@ -1 +0,0 @@
-174
View File
@@ -1,174 +0,0 @@
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)
-6
View File
@@ -1,6 +0,0 @@
[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")
-8
View File
@@ -1,8 +0,0 @@
; Engine configuration file.
config_version=5
[application]
config/name="sandbox_probe"
run/main_scene="res://probe6.tscn"
config/features=PackedStringArray("4.6")
-4
View File
@@ -1,4 +0,0 @@
extends RefCounted
func hello():
return "from test_script.gd"
View File
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://main_scene"]
[ext_resource type="Script" path="res://scripts/chunk_stream_manager.gd" id="1_chunk_stream"]
[node name="Main" type="Node3D"]
script = ExtResource("1_chunk_stream")
-43
View File
@@ -1,43 +0,0 @@
[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
-292
View File
@@ -1,292 +0,0 @@
extends Node
## Central HTTP client singleton (autoload name: "ApiClient").
##
## Every network call in the game flows through this class. Callers use the
## high-level helpers ([method login], [method register], [method get_worlds],
## [method health_check], [method get_me]) or the generic [method request]
## primitive. All results conform to the result-dictionary contract below.
##
## [b]Result Dictionary Contract[/b]
## Every public method returns (after `await`) a Dictionary with this shape:
##
## {
## "ok": bool, # true for HTTP 2xx, false otherwise
## "status": int, # HTTP status code (0 on network failure)
## "data": Variant, # parsed JSON body on success (null otherwise)
## "detail": String, # human-readable message
## }
##
## Network failures (timeout / DNS / refused connection) return `status == 0`
## with a `detail` describing the transport failure. Non-2xx responses return
## the real HTTP status and the backend's `detail` field from the JSON error
## body when present. No caller can crash on a failed request.
##
## [b]Token storage[/b]
## JWTs are kept in memory. [method set_token] is the single seam for future
## disk persistence (override it to save to `user://`).
##
## [b]Usage[/b]
## var result: Dictionary = await ApiClient.login("alice", "secret")
## if result.ok:
## print("Logged in!")
## else:
## print("Error: ", result.detail)
# ---------------------------------------------------------------------------
# Token storage
# ---------------------------------------------------------------------------
var _access_token: String = ""
var _token_type: String = "Bearer"
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
var bearer_token: String:
get:
return _token_type + " " + _access_token if _access_token != "" else ""
## Whether an access token is currently stored.
func has_token() -> bool:
return _access_token != ""
## Set the access token (called after login / register / token refresh).
## This is the persistence seam: override to also write to disk.
func set_token(access_token: String, token_type: String = "Bearer") -> void:
_access_token = access_token
_token_type = token_type
## Clear stored tokens.
func clear_token() -> void:
_access_token = ""
_token_type = "Bearer"
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
var _base_url: String = "http://127.0.0.1:8000"
## Request timeout in seconds. 0 disables the timeout.
var timeout_seconds: float = 10.0
func _ready() -> void:
# Environment variable override (e.g. for headless smoke tests against a
# backend on a non-default port). Falls back to the ProjectSetting, then
# to the documented default.
var env_url := OS.get_environment("AGP_API_BASE_URL")
if env_url != "":
_base_url = env_url.rstrip("/")
else:
_base_url = str(ProjectSettings.get_setting(
"application/agp/api_base_url",
"http://127.0.0.1:8000"
)).rstrip("/")
# ---------------------------------------------------------------------------
# Core request primitive
# ---------------------------------------------------------------------------
## Generic request primitive. Awaitable via `await`.
##
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
## cross-talk, and so the result maps 1:1 to the awaiting caller.
##
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
## @param path URL path, e.g. "/api/health".
## @param body Request body string (JSON or form-encoded).
## @param content_type "application/json" or "application/x-www-form-urlencoded".
## @param require_auth If true, attach the stored bearer token when present.
## @return Result dictionary conforming to the contract.
func request(
method: HTTPClient.Method,
path: String,
body: String = "",
content_type: String = "application/json",
require_auth: bool = false
) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = timeout_seconds
http.max_redirects = 8
http.accept_gzip = true
add_child(http)
var headers := PackedStringArray()
headers.append("Accept: application/json")
if content_type != "":
headers.append("Content-Type: " + content_type)
if require_auth and has_token():
headers.append("Authorization: " + bearer_token)
var url := _base_url + path
var err := http.request(url, headers, method, body)
if err != OK:
http.queue_free()
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
# request_completed emits (result, response_code, headers, body).
var args: Array = await http.request_completed
var result_code: int = args[0]
var response_code: int = args[1]
var response_body: PackedByteArray = args[3]
http.queue_free()
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
if result_code != HTTPRequest.RESULT_SUCCESS:
return _make_result(false, 0, null, _transport_error_detail(result_code))
# Server accepted the connection but returned no HTTP status line.
if response_code == 0:
return _make_result(false, 0, null, "No response received from server.")
var json_string := response_body.get_string_from_utf8()
var ok := response_code >= 200 and response_code < 300
if ok:
var data: Variant = _parse_json(json_string)
return _make_result(true, response_code, data, "OK")
# Non-2xx: surface the backend's "detail" message when available.
var detail := "HTTP error %d" % response_code
var parsed: Variant = _parse_json(json_string)
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
detail = str((parsed as Dictionary)["detail"])
return _make_result(false, response_code, null, detail)
# ---------------------------------------------------------------------------
# High-level helpers
# ---------------------------------------------------------------------------
## Log in with username/password (OAuth2PasswordRequestForm).
## On success stores the returned access token.
func login(username: String, password: String) -> Dictionary:
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
var result := await request(
HTTPClient.METHOD_POST,
"/api/auth/login",
form,
"application/x-www-form-urlencoded",
false
)
_store_token_from_result(result)
return result
## Register a new user. On success stores the returned access token.
## @param date_of_birth ISO date string "YYYY-MM-DD".
func register(
username: String,
email: String,
password: String,
date_of_birth: String
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"date_of_birth": date_of_birth,
}
var result := await request(
HTTPClient.METHOD_POST,
"/api/auth/register",
JSON.stringify(payload),
"application/json",
false
)
_store_token_from_result(result)
return result
## List worlds visible to the current user (requires auth).
## @param visibility Optional filter: "public", "private", "friends_only".
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
if visibility != "":
path += "&visibility=" + visibility.uri_encode()
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
## Ping the backend health endpoint (no auth).
func health_check() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
## Fetch the current user's profile from the stored JWT (requires auth).
func get_me() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
func _store_token_from_result(result: Dictionary) -> void:
if result.ok and result.data is Dictionary:
var token: String = str((result.data as Dictionary).get("access_token", ""))
if token != "":
set_token(token)
func _parse_json(text: String) -> Variant:
if text == "":
return null
var parsed: Variant = JSON.parse_string(text)
if parsed == null:
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
# the raw string so callers never lose the body entirely.
return text if text != "null" else null
return parsed
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
return {
"ok": ok,
"status": status,
"data": data,
"detail": detail,
}
func _transport_error_detail(result_code: int) -> String:
match result_code:
HTTPRequest.RESULT_CANT_CONNECT:
return "Could not connect to server. Is it running?"
HTTPRequest.RESULT_CANT_RESOLVE:
return "Could not resolve server hostname."
HTTPRequest.RESULT_CONNECTION_ERROR:
return "Connection error."
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS handshake failed."
HTTPRequest.RESULT_NO_RESPONSE:
return "No response from server."
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
return "Response body too large."
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
return "Failed to decompress response body."
HTTPRequest.RESULT_REQUEST_FAILED:
return "Request failed."
HTTPRequest.RESULT_TIMEOUT:
return "Request timed out."
_:
return "Network error (code %d)." % result_code
func _error_string(err_code: int) -> String:
match err_code:
OK:
return "OK"
ERR_UNCONFIGURED:
return "HTTPRequest not configured (not in tree)."
ERR_BUSY:
return "HTTPRequest is busy with a previous request."
ERR_INVALID_PARAMETER:
return "Invalid URL format."
ERR_CANT_CONNECT:
return "Cannot connect to host."
_:
return "Unknown error (%d)." % err_code
-1
View File
@@ -1 +0,0 @@
uid://cjd0uhll7iylj
-105
View File
@@ -1,105 +0,0 @@
class_name Chunk
extends Node3D
## A single world chunk. Holds the chunk coordinate, bounds, and the
## visual/gameplay content for that region of the world.
##
## Chunks are the atomic unit of world geometry, gameplay objects, and
## persistence (see docs/architecture/world.md). The server loads and saves
## whole chunks; clients stream the chunks around them.
## Default chunk dimensions in world units (tiles). Matches the constants in
## docs/architecture/world.md section 2.2.
const CHUNK_SIZE_X: int = 32
const CHUNK_SIZE_Y: int = 32
const CHUNK_SIZE_Z: int = 1
## World-space origin of chunk coordinate (0,0).
const CHUNK_ORIGIN := Vector3(0.0, 0.0, 0.0)
## Chunk coordinate (cx, cy, cz) in chunk space.
var coord := Vector3i.ZERO
## The canonical chunk key: "{world_id}:{room_id}:{cx}:{cy}:{cz}".
var key: String = ""
## World and room identifiers this chunk belongs to.
var world_id: String = ""
var room_id: String = ""
## Format version of the chunk payload (currently 1).
var format_version: int = 1
## Revision counter, incremented on every write. Used for optimistic
## concurrency and change detection.
var revision: int = 0
## Whether this chunk has been modified in memory but not yet flushed.
var dirty: bool = false
## Container for static objects (props, geometry) in this chunk.
var objects: Array[Node3D] = []
## Container for dynamic object templates (spawnable entities).
var dynamic_templates: Array[Node3D] = []
## The world-space bounding box of this chunk.
var bounds: AABB
func _init(p_coord: Vector3i = Vector3i.ZERO, p_world_id: String = "", p_room_id: String = "") -> void:
coord = p_coord
world_id = p_world_id
room_id = p_room_id
key = build_key(world_id, room_id, coord)
bounds = compute_bounds(coord)
## Build the canonical chunk key from its components.
static func build_key(p_world_id: String, p_room_id: String, p_coord: Vector3i) -> String:
return "%s:%s:%d:%d:%d" % [p_world_id, p_room_id, p_coord.x, p_coord.y, p_coord.z]
## Compute the world-space bounding box for a chunk coordinate.
static func compute_bounds(p_coord: Vector3i) -> AABB:
var min_pos := Vector3(
CHUNK_ORIGIN.x + p_coord.x * CHUNK_SIZE_X,
CHUNK_ORIGIN.y + p_coord.y * CHUNK_SIZE_Y,
CHUNK_ORIGIN.z + p_coord.z * CHUNK_SIZE_Z
)
return AABB(min_pos, Vector3(CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z))
## Compute the chunk coordinate that contains a world-space position.
static func coord_for_position(p_pos: Vector3) -> Vector3i:
return Vector3i(
int(floor((p_pos.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)),
int(floor((p_pos.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)),
int(floor((p_pos.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z))
)
## Add a static object to this chunk.
func add_object(p_object: Node3D) -> void:
objects.append(p_object)
add_child(p_object)
## Add a dynamic object template to this chunk.
func add_dynamic_template(p_template: Node3D) -> void:
dynamic_templates.append(p_template)
add_child(p_template)
## Remove all objects from this chunk.
func clear_objects() -> void:
for obj in objects:
if is_instance_valid(obj):
obj.queue_free()
objects.clear()
dynamic_templates.clear()
## Mark this chunk as dirty (modified but not yet persisted).
func mark_dirty() -> void:
dirty = true
revision += 1
-1
View File
@@ -1 +0,0 @@
uid://dc6j7kqjitc6g
-667
View File
@@ -1,667 +0,0 @@
class_name ChunkDataDecoder
extends RefCounted
## Decodes chunk binary payloads (and JSON) into logical chunk data.
##
## Implements the binary format spec from docs/architecture/world.md §3.6.
## Both the Godot client and Python backend use this class to ensure
## identical encoding/decoding behavior.
##
## ## Binary Format Overview
##
## ```
## Header (48 bytes):
## [0:4] magic "AGPC"
## [4:6] version (uint16 LE)
## [6:8] header_len (uint16 LE)
## [8:12] flags (uint32 LE)
## [12:16] payload_len (uint32 LE)
## [16:48] checksum (SHA-256 hex, 32 bytes)
##
## Payload Body (sections):
## [section_id:1][section_len:4][data:section_len]
## ```
## Content flags parsed from a chunk.
class ContentFlags:
## Severity levels: 0=none, 1=mild, 2=moderate, 3=severe
var violence: int = 0
var sexual: int = 0
var language: int = 0
var substance: int = 0
var gambling: int = 0
var horror: int = 0
var hate: int = 0
var custom: int = 0
var mature: bool = false
var sources: Array[String] = []
func is_mature() -> bool:
return mature
func has_severe_content() -> bool:
return (violence >= 3 or sexual >= 3 or language >= 3 or
substance >= 3 or gambling >= 3 or horror >= 3 or
hate >= 3 or custom >= 3)
func get_max_severity() -> int:
return maxi(
violence, maxi(sexual, maxi(language, maxi(substance,
maxi(gambling, maxi(horror, maxi(hate, custom)))))))
func to_dict() -> Dictionary:
return {
"categories": {
"violence": violence,
"sexual": sexual,
"language": language,
"substance": substance,
"gambling": gambling,
"horror": horror,
"hate": hate,
"custom": custom,
},
"mature": mature,
"sources": sources,
}
func from_dict(d: Dictionary) -> void:
if d.has("categories"):
violence = d["categories"].get("violence", 0)
sexual = d["categories"].get("sexual", 0)
language = d["categories"].get("language", 0)
substance = d["categories"].get("substance", 0)
gambling = d["categories"].get("gambling", 0)
horror = d["categories"].get("horror", 0)
hate = d["categories"].get("hate", 0)
custom = d["categories"].get("custom", 0)
mature = d.get("mature", false)
sources = d.get("sources", [])
## A tile layer definition (palette + dense tile indices).
class TileLayerData:
var name: String = ""
var layer_id: int = 0
var width: int = 0
var height: int = 0
var depth: int = 1
var palette: Array[TileDef] = []
var tiles: PackedInt32Array = PackedInt32Array()
func tile_count() -> int:
return width * height * depth
func get_tile(x: int, y: int, z: int = 0) -> int:
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
return 0 # Out of bounds returns empty tile
var idx := (z * height + y) * width + x
if idx < tiles.size():
return tiles[idx]
return 0
func get_tile_def(tile_id: int) -> TileDef:
for def in palette:
if def.id == tile_id:
return def
return null
## A world object parsed from chunk data.
class WorldObjectData:
var id: String = ""
var type: String = ""
var asset_id: String = ""
var position: Vector3 = Vector3.ZERO
var rotation: Vector4 = Vector4(0, 0, 0, 1)
var scale: Vector3 = Vector3.ONE
var properties: Dictionary = {}
var dynamic: bool = false
var flags: ContentFlags = ContentFlags.new()
## The complete decoded chunk payload.
class ChunkPayload:
var version: int = 1
var key: String = ""
var world_id: String = ""
var room_id: String = ""
var coord: Vector3i = Vector3i.ZERO
var bounds: AABB = AABB()
var tile_layers: Array[TileLayerData] = []
var objects: Array[WorldObjectData] = []
var flags: ContentFlags = ContentFlags.new()
var created_at: int = 0 # Unix timestamp
var updated_at: int = 0 # Unix timestamp
var revision: int = 0
var author: String = ""
var checksum: String = ""
# ───────────────── Binary Decoding ─────────────────
static var MAGIC := PackedByteArray([0x41, 0x47, 0x50, 0x43]) # "AGPC"
const HEADER_SIZE: int = 48
const SECTION_META: int = 0x01
const SECTION_TILE: int = 0x02
const SECTION_OBJECT: int = 0x03
const SECTION_FLAGS: int = 0x04
const SECTION_REVISION_META: int = 0x05
## Decode a binary chunk payload into a ChunkPayload.
## Returns null if the data is invalid or version is unsupported.
static func decode_binary(data: PackedByteArray) -> ChunkPayload:
if data.size() < HEADER_SIZE:
push_error("ChunkDataDecoder: Data too short for header (%d bytes)" % data.size())
return null
# Verify magic
var magic := PackedByteArray(data.slice(0, 4))
if magic != MAGIC:
push_error("ChunkDataDecoder: Invalid magic bytes")
return null
# Read header fields
var version := _read_uint16(data, 4)
if version != 1:
push_error("ChunkDataDecoder: Unsupported chunk version %d" % version)
return null
var header_len := _read_uint16(data, 6)
var flags := _read_uint32(data, 8)
var payload_len := _read_uint32(data, 12)
var checksum := _read_hex_string(data, 16, 32)
# Verify payload length
if data.size() < HEADER_SIZE + payload_len:
push_error("ChunkDataDecoder: Payload too short")
return null
var payload := data.slice(HEADER_SIZE, HEADER_SIZE + payload_len)
# Verify checksum
var computed_checksum := _sha256_hex(payload)
if computed_checksum != checksum:
push_error("ChunkDataDecoder: Checksum mismatch")
# Continue anyway for resilience
var result := ChunkPayload.new()
result.version = version
result.checksum = checksum
# Parse sections
var offset := 0
while offset < payload.size():
if offset + 5 > payload.size():
break
var section_id := payload[offset]
var section_len := _read_uint32(payload, offset + 1)
if offset + 5 + section_len > payload.size():
break
var section_data := payload.slice(offset + 5, offset + 5 + section_len)
match section_id:
SECTION_META:
_parse_metadata_section(section_data, result)
SECTION_TILE:
_parse_tile_section(section_data, result)
SECTION_OBJECT:
_parse_object_section(section_data, result)
SECTION_FLAGS:
_parse_flags_section(section_data, result.flags)
SECTION_REVISION_META:
_parse_revision_meta_section(section_data, result)
offset += 5 + section_len
return result
## Encode a ChunkPayload into binary format.
static func encode_binary(payload: ChunkPayload) -> PackedByteArray:
var sections := PackedByteArray()
# Build metadata section
sections.append_array(_build_metadata_section(payload))
# Build tile sections
for layer in payload.tile_layers:
sections.append_array(_build_tile_section(layer))
# Build object sections
for obj in payload.objects:
sections.append_array(_build_object_section(obj))
# Build flags section
sections.append_array(_build_flags_section(payload.flags))
# Build revision meta section
sections.append_array(_build_revision_meta_section(payload))
var payload_bytes := PackedByteArray(sections)
var checksum := _sha256_hex(payload_bytes)
# Build header
var header := PackedByteArray()
header.append_array(MAGIC)
header.append_array(_write_uint16(1)) # version
header.append_array(_write_uint16(0)) # header_len (after this field)
header.append_array(_write_uint32(0)) # flags
header.append_array(_write_uint32(payload_bytes.size()))
header.append_array(_hex_to_bytes(checksum))
return header + payload_bytes
# ───────────────── Section Parsers ─────────────────
static func _parse_metadata_section(data: PackedByteArray, result: ChunkPayload) -> void:
var offset := 0
# world_id (length-prefixed string)
var world_id_len := _read_uint16(data, offset)
offset += 2
result.world_id = data.slice(offset, offset + world_id_len).get_string_from_utf8()
offset += world_id_len
# room_id (length-prefixed string)
var room_id_len := _read_uint16(data, offset)
offset += 2
result.room_id = data.slice(offset, offset + room_id_len).get_string_from_utf8()
offset += room_id_len
# coord (3 × int32)
result.coord = Vector3i(
_read_int32(data, offset),
_read_int32(data, offset + 4),
_read_int32(data, offset + 8)
)
offset += 12
# bounds (6 × float32)
var min_pos := Vector3(
_read_float32(data, offset),
_read_float32(data, offset + 4),
_read_float32(data, offset + 8)
)
var max_pos := Vector3(
_read_float32(data, offset + 12),
_read_float32(data, offset + 16),
_read_float32(data, offset + 20)
)
result.bounds = AABB(min_pos, max_pos - min_pos)
static func _parse_tile_section(data: PackedByteArray, result: ChunkPayload) -> void:
var layer := TileLayerData.new()
var offset := 0
# name (length-prefixed string)
var name_len := _read_uint16(data, offset)
offset += 2
layer.name = data.slice(offset, offset + name_len).get_string_from_utf8()
offset += name_len
# layer_id (uint16)
layer.layer_id = _read_uint16(data, offset)
offset += 2
# dimensions (3 × uint16)
layer.width = _read_uint16(data, offset)
offset += 2
layer.height = _read_uint16(data, offset)
offset += 2
layer.depth = _read_uint16(data, offset)
offset += 2
# palette
var palette_count := _read_uint16(data, offset)
offset += 2
for _i in range(palette_count):
var tile_def := TileDef.new()
tile_def.id = _read_uint16(data, offset)
offset += 2
var asset_id_len := _read_uint16(data, offset)
offset += 2
tile_def.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
offset += asset_id_len
tile_def.solid = data[offset] != 0
offset += 1
tile_def.flags = _read_uint32(data, offset)
offset += 4
tile_def.variants = data[offset]
offset += 1
layer.palette.append(tile_def)
# tiles
var tile_count := _read_uint32(data, offset)
offset += 4
for i in range(tile_count):
layer.tiles.append(_read_uint16(data, offset))
offset += 2
result.tile_layers.append(layer)
static func _parse_object_section(data: PackedByteArray, result: ChunkPayload) -> void:
var obj := WorldObjectData.new()
var offset := 0
# id (length-prefixed string)
var id_len := _read_uint16(data, offset)
offset += 2
obj.id = data.slice(offset, offset + id_len).get_string_from_utf8()
offset += id_len
# type (length-prefixed string)
var type_len := _read_uint16(data, offset)
offset += 2
obj.type = data.slice(offset, offset + type_len).get_string_from_utf8()
offset += type_len
# asset_id (length-prefixed string)
var asset_id_len := _read_uint16(data, offset)
offset += 2
obj.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
offset += asset_id_len
# position (3 × float32)
obj.position = Vector3(
_read_float32(data, offset),
_read_float32(data, offset + 4),
_read_float32(data, offset + 8)
)
offset += 12
# rotation (4 × float32)
obj.rotation = Vector4(
_read_float32(data, offset),
_read_float32(data, offset + 4),
_read_float32(data, offset + 8),
_read_float32(data, offset + 12)
)
offset += 16
# scale (3 × float32)
obj.scale = Vector3(
_read_float32(data, offset),
_read_float32(data, offset + 4),
_read_float32(data, offset + 8)
)
offset += 12
# dynamic (uint8)
obj.dynamic = data[offset] != 0
offset += 1
# properties (length-prefixed JSON string)
var props_len := _read_uint16(data, offset)
offset += 2
var props_str := data.slice(offset, offset + props_len).get_string_from_utf8()
offset += props_len
obj.properties = JSON.parse_string(props_str) or {}
# flags (ContentFlags)
obj.flags.violence = data[offset]
offset += 1
obj.flags.sexual = data[offset]
offset += 1
obj.flags.language = data[offset]
offset += 1
obj.flags.substance = data[offset]
offset += 1
obj.flags.gambling = data[offset]
offset += 1
obj.flags.horror = data[offset]
offset += 1
obj.flags.hate = data[offset]
offset += 1
obj.flags.custom = data[offset]
offset += 1
obj.flags.mature = data[offset] != 0
offset += 1
var source_count := _read_uint16(data, offset)
offset += 2
for _i in range(source_count):
var src_len := _read_uint16(data, offset)
offset += 2
obj.flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
offset += src_len
result.objects.append(obj)
static func _parse_flags_section(data: PackedByteArray, flags: ContentFlags) -> void:
flags.violence = data[0]
flags.sexual = data[1]
flags.language = data[2]
flags.substance = data[3]
flags.gambling = data[4]
flags.horror = data[5]
flags.hate = data[6]
flags.custom = data[7]
flags.mature = data[8] != 0
var offset := 9
var source_count := _read_uint16(data, offset)
offset += 2
for _i in range(source_count):
var src_len := _read_uint16(data, offset)
offset += 2
flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
offset += src_len
static func _parse_revision_meta_section(data: PackedByteArray, result: ChunkPayload) -> void:
var offset := 0
# created_at (int64 unix)
result.created_at = _read_int64(data, offset)
offset += 8
# updated_at (int64 unix)
result.updated_at = _read_int64(data, offset)
offset += 8
# revision (uint32)
result.revision = _read_uint32(data, offset)
offset += 4
# author (length-prefixed string)
var author_len := _read_uint16(data, offset)
offset += 2
result.author = data.slice(offset, offset + author_len).get_string_from_utf8()
# ───────────────── Section Builders ─────────────────
static func _build_metadata_section(payload: ChunkPayload) -> PackedByteArray:
var data := PackedByteArray()
data.append_array(_write_string(payload.world_id))
data.append_array(_write_string(payload.room_id))
data.append_array(_write_int32(payload.coord.x))
data.append_array(_write_int32(payload.coord.y))
data.append_array(_write_int32(payload.coord.z))
data.append_array(_write_float32(payload.bounds.position.x))
data.append_array(_write_float32(payload.bounds.position.y))
data.append_array(_write_float32(payload.bounds.position.z))
data.append_array(_write_float32(payload.bounds.size.x))
data.append_array(_write_float32(payload.bounds.size.y))
data.append_array(_write_float32(payload.bounds.size.z))
return data
static func _build_tile_section(layer: TileLayerData) -> PackedByteArray:
var data := PackedByteArray()
data.append_array(_write_string(layer.name))
data.append_array(_write_uint16(layer.layer_id))
data.append_array(_write_uint16(layer.width))
data.append_array(_write_uint16(layer.height))
data.append_array(_write_uint16(layer.depth))
data.append_array(_write_uint16(layer.palette.size()))
for tile_def in layer.palette:
data.append_array(_write_uint16(tile_def.id))
data.append_array(_write_string(tile_def.asset_id))
data.append(tile_def.solid as int)
data.append_array(_write_uint32(tile_def.flags))
data.append(tile_def.variants)
data.append_array(_write_uint32(layer.tiles.size()))
for tile_id in layer.tiles:
data.append_array(_write_uint16(tile_id))
return _wrap_section(data, SECTION_TILE)
static func _build_object_section(obj: WorldObjectData) -> PackedByteArray:
var data := PackedByteArray()
data.append_array(_write_string(obj.id))
data.append_array(_write_string(obj.type))
data.append_array(_write_string(obj.asset_id))
data.append_array(_write_float32(obj.position.x))
data.append_array(_write_float32(obj.position.y))
data.append_array(_write_float32(obj.position.z))
data.append_array(_write_float32(obj.rotation.x))
data.append_array(_write_float32(obj.rotation.y))
data.append_array(_write_float32(obj.rotation.z))
data.append_array(_write_float32(obj.rotation.w))
data.append_array(_write_float32(obj.scale.x))
data.append_array(_write_float32(obj.scale.y))
data.append_array(_write_float32(obj.scale.z))
data.append(obj.dynamic as int)
var props_json := JSON.stringify(obj.properties, " ")
data.append_array(_write_string(props_json))
data.append(obj.flags.violence)
data.append(obj.flags.sexual)
data.append(obj.flags.language)
data.append(obj.flags.substance)
data.append(obj.flags.gambling)
data.append(obj.flags.horror)
data.append(obj.flags.hate)
data.append(obj.flags.custom)
data.append(obj.flags.mature as int)
data.append_array(_write_uint16(obj.flags.sources.size()))
for src in obj.flags.sources:
data.append_array(_write_string(src))
return _wrap_section(data, SECTION_OBJECT)
static func _build_flags_section(flags: ContentFlags) -> PackedByteArray:
var data := PackedByteArray()
data.append(flags.violence)
data.append(flags.sexual)
data.append(flags.language)
data.append(flags.substance)
data.append(flags.gambling)
data.append(flags.horror)
data.append(flags.hate)
data.append(flags.custom)
data.append(flags.mature as int)
data.append_array(_write_uint16(flags.sources.size()))
for src in flags.sources:
data.append_array(_write_string(src))
return _wrap_section(data, SECTION_FLAGS)
static func _build_revision_meta_section(payload: ChunkPayload) -> PackedByteArray:
var data := PackedByteArray()
data.append_array(_write_int64(payload.created_at))
data.append_array(_write_int64(payload.updated_at))
data.append_array(_write_uint32(payload.revision))
data.append_array(_write_string(payload.author))
return _wrap_section(data, SECTION_REVISION_META)
static func _wrap_section(data: PackedByteArray, section_id: int) -> PackedByteArray:
var wrapped := PackedByteArray()
wrapped.append(section_id)
wrapped.append_array(_write_uint32(data.size()))
wrapped.append_array(data)
return wrapped
# ───────────────── Binary Helpers ─────────────────
static func _read_uint16(data: PackedByteArray, offset: int) -> int:
return data[offset] | (data[offset + 1] << 8)
static func _read_uint32(data: PackedByteArray, offset: int) -> int:
return (data[offset] | (data[offset + 1] << 8) |
(data[offset + 2] << 16) | (data[offset + 3] << 24))
static func _read_int32(data: PackedByteArray, offset: int) -> int:
var val := _read_uint32(data, offset)
if val >= 0x80000000:
return val - 0x100000000
return val
static func _read_int64(data: PackedByteArray, offset: int) -> int:
var lo := _read_uint32(data, offset)
var hi := _read_uint32(data, offset + 4)
return hi * 0x100000000 + lo
static func _read_float32(data: PackedByteArray, offset: int) -> float:
var bits := _read_uint32(data, offset)
return bits as float # GDScript float is 64-bit; bits repr works for IEEE 754
static func _read_hex_string(data: PackedByteArray, offset: int, length: int) -> String:
var hex_chars := "0123456789abcdef"
var result := ""
for i in range(length):
var byte_val := data[offset + i]
result += hex_chars[byte_val >> 4]
result += hex_chars[byte_val & 0x0F]
return result
static func _write_uint16(val: int) -> PackedByteArray:
return PackedByteArray([(val & 0xFF), (val >> 8) & 0xFF])
static func _write_uint32(val: int) -> PackedByteArray:
return PackedByteArray([
val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF
])
static func _write_int32(val: int) -> PackedByteArray:
return _write_uint32(val)
static func _write_int64(val: int) -> PackedByteArray:
var lo := val & 0xFFFFFFFF
var hi := (val >> 32) & 0xFFFFFFFF
return _write_uint32(lo) + _write_uint32(hi)
static func _write_float32(val: float) -> PackedByteArray:
var bits := val as int
return _write_uint32(bits)
static func _write_string(s: String) -> PackedByteArray:
var bytes := s.to_utf8_buffer()
return _write_uint16(bytes.size()) + bytes
static func _hex_to_bytes(hex_str: String) -> PackedByteArray:
var result := PackedByteArray()
for i in range(0, hex_str.length(), 2):
var byte_val := ("0x" + hex_str.substr(i, 2)).hex_to_int()
result.append(byte_val)
return result
# ───────────────── SHA-256 ─────────────────
static func _sha256_hex(data: PackedByteArray) -> String:
var ctx := HashingContext.new()
ctx.start(HashingContext.HASH_SHA256)
ctx.update(data)
var hash := ctx.finish()
var hex_chars := "0123456789abcdef"
var result := ""
for byte_val in hash:
result += hex_chars[(byte_val >> 4) & 0x0F]
result += hex_chars[byte_val & 0x0F]
return result
-1
View File
@@ -1 +0,0 @@
uid://bv445xrd6f5tk
-170
View File
@@ -1,170 +0,0 @@
class_name ChunkScene
extends Node3D
## A visual representation of a single loaded world chunk.
##
## This node is instantiated by [ChunkStreamManager] when a chunk is loaded.
## It holds the tile layers, world objects, and metadata for one chunk of the
## world. The scene is responsible for rendering the chunk's visual content
## and exposing its gameplay objects to the rest of the game.
##
## ## Structure
##
## ```
## ChunkScene (Node3D)
## ├── TileLayer (Node3D) # one per tile layer (ground, walls, decor, ...)
## ├── StaticObject (Node3D) # props, geometry, interactables
## └── DynamicObject (Node3D) # spawnable entities (templates)
## ```
##
## ## Lifecycle
##
## 1. [ChunkStreamManager] requests chunk data from the server.
## 2. Server responds with binary payload.
## 3. [ChunkStreamManager] decodes the payload and creates a [ChunkScene].
## 4. [ChunkScene] populates itself with tile layers and objects.
## 5. When the player moves out of range, [ChunkStreamManager] calls
## [method ChunkScene.queue_free] to unload it.
## The chunk coordinate this scene represents.
var coord: Vector3i = Vector3i.ZERO
## The canonical chunk key.
var key: String = ""
## World-space bounding box of this chunk.
var bounds: AABB = AABB()
## Format version of the chunk data.
var format_version: int = 1
## Revision counter from the server.
var revision: int = 0
## List of tile layers in this chunk.
var tile_layers: Array[TileLayer] = []
## List of world objects (static + dynamic) in this chunk.
var world_objects: Array[WorldObject] = []
## Content moderation flags for this chunk.
var content_flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
## Whether this chunk has been modified locally (not yet synced to server).
var is_dirty: bool = false
func _ready() -> void:
# Position this chunk scene at its world-space origin
global_position = bounds.position
# Set up collision shape for the chunk bounds
_setup_collision()
func _setup_collision() -> void:
# Add a collision shape for the chunk's bounding box
var collision := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = bounds.size
collision.shape = shape
collision.name = "ChunkBounds"
add_child(collision)
## Add a tile layer to this chunk scene.
func add_tile_layer(layer: TileLayer) -> void:
tile_layers.append(layer)
var layer_node := TileLayerNode.new()
layer_node.tile_layer = layer
layer_node.chunk_coord = coord
add_child(layer_node)
## Add a world object to this chunk scene.
func add_object(obj: WorldObject) -> void:
world_objects.append(obj)
if obj.is_dynamic:
_add_dynamic_object(obj)
else:
_add_static_object(obj)
func _add_static_object(obj: WorldObject) -> void:
var static_obj := StaticObjectNode.new()
static_obj.world_object = obj
static_obj.position = obj.position
static_obj.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
static_obj.scale = obj.scale
static_obj.name = "static_%s" % obj.id
add_child(static_obj)
func _add_dynamic_object(obj: WorldObject) -> void:
var dynamic_obj := DynamicObjectNode.new()
dynamic_obj.world_object = obj
dynamic_obj.position = obj.position
dynamic_obj.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
dynamic_obj.scale = obj.scale
dynamic_obj.name = "dynamic_%s" % obj.id
add_child(dynamic_obj)
## Get a world object by its ID.
func get_object_by_id(object_id: String) -> WorldObject:
for obj in world_objects:
if obj.id == object_id:
return obj
return null
## Get all static objects in this chunk.
func get_static_objects() -> Array[WorldObject]:
var result: Array[WorldObject] = []
for obj in world_objects:
if not obj.is_dynamic:
result.append(obj)
return result
## Get all dynamic object templates in this chunk.
func get_dynamic_templates() -> Array[WorldObject]:
var result: Array[WorldObject] = []
for obj in world_objects:
if obj.is_dynamic:
result.append(obj)
return result
## Mark this chunk as dirty (locally modified).
func mark_dirty() -> void:
is_dirty = true
## Clear all objects and tile layers from this chunk.
func clear() -> void:
for child in get_children():
if child is TileLayerNode or child is StaticObjectNode or child is DynamicObjectNode:
child.queue_free()
tile_layers.clear()
world_objects.clear()
## Get the world-space position of a tile within this chunk.
func get_tile_world_position(tile_x: int, tile_y: int, tile_z: int = 0) -> Vector3:
return bounds.position + Vector3(
tile_x * 1.0, # Tile size is 1 world unit
tile_y * 1.0,
tile_z * 1.0
)
## Check if a world-space position is within this chunk's bounds.
func contains_position(pos: Vector3) -> bool:
return bounds.has_point(pos)
## Check if a chunk coordinate is adjacent to this chunk.
func is_adjacent(other_coord: Vector3i) -> bool:
var dx: int = abs(coord.x - other_coord.x)
var dy: int = abs(coord.y - other_coord.y)
var dz: int = abs(coord.z - other_coord.z)
return dx <= 1 and dy <= 1 and dz <= 1 and (dx + dy + dz) > 0
-1
View File
@@ -1 +0,0 @@
uid://d4gh4h5iey6xb
-358
View File
@@ -1,358 +0,0 @@
class_name ChunkStreamManager
extends Node
## Manages loading and unloading of world chunks based on player position.
##
## This is the central orchestrator for the chunk streaming system. It monitors
## the player's position, determines which chunks should be loaded, requests
## chunk data from the server, instantiates ChunkScene nodes, and unloads
## chunks that fall outside the streaming radius.
##
## ## Design
##
## - **Streaming radius** defines how many chunks around the player are kept
## loaded. Chunks beyond this radius are unloaded to save memory and CPU.
## - **Load priority** ensures chunks closer to the player are loaded first,
## preventing visual pop-in at the edge of the view.
## - **Server communication** uses the RPCs defined in docs/architecture/world.md
## (§5.3): `request_chunk`, `chunk_data`, `chunk_invalidated`.
##
## ## Usage
##
## Add this node to your scene tree and set the `player` reference:
## ```gdscript
## var stream_manager: ChunkStreamManager = $ChunkStreamManager
## stream_manager.player = $Player
## stream_manager.streaming_radius = 4 # chunks
## ```
## Number of chunks (radius) to keep loaded around the player.
## A radius of 2 means a 5x5 grid of chunks centered on the player.
## Higher values increase memory usage but reduce streaming pop-in.
@export var streaming_radius: int = 3
## Maximum number of chunks to load per frame. Prevents frame spikes during
## rapid movement or when entering a new area.
@export var max_loads_per_frame: int = 4
## Maximum number of chunks to unload per frame.
@export var max_unloads_per_frame: int = 4
## Distance (in chunks) from the streaming radius edge before a chunk is
## considered for unloading. Prevents rapid load/unload cycling.
@export var unload_margin: int = 1
## Emitted when a chunk finishes loading.
signal chunk_loaded(chunk_coord: Vector3i, chunk_scene: Node)
## Emitted when a chunk is unloaded.
signal chunk_unloaded(chunk_coord: Vector3i)
## Emitted when a chunk fails to load (e.g., server error).
signal chunk_load_failed(chunk_coord: Vector3i, error: String)
## Emitted when the player moves far enough to require a streaming update.
signal streaming_updated
## Reference to the player node. Must be set before the manager starts.
var player: Node3D = null
## The current player chunk coordinate.
var _player_chunk_coord: Vector3i = Vector3i.ZERO
## Map of loaded chunk coordinates to their scene nodes.
var _loaded_chunks: Dictionary = {}
## Queue of chunk coordinates pending load, sorted by distance from player.
var _load_queue: Array[Vector3i] = []
## Queue of chunk coordinates pending unload.
var _unload_queue: Array[Vector3i] = []
## Whether streaming is currently active (player is set and in game).
var is_active: bool = false
## The server RPC interface for chunk data. Set by the networking layer.
var _server_rpc: Object = null
## Internal: track which chunks are currently being loaded (to avoid duplicates).
var _loading_in_progress: Dictionary = {}
## Internal: time accumulator for throttled streaming updates.
var _update_accumulator: float = 0.0
## Internal: interval between streaming updates (seconds).
const UPDATE_INTERVAL: float = 0.1
func _ready() -> void:
# Streaming is driven from _process (main thread) so we can safely
# manipulate the scene tree. No background thread is used.
set_process(true)
func _process(delta: float) -> void:
if not is_active or player == null:
return
_update_accumulator += delta
if _update_accumulator < UPDATE_INTERVAL:
return
_update_accumulator = 0.0
_update_streaming()
_process_load_queue()
_process_unload_queue()
streaming_updated.emit()
func _update_streaming() -> void:
if player == null or not is_inside_tree():
return
var current_coord: Vector3i = Chunk.coord_for_position(player.global_position)
if current_coord == _player_chunk_coord:
return
_player_chunk_coord = current_coord
_rebuild_queues()
func _rebuild_queues() -> void:
var target_set: Dictionary = _compute_target_chunks()
# Determine which chunks need to be loaded
var to_load: Array[Vector3i] = []
for coord in target_set:
if not _loaded_chunks.has(coord) and not _loading_in_progress.has(coord):
to_load.append(coord)
# Sort by distance from player (closest first)
to_load.sort_custom(_compare_distance)
_load_queue = to_load
# Determine which chunks need to be unloaded
var to_unload: Array[Vector3i] = []
for coord in _loaded_chunks:
if not target_set.has(coord):
to_unload.append(coord)
to_unload.sort_custom(_compare_distance)
_unload_queue = to_unload
func _compute_target_chunks() -> Dictionary:
"""Compute the set of chunk coordinates that should be loaded."""
var target: Dictionary = {}
var half: int = streaming_radius
for dx in range(-half, half + 1):
for dy in range(-half, half + 1):
for dz in range(-half, half + 1):
var coord: Vector3i = Vector3i(
_player_chunk_coord.x + dx,
_player_chunk_coord.y + dy,
_player_chunk_coord.z + dz
)
target[coord] = coord
return target
func _compare_distance(a: Vector3i, b: Vector3i) -> bool:
"""Compare chunk coordinates by distance from player chunk."""
var dist_a: int = _chunk_distance_squared(_player_chunk_coord, a)
var dist_b: int = _chunk_distance_squared(_player_chunk_coord, b)
return dist_a < dist_b
func _chunk_distance_squared(a: Vector3i, b: Vector3i) -> int:
var dx: int = a.x - b.x
var dy: int = a.y - b.y
var dz: int = a.z - b.z
return dx * dx + dy * dy + dz * dz
func _process_load_queue() -> void:
var count: int = 0
while count < max_loads_per_frame and not _load_queue.is_empty():
var coord: Vector3i = _load_queue.pop_front()
_load_chunk(coord)
count += 1
func _process_unload_queue() -> void:
var count: int = 0
while count < max_unloads_per_frame and not _unload_queue.is_empty():
var coord: Vector3i = _unload_queue.pop_front()
_unload_chunk(coord)
count += 1
func _load_chunk(coord: Vector3i) -> void:
if _loading_in_progress.has(coord):
return
_loading_in_progress[coord] = true
# Request chunk data from server via RPC
if _server_rpc != null and _server_rpc.has_method("request_chunk"):
_server_rpc.request_chunk(Chunk.build_key(
"", "", coord
))
else:
# Fallback: try to load from local cache or create empty chunk
_instantiate_chunk_scene(coord)
func _on_chunk_data_received(key: String, binary_data: PackedByteArray) -> void:
"""Called by the networking layer when chunk data arrives from server."""
var coord: Vector3i = _parse_coord_from_key(key)
if coord == null:
return
_loading_in_progress.erase(coord)
# Decode the binary chunk data
var chunk_data: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(binary_data)
if chunk_data == null:
chunk_load_failed.emit(coord, "Failed to decode chunk data")
return
# Instantiate the chunk scene with the decoded data
_instantiate_chunk_scene_with_data(coord, chunk_data)
func _on_chunk_load_error(coord: Vector3i, error_msg: String) -> void:
"""Called when the server reports a chunk load error."""
_loading_in_progress.erase(coord)
chunk_load_failed.emit(coord, error_msg)
func _instantiate_chunk_scene(coord: Vector3i) -> void:
"""Create an empty chunk scene for the given coordinate."""
var chunk_scene: ChunkScene = ChunkScene.new()
chunk_scene.coord = coord
chunk_scene.key = Chunk.build_key("", "", coord)
chunk_scene.bounds = Chunk.compute_bounds(coord)
_add_loaded_chunk(coord, chunk_scene)
func _instantiate_chunk_scene_with_data(coord: Vector3i, data: ChunkDataDecoder.ChunkPayload) -> void:
"""Create a chunk scene populated with server data."""
var chunk_scene: ChunkScene = ChunkScene.new()
chunk_scene.coord = coord
chunk_scene.key = data.key
chunk_scene.bounds = data.bounds
chunk_scene.format_version = data.format_version
chunk_scene.revision = data.revision
# Add tiles
for layer in data.tile_layers:
var tile_layer: TileLayer = TileLayer.new()
tile_layer.name = layer.name
tile_layer.layer_id = layer.layer_id
tile_layer.width = layer.width
tile_layer.height = layer.height
tile_layer.depth = layer.depth
tile_layer.palette = layer.palette
tile_layer.tiles = layer.tiles
chunk_scene.add_tile_layer(tile_layer)
# Add objects
for obj_data in data.objects:
var world_obj: WorldObject = WorldObject.new()
world_obj.id = obj_data.id
world_obj.type = obj_data.type
world_obj.asset_id = obj_data.asset_id
world_obj.position = obj_data.position
world_obj.rotation = obj_data.rotation
world_obj.scale = obj_data.scale
world_obj.properties = obj_data.properties
world_obj.is_dynamic = obj_data.dynamic
world_obj.flags = obj_data.flags
chunk_scene.add_object(world_obj)
# Add content flags
chunk_scene.content_flags = data.flags
_add_loaded_chunk(coord, chunk_scene)
func _add_loaded_chunk(coord: Vector3i, chunk_scene: Node) -> void:
_loaded_chunks[coord] = chunk_scene
add_child(chunk_scene)
_loading_in_progress.erase(coord)
chunk_loaded.emit(coord, chunk_scene)
func _unload_chunk(coord: Vector3i) -> void:
if not _loaded_chunks.has(coord):
return
var chunk_scene: Node = _loaded_chunks[coord]
if is_instance_valid(chunk_scene):
chunk_scene.queue_free()
_loaded_chunks.erase(coord)
chunk_unloaded.emit(coord)
func _on_player_position_changed() -> void:
"""Call this when the player position changes significantly."""
if player != null:
_update_streaming()
func set_server_rpc(rpc_interface: Object) -> void:
"""Set the server RPC interface for chunk requests."""
_server_rpc = rpc_interface
func get_loaded_chunk(coord: Vector3i) -> Node:
"""Get a loaded chunk scene by coordinate, or null if not loaded."""
return _loaded_chunks.get(coord, null)
func is_chunk_loaded(coord: Vector3i) -> bool:
"""Check if a chunk is currently loaded."""
return _loaded_chunks.has(coord)
func get_loaded_chunk_count() -> int:
"""Get the number of currently loaded chunks."""
return _loaded_chunks.size()
func get_all_loaded_coords() -> Array[Vector3i]:
"""Get all currently loaded chunk coordinates."""
var coords: Array[Vector3i] = []
for key in _loaded_chunks:
coords.append(key)
return coords
func force_reload_chunk(coord: Vector3i) -> void:
"""Force reload a specific chunk (e.g., after server update)."""
if _loaded_chunks.has(coord):
_unload_chunk(coord)
if not _loading_in_progress.has(coord):
_load_chunk(coord)
func clear_all_chunks() -> void:
"""Unload all chunks immediately."""
var coords: Array[Vector3i] = get_all_loaded_coords()
for coord in coords:
_unload_chunk(coord)
_load_queue.clear()
_unload_queue.clear()
func _parse_coord_from_key(key: String) -> Variant:
"""Parse chunk coordinate from a canonical chunk key."""
var parts: Array[String] = key.split(":")
if parts.size() < 5:
return null
return Vector3i(
int(parts[2]),
int(parts[3]),
int(parts[4])
)
-1
View File
@@ -1 +0,0 @@
uid://8yokqwe23c23
-68
View File
@@ -1,68 +0,0 @@
class_name DynamicObjectNode
extends Node3D
## A visual representation of a dynamic (spawnable) world object template.
##
## This node is instantiated by [ChunkScene] for each dynamic [WorldObject].
## Dynamic objects are stored as *templates* in the chunk; their runtime state
## (position, health, inventory) is managed by the entity system, not by this
## node. This node renders the template's visual representation so the object
## is visible in the world.
##
## ## Structure
##
## ```
## DynamicObjectNode (Node3D)
## └── MeshInstance3D # the object's visual mesh
## ```
## The world object data this node represents.
var world_object: WorldObject = null
## The loaded mesh instance (or placeholder).
var _mesh_instance: MeshInstance3D = null
func _ready() -> void:
if world_object == null:
return
_load_mesh()
## Load and attach the object's mesh.
func _load_mesh() -> void:
_mesh_instance = MeshInstance3D.new()
_mesh_instance.name = "mesh"
var mesh := _load_asset_mesh(world_object.asset_id)
if mesh == null:
mesh = _make_placeholder_mesh()
_mesh_instance.mesh = mesh
add_child(_mesh_instance)
## Attempt to load a mesh from the asset path.
func _load_asset_mesh(asset_id: String) -> Mesh:
if asset_id.is_empty():
return null
var path := "res://assets/%s" % asset_id
if ResourceLoader.exists(path):
var res := load(path)
if res is Mesh:
return res
return null
## Create a simple placeholder sphere mesh when the real asset is unavailable.
func _make_placeholder_mesh() -> Mesh:
var mesh := SphereMesh.new()
mesh.radius = 0.5
mesh.height = 1.0
return mesh
## Get the world object's display name.
func get_display_name() -> String:
if world_object == null:
return ""
return world_object.get_display_name()
-1
View File
@@ -1 +0,0 @@
uid://dyjqcstdnr4u1
-14
View File
@@ -1,14 +0,0 @@
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()
-1
View File
@@ -1 +0,0 @@
uid://bgnc3iamfgf6k
-46
View File
@@ -1,46 +0,0 @@
extends SceneTree
## Headless smoke test for the ApiClient autoload.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_api.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
##
## Calls the real autoload's `health_check()` (which exercises the generic
## `request()` primitive) and exits 0 on a `status == "ok"` response, 1
## otherwise. This script is intentionally NOT wired into any game scene.
func _initialize() -> void:
_run()
func _run() -> void:
# _initialize() fires before the SceneTree has processed its first frame,
# so a freshly add_child()'d HTTPRequest node isn't reliably considered
# "inside tree" yet (request_raw fails with !is_inside_tree()). One frame
# of delay is enough; this only matters for --script mode, not a real
# running game.
await process_frame
# Godot's --script/SceneTree runner doesn't resolve autoload singletons
# as bare global identifiers at compile time (the node exists at
# runtime -- root.has_node("ApiClient") is true -- but referencing it as
# a plain identifier fails with "Identifier not found: ApiClient" during
# script compilation). get_node sidesteps compile-time resolution
# entirely. This only affects --script mode; a real game scene resolves
# the bare autoload name normally.
var api_client := root.get_node("ApiClient")
print("SMOKE: calling ApiClient.health_check() ...")
var result: Dictionary = await api_client.health_check()
print("SMOKE: result = ", result)
if result.ok \
and result.data is Dictionary \
and str((result.data as Dictionary).get("status", "")) == "ok":
print("SMOKE RESULT: PASS (status=ok)")
quit(0)
else:
printerr("SMOKE RESULT: FAIL")
quit(1)
-67
View File
@@ -1,67 +0,0 @@
class_name StaticObjectNode
extends Node3D
## A visual representation of a static world object within a chunk.
##
## This node is instantiated by [ChunkScene] for each static (non-dynamic)
## [WorldObject]. It renders the object's mesh (referenced by `asset_id`) at
## the object's transform. If the asset is not yet loaded, a placeholder mesh
## is shown.
##
## ## Structure
##
## ```
## StaticObjectNode (Node3D)
## └── MeshInstance3D # the object's visual mesh
## ```
## The world object data this node represents.
var world_object: WorldObject = null
## The loaded mesh instance (or placeholder).
var _mesh_instance: MeshInstance3D = null
func _ready() -> void:
if world_object == null:
return
_load_mesh()
## Load and attach the object's mesh.
func _load_mesh() -> void:
_mesh_instance = MeshInstance3D.new()
_mesh_instance.name = "mesh"
# Try to load the asset by its id (path). Fall back to a placeholder.
var mesh := _load_asset_mesh(world_object.asset_id)
if mesh == null:
mesh = _make_placeholder_mesh()
_mesh_instance.mesh = mesh
add_child(_mesh_instance)
## Attempt to load a mesh from the asset path.
func _load_asset_mesh(asset_id: String) -> Mesh:
if asset_id.is_empty():
return null
var path := "res://assets/%s" % asset_id
if ResourceLoader.exists(path):
var res := load(path)
if res is Mesh:
return res
return null
## Create a simple placeholder box mesh when the real asset is unavailable.
func _make_placeholder_mesh() -> Mesh:
var mesh := BoxMesh.new()
mesh.size = Vector3(1.0, 1.0, 1.0)
return mesh
## Get the world object's display name.
func get_display_name() -> String:
if world_object == null:
return ""
return world_object.get_display_name()
-1
View File
@@ -1 +0,0 @@
uid://chf6mruev1kc4
-42
View File
@@ -1,42 +0,0 @@
class_name TileDef
extends RefCounted
## A single tile definition (palette entry) within a tile layer.
##
## Maps a tile ID to its asset reference and gameplay flags. See
## docs/architecture/world.md §3.2.
## Tile ID (index into the layer palette). 0 is reserved for empty/air.
var id: int = 0
## Reference to the tile/terrain asset.
var asset_id: String = ""
## Whether this tile has collision.
var solid: bool = false
## Bitmask flags: bit 0 = walkable, bit 1 = climbable, bit 2 = liquid.
var flags: int = 0
## Number of visual variants for this tile.
var variants: int = 1
func _init(p_id: int = 0, p_asset_id: String = "", p_solid: bool = false,
p_flags: int = 0, p_variants: int = 1) -> void:
id = p_id
asset_id = p_asset_id
solid = p_solid
flags = p_flags
variants = p_variants
func is_walkable() -> bool:
return (flags & 0x01) != 0
func is_climbable() -> bool:
return (flags & 0x02) != 0
func is_liquid() -> bool:
return (flags & 0x04) != 0
-1
View File
@@ -1 +0,0 @@
uid://bavmpbmthilbx
-83
View File
@@ -1,83 +0,0 @@
class_name TileLayer
extends RefCounted
## A single tile layer within a chunk.
##
## Holds the dense grid of tile IDs, the palette mapping tile IDs to tile
## definitions, and the layer metadata. This is the runtime representation of
## the `TileLayer` from the chunk data format (see docs/architecture/world.md
## §3.2).
## Layer name (e.g. "ground", "walls", "decor").
var name: String = ""
## Numeric layer id, unique within the chunk.
var layer_id: int = 0
## Grid dimensions in tiles.
var width: int = 0
var height: int = 0
var depth: int = 1
## Palette mapping tile IDs to tile definitions (shared per layer).
var palette: Array[TileDef] = []
## Dense, row-major tile IDs. Length = width * height * depth.
var tiles: PackedInt32Array = PackedInt32Array()
func _init(
p_layer_name: String = "",
p_layer_id: int = 0,
p_width: int = 0,
p_height: int = 0,
p_depth: int = 1
) -> void:
name = p_layer_name
layer_id = p_layer_id
width = p_width
height = p_height
depth = p_depth
## Total number of tiles in this layer.
func tile_count() -> int:
return width * height * depth
## Get the tile ID at (x, y, z). Returns 0 (empty) for out-of-bounds.
func get_tile(x: int, y: int, z: int = 0) -> int:
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
return 0
var idx := (z * height + y) * width + x
if idx < tiles.size():
return tiles[idx]
return 0
## Set the tile ID at (x, y, z). No-op if out of bounds.
func set_tile(x: int, y: int, z: int, tile_id: int) -> void:
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
return
var idx := (z * height + y) * width + x
if idx < tiles.size():
tiles[idx] = tile_id
## Get the tile definition for a tile ID, or null if not in the palette.
func get_tile_def(tile_id: int) -> TileDef:
for def in palette:
if def.id == tile_id:
return def
return null
## Whether the tile at (x, y, z) is solid (collision).
func is_solid(x: int, y: int, z: int = 0) -> bool:
var def := get_tile_def(get_tile(x, y, z))
return def != null and def.solid
## Whether the tile at (x, y, z) is walkable.
func is_walkable(x: int, y: int, z: int = 0) -> bool:
var def := get_tile_def(get_tile(x, y, z))
return def != null and def.is_walkable()
-1
View File
@@ -1 +0,0 @@
uid://came2ygke3ajs
-112
View File
@@ -1,112 +0,0 @@
class_name TileLayerNode
extends Node3D
## A visual representation of a single tile layer within a chunk.
##
## This node is instantiated by [ChunkScene] for each tile layer in a chunk.
## It renders the layer's tiles as instanced meshes (one per tile definition
## in the palette) using [MultiMeshInstance3D] for efficiency.
##
## ## Structure
##
## ```
## TileLayerNode (Node3D)
## └── MultiMeshInstance3D # one per palette entry (tile definition)
## ```
## The tile layer data this node renders.
var tile_layer: TileLayer = null
## The chunk coordinate this layer belongs to (for world positioning).
var chunk_coord: Vector3i = Vector3i.ZERO
## Tile size in world units (each tile is 1x1x1).
const TILE_SIZE: float = 1.0
## Cache of MultiMeshInstance3D nodes, keyed by tile definition id.
var _mesh_instances: Dictionary = {}
func _ready() -> void:
if tile_layer == null:
return
_build_meshes()
## Build the MultiMesh instances for each tile definition in the palette.
func _build_meshes() -> void:
for tile_def in tile_layer.palette:
if tile_def.id == 0:
continue # Tile 0 is "empty/air", no mesh needed
_create_mesh_for_tile(tile_def)
func _create_mesh_for_tile(tile_def: TileDef) -> void:
# Count how many cells use this tile definition
var count := 0
for tile_id in tile_layer.tiles:
if tile_id == tile_def.id:
count += 1
if count == 0:
return
var multi_mesh := MultiMesh.new()
multi_mesh.transform_format = MultiMesh.TRANSFORM_3D
multi_mesh.mesh = _make_unit_cube_mesh()
multi_mesh.instance_count = count
var instance_idx := 0
for z in range(tile_layer.depth):
for y in range(tile_layer.height):
for x in range(tile_layer.width):
var tile_id := tile_layer.get_tile(x, y, z)
if tile_id != tile_def.id:
continue
var world_pos := _tile_to_world(x, y, z)
var xform := Transform3D(Basis(), world_pos)
multi_mesh.set_instance_transform(instance_idx, xform)
instance_idx += 1
var mesh_instance := MultiMeshInstance3D.new()
mesh_instance.multimesh = multi_mesh
mesh_instance.name = "tiles_%d" % tile_def.id
add_child(mesh_instance)
_mesh_instances[tile_def.id] = mesh_instance
## Convert a tile grid coordinate to a world-space position (center of tile).
func _tile_to_world(x: int, y: int, z: int) -> Vector3:
var origin := Vector3(
chunk_coord.x * tile_layer.width,
chunk_coord.y * tile_layer.height,
chunk_coord.z * tile_layer.depth
)
return origin + Vector3(
x * TILE_SIZE + TILE_SIZE * 0.5,
y * TILE_SIZE + TILE_SIZE * 0.5,
z * TILE_SIZE + TILE_SIZE * 0.5
)
## Create a simple unit cube mesh for tile rendering.
func _make_unit_cube_mesh() -> Mesh:
var mesh := BoxMesh.new()
mesh.size = Vector3(TILE_SIZE, TILE_SIZE, TILE_SIZE)
return mesh
## Get the tile definition at a grid position, or null if empty.
func get_tile_def_at(x: int, y: int, z: int = 0) -> TileDef:
if tile_layer == null:
return null
var tile_id := tile_layer.get_tile(x, y, z)
if tile_id == 0:
return null
return tile_layer.get_tile_def(tile_id)
## Clear all rendered meshes.
func clear() -> void:
for child in get_children():
child.queue_free()
_mesh_instances.clear()
-1
View File
@@ -1 +0,0 @@
uid://d1x05tq8h43gm
-135
View File
@@ -1,135 +0,0 @@
class_name WorldObject
extends RefCounted
## A world object (static or dynamic) within a chunk.
##
## This is the runtime representation of a WorldObject from the chunk data
## format (see docs/architecture/world.md §3.3). It holds the object's
## identity, transform, properties, and moderation flags.
## Stable object ID, unique within the chunk.
var id: String = ""
## Object type (e.g. "prop", "interactable", "spawn_point", "door").
var type: String = ""
## Reference to the object's mesh/glTF asset.
var asset_id: String = ""
## World-space position.
var position: Vector3 = Vector3.ZERO
## World-space rotation as a quaternion (x, y, z, w).
var rotation: Vector4 = Vector4(0, 0, 0, 1)
## World-space scale.
var scale: Vector3 = Vector3.ONE
## Type-specific key/value properties.
var properties: Dictionary = {}
## Whether this is a spawnable dynamic entity (true) or static (false).
var is_dynamic: bool = false
## Per-object content moderation flags.
var flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
## Whether this object has been modified locally.
var is_dirty: bool = false
func _init(
p_id: String = "",
p_type: String = "",
p_asset_id: String = "",
p_position: Vector3 = Vector3.ZERO,
p_rotation: Vector4 = Vector4(0, 0, 0, 1),
p_scale: Vector3 = Vector3.ONE,
p_properties: Dictionary = {},
p_dynamic: bool = false,
p_flags: ChunkDataDecoder.ContentFlags = null
) -> void:
id = p_id
type = p_type
asset_id = p_asset_id
position = p_position
rotation = p_rotation
scale = p_scale
properties = p_properties
is_dynamic = p_dynamic
if p_flags != null:
flags = p_flags
## Get a property value by key.
func get_property(key: String) -> Variant:
return properties.get(key)
## Set a property value.
func set_property(key: String, value: Variant) -> void:
properties[key] = value
is_dirty = true
## Check if this object is walkable (based on properties).
func is_walkable() -> bool:
return properties.get("walkable", true)
## Check if this object is climbable (based on properties).
func is_climbable() -> bool:
return properties.get("climbable", false)
## Check if this object is interactable.
func is_interactable() -> bool:
return type == "interactable" or properties.get("interactable", false)
## Get the object's display name.
func get_display_name() -> String:
return properties.get("display_name", id)
## Serialize this object to a dictionary (for networking).
func to_dict() -> Dictionary:
return {
"id": id,
"type": type,
"asset_id": asset_id,
"position": [position.x, position.y, position.z],
"rotation": [rotation.x, rotation.y, rotation.z, rotation.w],
"scale": [scale.x, scale.y, scale.z],
"properties": properties,
"dynamic": is_dynamic,
}
## Create a WorldObject from a dictionary (for networking).
static func from_dict(d: Dictionary) -> WorldObject:
var pos := Vector3(
d.get("position", [0, 0, 0])[0],
d.get("position", [0, 0, 0])[1],
d.get("position", [0, 0, 0])[2]
)
var rot := Vector4(
d.get("rotation", [0, 0, 0, 1])[0],
d.get("rotation", [0, 0, 0, 1])[1],
d.get("rotation", [0, 0, 0, 1])[2],
d.get("rotation", [0, 0, 0, 1])[3]
)
var scl := Vector3(
d.get("scale", [1, 1, 1])[0],
d.get("scale", [1, 1, 1])[1],
d.get("scale", [1, 1, 1])[2]
)
return WorldObject.new(
d.get("id", ""),
d.get("type", ""),
d.get("asset_id", ""),
pos,
rot,
scl,
d.get("properties", {}),
d.get("dynamic", false),
)
-1
View File
@@ -1 +0,0 @@
uid://cxut2ghbsvobn