Compare commits

..

15 Commits

Author SHA1 Message Date
coder-bot 72a994c5d1 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:20:09 +00:00
coder-bot 276e63753f Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
coder-bot 88095b82d9 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
coder-bot d11eb7d732 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
coder-bot 7fd43cc158 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
architect-bot ae37db6fc1 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot a7a596aa47 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
agent-bot aa602e78b7 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
agent-bot 38c4967169 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 9909d3a612 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 4cb64e046a WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 84b2c656f6 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot fdbaf2d8c1 chore(backend): add env config template
Scaffolding only — FastAPI app, SQLAlchemy models, Alembic migrations,
and docker-compose from the original task description are still not
implemented. Committing what exists so it isn't lost; task ec0074121ca3
reset to pending to finish the rest.
2026-08-17 19:14:25 +00:00
reviewer-bot a632f2d8a6 Merge pull request 'Address review feedback: Design world chunk data format and persistence schema' (#11) from agent/c92c939b819c into main 2026-08-17 19:02:34 +00:00
architect-bot 0b973b73ab WIP checkpoint: Address review feedback: Design world chunk data format and persistence schema 2026-08-17 18:29:14 +00:00
36 changed files with 1764 additions and 504 deletions
-1
View File
@@ -1 +0,0 @@
-14
View File
@@ -1,14 +0,0 @@
63f7b34db8d8cdea90c76aacccf841ec
::res://::1787259973
README.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>::
::res://docs/::1787259973
::res://docs/architecture/::1787259973
avatar.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>::
content_moderation.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>::
networking.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>::
::res://scenes/::1787259973
player.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
server.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::
::res://scripts/::1787259973
player_spawner.gd::GDScript::3637338339553764865::1787259973::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
server.gd::GDScript::6336910781803684784::1787259973::0::1::::<>Node<><>0<>0<><>::
-11
View File
@@ -1,11 +0,0 @@
[game_view]
select_mode=0
[editor_metadata]
executable_path="/home/daddy/godot/godot"
[recent_files]
scenes=["res://scenes/server.tscn"]
@@ -1,5 +0,0 @@
[folding]
node_unfolds=[]
resource_unfolds=[]
nodes_folded=[]
-9
View File
@@ -1,9 +0,0 @@
list=[{
"base": &"CharacterBody3D",
"class": &"PlayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player_spawner.gd"
}]
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
# Database
DATABASE_URL=postgresql://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
DEBUG=true
CORS_ORIGINS=http://localhost:8080,http://localhost:3000
+4
View File
@@ -0,0 +1,4 @@
.env
__pycache__/
*.pyc
.venv/
View File
+42
View File
@@ -0,0 +1,42 @@
# Alembic configuration file.
# Run: alembic upgrade head (to apply migrations)
# Run: alembic revision --autogenerate -m "description" (to generate migration)
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+65
View File
@@ -0,0 +1,65 @@
"""Alembic environment configuration."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
"""Run migrations in 'online' mode with async engine."""
connectable = create_async_engine(settings.DATABASE_URL, echo=False)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade database schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade database schema."""
${downgrades if downgrades else "pass"}
View File
+48
View File
@@ -0,0 +1,48 @@
"""Application configuration via pydantic-settings."""
from pydantic_settings import BaseSettings
from functools import lru_cache
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
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
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
"""Return cached settings instance."""
return Settings()
# Convenience singleton
settings = get_settings()
+38
View File
@@ -0,0 +1,38 @@
"""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."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+36
View File
@@ -0,0 +1,36 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
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,
)
# Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
@app.get("/api/health")
async def health_check():
"""Simple health check endpoint."""
return {"status": "ok", "app": settings.app_name}
+6
View File
@@ -0,0 +1,6 @@
from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
__all__ = ["User", "Session", "World", "Asset"]
+36
View File
@@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Enum as SAEnum
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(SAEnum(AssetType), 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(Integer, default=0, 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")
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Column, 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, primary_key=True)
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token = Column(String, unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions")
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(120), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
+27
View File
@@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, 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), 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
+181
View File
@@ -0,0 +1,181 @@
"""Asset management router."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.asset import Asset, AssetType
from app.models.user import User
from app.models.world import World
from app.routers.worlds import get_current_user
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
class Config:
from_attributes = True
class AssetUpdate(BaseModel):
"""Schema for updating an asset."""
name: Optional[str] = None
description: Optional[str] = None
is_public: Optional[bool] = None
# --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201)
async def create_asset(
payload: AssetCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""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(
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),
current_user: User = Depends(get_current_user),
):
"""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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete an asset."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
await db.delete(asset)
await db.commit()
@router.post("/{asset_id}/upload")
async def upload_asset_file(
asset_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""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")
# TODO: Implement actual file upload logic (S3, local filesystem, etc.)
file_path = f"uploads/{asset_id}_{file.filename}"
asset.file_path = file_path
asset.mime_type = file.content_type
asset.file_size = 0 # Will be updated after actual write
await db.commit()
await db.refresh(asset)
return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path}
+133
View File
@@ -0,0 +1,133 @@
"""Authentication router - login, register, token refresh."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
from app.database import get_db
from app.models.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
# --- Pydantic schemas ---
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
class LoginRequest(BaseModel):
username: str
password: str
# --- Helpers ---
def _create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token."""
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already registered",
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == req.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
user = User(
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token."""
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not verify_password(req.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")
async def get_current_user(
user_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get current user 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")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
"created_at": user.created_at,
}
+130
View File
@@ -0,0 +1,130 @@
"""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.models.user import User
router = APIRouter()
# ── Pydantic schemas ──────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
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.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_user(
user_in: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Register a new user."""
# Check uniqueness of username
existing = await db.execute(select(User).where(User.username == user_in.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
# Check uniqueness of email
existing = await db.execute(select(User).where(User.email == user_in.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
from app.routers.auth import hash_password
hashed = hash_password(user_in.password)
user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hashed,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.get("/me", response_model=UserResponse)
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
# current_user would come from JWT dependency in real usage
):
"""Get the currently authenticated user (placeholder)."""
raise HTTPException(status_code=501, detail="Not implemented")
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a user by ID."""
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")
return user
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_update: UserUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a user."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_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,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Delete a user."""
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")
await db.delete(user)
await db.commit()
+151
View File
@@ -0,0 +1,151 @@
"""Worlds router - CRUD for game worlds."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.world import World, WorldVisibility
router = APIRouter()
async def get_current_user():
"""Placeholder dependency for current user auth."""
# TODO: Implement JWT auth dependency
return {"id": 1, "username": "testuser", "email": "test@example.com"}
@router.get("/")
async def list_worlds(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
visibility: WorldVisibility | None = None,
db: Annotated[AsyncSession, Depends(get_db)] = Depends(),
):
"""List worlds with optional visibility filter."""
query = select(World).options(selectinload(World.owner))
if visibility:
query = query.where(World.visibility == visibility)
query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
result = await db.execute(query)
worlds = result.scalars().all()
return [
{
"id": w.id,
"name": w.name,
"description": w.description,
"visibility": w.visibility.value if w.visibility else None,
"owner_id": w.owner_id,
"created_at": w.created_at.isoformat() if w.created_at else None,
}
for w in worlds
]
@router.get("/{world_id}")
async def get_world(
world_id: int,
db: Annotated[AsyncSession, Depends(get_db)] = Depends(),
):
"""Get a single world by ID."""
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")
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.post("/")
async def create_world(
world_data: dict,
current_user=Depends(get_current_user),
db: Annotated[AsyncSession, Depends(get_db)] = Depends(),
):
"""Create a new world."""
name = world_data.get("name")
if not name:
raise HTTPException(status_code=400, detail="World name is required")
world = World(
name=name,
description=world_data.get("description"),
owner_id=current_user["id"],
visibility=WorldVisibility(
world_data.get("visibility", "public")
),
)
db.add(world)
await db.commit()
await db.refresh(world)
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.put("/{world_id}")
async def update_world(
world_id: int,
world_data: dict,
current_user=Depends(get_current_user),
db: Annotated[AsyncSession, Depends(get_db)] = Depends(),
):
"""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_data.get("name"):
world.name = world_data["name"]
if world_data.get("description") is not None:
world.description = world_data["description"]
if world_data.get("visibility"):
world.visibility = WorldVisibility(world_data["visibility"])
await db.commit()
await db.refresh(world)
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.delete("/{world_id}")
async def delete_world(
world_id: int,
current_user=Depends(get_current_user),
db: Annotated[AsyncSession, Depends(get_db)] = Depends(),
):
"""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")
await db.delete(world)
await db.commit()
return {"detail": "World deleted"}
+34
View File
@@ -0,0 +1,34 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: agp_user
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agp_user -d agp_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg2-binary==2.9.10
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
+737
View File
@@ -0,0 +1,737 @@
# World Chunk Data Format & Persistence Architecture
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
## 1. Overview
This document specifies the **world chunk data format** and **persistence
schema** for **agp-game**, a Godot-based multiplayer game built by autonomous
agents. It defines:
- The **chunk data format** — both a human-readable **JSON** form (for
authoring, tooling, and debugging) and a compact **binary** form (for
runtime loading and network transfer).
- The **SQLite schema** that persists world state on the server, including
chunk metadata, chunk payloads, and the world/room hierarchy.
- The **read/write API** that the server and client use to load, save, and
stream chunks.
The goal is a **single, versioned chunk contract** that is shared across the
Godot client, the Python backend, and the persistence layer. A chunk is the
atomic unit of world geometry, gameplay objects, and persistence — the server
loads and saves whole chunks, and clients stream the chunks around them.
### 1.1 Design Goals
- **Versioned & forward-compatible:** Every chunk carries a format version so
the loader can migrate old data and reject unknown future data gracefully.
- **Dual representation:** JSON for authoring/debugging, binary for
runtime/network. Both encode the *same* logical model so there is exactly one
source of truth for the schema.
- **Server-authoritative persistence:** The server is the only writer to the
SQLite store. Clients submit *edits* (deltas) and the server applies and
persists them, mirroring the server-authoritative networking model.
- **Chunk-granular persistence:** Persistence happens at chunk granularity so
the server can save a dirty chunk without rewriting the whole world, and can
stream only the chunks a client needs.
- **Deterministic & testable:** The binary format is byte-stable for a given
logical payload, enabling checksums, caching, and reproducible tests.
- **Moderation-aware:** Chunks reference content (assets, scripts) that carry
content flags (see `content_moderation.md`); the persistence layer stores the
moderation state alongside chunk data so enforcement can happen at load time.
---
## 2. World Model & Chunking
### 2.1 World Hierarchy
The world is organized as a hierarchy that matches the moderation content graph:
```
World
└── Room
└── Chunk (the atomic persistence & streaming unit)
├── Tiles / Terrain
├── Static objects (props, geometry)
├── Dynamic objects (spawnable entities, interactables)
└── Metadata (flags, version, timestamps)
```
- A **World** is the top-level container (a named, versioned space).
- A **Room** is a bounded region within a world (a level, a building, a zone).
- A **Chunk** is a fixed-size, axis-aligned region of a room. It is the unit of
load, save, and stream.
### 2.2 Chunk Dimensions
Chunks are **fixed-size, axis-aligned boxes** in world space. The default
dimensions are a configuration constant so they can be tuned per world:
| Constant | Default | Description |
|---------------------|---------|----------------------------------------------------|
| `CHUNK_SIZE_X` | 32 | Chunk width in world units (tiles). |
| `CHUNK_SIZE_Y` | 32 | Chunk height in world units (tiles). |
| `CHUNK_SIZE_Z` | 1 | Chunk depth (1 = flat 2D world; >1 = volumetric). |
| `CHUNK_ORIGIN` | `(0,0)` | World-space origin of chunk coordinate `(0,0)`. |
A chunk is identified by its **integer chunk coordinate** `(cx, cy, cz)`,
computed from a world-space position `p` as:
```
cx = floor((p.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)
cy = floor((p.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)
cz = floor((p.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z)
```
The chunk's world-space bounding box is therefore:
```
min = (CHUNK_ORIGIN.x + cx*CHUNK_SIZE_X, ...)
max = min + (CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z)
```
### 2.3 Chunk Coordinate Key
For storage and lookup, a chunk is addressed by a **canonical string key**:
```
"{world_id}:{room_id}:{cx}:{cy}:{cz}"
```
Example: `"w_0001:r_0001:12:-4:0"`. This key is the primary lookup key in the
SQLite `chunks` table and is used in the read/write API.
---
## 3. Chunk Data Format
### 3.1 Logical Model
Both the JSON and binary encodings serialize the same logical chunk model. The
logical model is defined here once; the two encodings are projections of it.
```
Chunk
├── version : uint (format version, currently 1)
├── key : string (canonical chunk key, see §2.3)
├── world_id : string
├── room_id : string
├── coord : { cx, cy, cz } (int)
├── bounds : { min_x, min_y, min_z, max_x, max_y, max_z } (float)
├── tiles : TileLayer[] (terrain / tile data)
├── objects : WorldObject[] (static + dynamic objects)
├── flags : ContentFlags (moderation flags, see §3.5)
├── meta : { created_at, updated_at, revision, author }
└── checksum : string (SHA-256 of canonical payload)
```
### 3.2 Tile Layer
A chunk's terrain is stored as one or more **tile layers**. Each layer is a
dense grid of tile IDs of size `CHUNK_SIZE_X × CHUNK_SIZE_Y` (× `CHUNK_SIZE_Z`
for volumetric worlds).
```
TileLayer
├── name : string (e.g. "ground", "walls", "decor")
├── layer_id : uint
├── width : uint (= CHUNK_SIZE_X)
├── height : uint (= CHUNK_SIZE_Y)
├── depth : uint (= CHUNK_SIZE_Z)
├── tiles : uint[] (flattened tile IDs, row-major, length = w*h*d)
└── palette : TileDef[] (tile ID → tile definition, shared per layer)
```
**Tile encoding:** A tile ID is a `uint16` index into the layer's `palette`.
`0` is reserved for "empty/air". The palette maps each tile ID to a `TileDef`:
```
TileDef
├── id : uint16
├── asset_id : string (reference to a tile/terrain asset)
├── solid : bool (collision)
├── flags : uint32 (bitmask: walkable, climbable, liquid, ...)
└── variants : uint8 (number of visual variants for this tile)
```
**Rationale for palette + indices:** Storing dense `uint16` indices rather than
full asset strings per cell keeps the binary payload small (2 bytes/cell) while
the palette (shared across the layer) carries the rich per-tile metadata.
### 3.3 World Objects
Objects are the non-tile entities in a chunk: static props, interactables,
spawn points, and dynamic-object templates.
```
WorldObject
├── id : string (stable object id, unique within the chunk)
├── type : string (e.g. "prop", "interactable", "spawn_point", "door")
├── asset_id : string (reference to the object's mesh/glTF asset)
├── transform : { pos: [x,y,z], rot: [x,y,z,w], scale: [x,y,z] }
├── properties : object (type-specific key/value map, JSON-typed)
├── dynamic : bool (true if this is a spawnable dynamic entity)
└── flags : ContentFlags (per-object moderation flags)
```
**Dynamic objects** are stored as *templates* in the chunk. Their *runtime
state* (position, health, inventory) is **not** stored in the chunk; it lives
in the runtime entity store and is persisted separately (see §4.4). This keeps
chunks stable and diffable — a chunk only changes when its *static* content
changes.
### 3.4 Content Flags
Each chunk (and each object) carries a **content flag set** that is the
aggregate of the moderation flags of the assets and scripts it references. This
mirrors the aggregation rules in `content_moderation.md`:
```
ContentFlags
├── categories : { violence, sexual, language, substance, gambling, horror, hate, custom }
│ (each → severity: none|mild|moderate|severe)
├── mature : bool (true if any contributing item is mature)
└── sources : string[] (flag ids that contributed, for audit)
```
The server computes and persists these flags at write time so that enforcement
(age gating, content filtering) can happen at load time without re-scanning
assets.
### 3.5 JSON Encoding
The JSON encoding is the **authoring and debugging** representation. It is
human-readable, diffable, and used by tools and tests. It is the canonical
logical model serialized directly.
```json
{
"version": 1,
"key": "w_0001:r_0001:12:-4:0",
"world_id": "w_0001",
"room_id": "r_0001",
"coord": { "cx": 12, "cy": -4, "cz": 0 },
"bounds": { "min_x": 384.0, "min_y": -128.0, "min_z": 0.0,
"max_x": 416.0, "max_y": -96.0, "max_z": 1.0 },
"tiles": [
{
"name": "ground",
"layer_id": 0,
"width": 32,
"height": 32,
"depth": 1,
"palette": [
{ "id": 0, "asset_id": "", "solid": false, "flags": 0, "variants": 1 },
{ "id": 1, "asset_id": "tiles/grass", "solid": false, "flags": 1, "variants": 4 }
],
"tiles": [0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1]
}
],
"objects": [
{
"id": "obj_0001",
"type": "interactable",
"asset_id": "props/chest",
"transform": { "pos": [400.0, -110.0, 0.0],
"rot": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0] },
"properties": { "loot_table": "chest_basic", "locked": false },
"dynamic": false,
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] }
}
],
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] },
"meta": { "created_at": "2026-08-17T00:00:00Z",
"updated_at": "2026-08-17T00:00:00Z",
"revision": 3,
"author": "agent-bot" },
"checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
```
> **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
and byte-stable. It uses a simple, explicitly-specified layout (no external
serialization library) so it can be produced and consumed identically by the
Godot client (GDScript) and the Python backend.
#### 3.6.1 Header
| Offset | Size | Field | Type / Encoding |
|--------|------|--------------|------------------------------------------|
| 0 | 4 | magic | ASCII `"AGPC"` (0x41 0x47 0x50 0x43) |
| 4 | 2 | version | `uint16` LE (currently `1`) |
| 6 | 2 | header_len | `uint16` LE (bytes of header after this) |
| 8 | 4 | flags | `uint32` LE (bitmask, reserved) |
| 12 | 4 | payload_len | `uint32` LE (bytes of payload body) |
| 16 | 32 | checksum | SHA-256 of payload body (hex, 32 bytes) |
Total header size: **48 bytes**.
#### 3.6.2 Payload Body
The payload body is a sequence of typed sections. Each section has a small
header:
| Offset | Size | Field | Type / Encoding |
|--------|------|------------|----------------------------|
| 0 | 1 | section_id | `uint8` (see table below) |
| 1 | 4 | section_len| `uint32` LE (bytes of section data) |
| 5 | ... | data | section-specific |
Section IDs:
| section_id | Section | Data layout |
|------------|----------------|-------------------------------------------------------------------|
| `0x01` | metadata | world_id, room_id (length-prefixed strings), coord (3×int32), bounds (6×float32) |
| `0x02` | tile layer | name, layer_id, dims (3×uint16), palette, dense tile indices |
| `0x03` | object | one `WorldObject` (repeated; one section per object) |
| `0x04` | content flags | category severities (8×uint8), mature (uint8), sources (string list) |
| `0x05` | meta | created_at, updated_at (int64 unix), revision (uint32), author (string) |
**String encoding:** length-prefixed UTF-8 — `uint16` LE byte length followed by
the UTF-8 bytes.
**Tile layer data layout (section `0x02`):**
```
name : string
layer_id : uint16 LE
width : uint16 LE
height : uint16 LE
depth : uint16 LE
palette_count : uint16 LE
palette : palette_count × TileDef
tile_count : uint32 LE
tiles : tile_count × uint16 LE (dense, row-major)
```
```
TileDef:
id : uint16 LE
asset_id : string
solid : uint8
flags : uint32 LE
variants : uint8
```
**Object data layout (section `0x03`):**
```
id : string
type : string
asset_id : string
pos : 3 × float32
rot : 4 × float32
scale : 3 × float32
dynamic : uint8
properties : JSON string (length-prefixed)
flags : ContentFlags (see 0x04 layout)
```
**Content flags layout (section `0x04`):**
```
violence, sexual, language, substance, gambling, horror, hate, custom
: 8 × uint8 (severity enum: 0=none,1=mild,2=moderate,3=severe)
mature : uint8
source_count: uint16 LE
sources : source_count × string
```
#### 3.6.3 Byte-Stability
For a given logical payload, the binary encoding is **deterministic**:
- All integers are little-endian.
- All strings are UTF-8, length-prefixed, and never null-terminated.
- Maps (e.g. `properties`) are serialized as a **canonical JSON string** with
keys sorted lexicographically.
- The `checksum` is the SHA-256 of the payload body bytes.
This guarantees that two encoders produce identical bytes, enabling content
addressing, caching, and reproducible tests.
---
## 4. SQLite Persistence Schema
The server persists world state in a **SQLite** database. SQLite is chosen for
its zero-configuration, single-file, transactional, and embedded nature — ideal
for a server that owns the world store and needs ACID guarantees without a
separate database process.
### 4.1 Database Layout
The database file is `world.db` (path configurable). It contains the following
tables:
```
worlds
rooms
chunks
chunk_objects
content_flags
revisions
```
### 4.2 `worlds`
Top-level world containers.
```sql
CREATE TABLE worlds (
id TEXT PRIMARY KEY, -- e.g. 'w_0001'
name TEXT NOT NULL,
description TEXT,
chunk_size_x INTEGER NOT NULL DEFAULT 32,
chunk_size_y INTEGER NOT NULL DEFAULT 32,
chunk_size_z INTEGER NOT NULL DEFAULT 1,
origin_x REAL NOT NULL DEFAULT 0,
origin_y REAL NOT NULL DEFAULT 0,
origin_z REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, -- ISO-8601 UTC
updated_at TEXT NOT NULL
);
```
### 4.3 `rooms`
Rooms within a world.
```sql
CREATE TABLE rooms (
id TEXT PRIMARY KEY, -- e.g. 'r_0001'
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
name TEXT NOT NULL,
bounds_min_x REAL NOT NULL,
bounds_min_y REAL NOT NULL,
bounds_min_z REAL NOT NULL DEFAULT 0,
bounds_max_x REAL NOT NULL,
bounds_max_y REAL NOT NULL,
bounds_max_z REAL NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (world_id, name)
);
CREATE INDEX idx_rooms_world ON rooms(world_id);
```
### 4.4 `chunks`
The core persistence table. One row per chunk. The **payload** is stored as a
BLOB in the **binary format** (§3.6); the JSON form is derived on demand for
authoring/debugging and is not stored (to avoid dual-write drift).
```sql
CREATE TABLE chunks (
key TEXT PRIMARY KEY, -- canonical key 'world:room:cx:cy:cz'
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
cx INTEGER NOT NULL,
cy INTEGER NOT NULL,
cz INTEGER NOT NULL DEFAULT 0,
format_version INTEGER NOT NULL DEFAULT 1,
payload BLOB NOT NULL, -- binary chunk encoding (§3.6)
payload_len INTEGER NOT NULL,
checksum TEXT NOT NULL, -- SHA-256 of payload
revision INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0, -- 1 = needs flush to disk
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (world_id, room_id, cx, cy, cz)
);
CREATE INDEX idx_chunks_world_room ON chunks(world_id, room_id);
CREATE INDEX idx_chunks_coord ON chunks(cx, cy, cz);
```
**Design notes:**
- The `payload` BLOB is the single source of truth for chunk content. The
`checksum` guards against corruption and enables content addressing.
- `revision` increments on every write; it is used for optimistic concurrency
and change detection (see §5.4).
- `dirty` marks chunks that have been modified in memory but not yet flushed,
enabling a write-behind persistence strategy (see §5.5).
### 4.5 `chunk_objects`
A denormalized, queryable index of the objects inside each chunk. This is
**derived** from the chunk payload and kept in sync by the write path. It exists
so the server can answer queries like "which chunks contain a door?" without
parsing every payload BLOB.
```sql
CREATE TABLE chunk_objects (
id TEXT NOT NULL, -- object id (unique within chunk)
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
type TEXT NOT NULL, -- 'prop', 'interactable', ...
asset_id TEXT,
dynamic INTEGER NOT NULL DEFAULT 0,
pos_x REAL NOT NULL,
pos_y REAL NOT NULL,
pos_z REAL NOT NULL DEFAULT 0,
PRIMARY KEY (chunk_key, id)
);
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).
This lets the server enforce age/content gates at load time without parsing
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)
violence INTEGER NOT NULL DEFAULT 0,
sexual INTEGER NOT NULL DEFAULT 0,
language INTEGER NOT NULL DEFAULT 0,
substance INTEGER NOT NULL DEFAULT 0,
gambling INTEGER NOT NULL DEFAULT 0,
horror INTEGER NOT NULL DEFAULT 0,
hate INTEGER NOT NULL DEFAULT 0,
custom INTEGER NOT NULL DEFAULT 0,
mature INTEGER NOT NULL DEFAULT 0,
sources TEXT NOT NULL DEFAULT '[]', -- JSON array of flag ids
PRIMARY KEY (chunk_key, object_id)
);
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
replication of changes.
```sql
CREATE TABLE revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chunk_key TEXT NOT NULL, -- NOT a FK: survives chunk deletion
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
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:
```sql
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- seed: INSERT INTO meta(key, value) VALUES ('schema_version', '1');
```
Migrations are applied in order at startup. Each migration is a numbered SQL
script; the server tracks the applied version in `meta` and applies any pending
migrations transactionally. This keeps the schema forward-compatible as the
chunk format evolves.
---
## 5. Read/Write API
The API is exposed as a **Python service layer** on the backend (the same
process that owns the SQLite store) and consumed by the Godot client over the
network transport (see `networking.md`). The service layer is the **only**
writer to the database; clients never touch SQLite directly.
### 5.1 Service Interface (Python)
```python
class WorldStore:
"""Owns the SQLite connection and chunk read/write operations."""
def get_chunk(self, key: str) -> Chunk | None: ...
def get_chunk_binary(self, key: str) -> bytes | None: ...
def put_chunk(self, chunk: Chunk, actor: str) -> Chunk: ...
def delete_chunk(self, key: str, actor: str) -> None: ...
def list_chunks(self, world_id: str, room_id: str | None = None,
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: ...
```
### 5.2 Encoding Helpers
The service layer provides symmetric encode/decode functions shared by the
client and server:
```python
def encode_binary(chunk: Chunk) -> bytes: ...
def decode_binary(data: bytes) -> Chunk: ...
def encode_json(chunk: Chunk) -> str: ...
def decode_json(text: str) -> Chunk: ...
def checksum(payload: bytes) -> str: ...
```
These are the single implementation of the format spec (§3), so the Godot
client and Python backend never drift.
### 5.3 Network RPCs
The chunk API is exposed to the client via RPCs (matching the networking
architecture). All chunk traffic is **reliable** (chunks are discrete,
must-arrive data).
| RPC | Direction | Payload | Notes |
|----------------------------|-----------|-------------------------------------------|-------|
| `request_chunk` | C → S | `{ key }` | Client asks for a chunk |
| `chunk_data` | S → C | `{ key, binary_payload, checksum }` | Server streams a chunk |
| `request_chunk_edit` | C → S | `{ key, base_revision, delta }` | Client submits an edit |
| `chunk_edit_result` | S → C | `{ key, success, new_revision, error? }` | Acknowledges an edit |
| `chunk_invalidated` | S → C | `{ key, new_revision }` | Server pushes a change |
| `request_chunk_flags` | C → S | `{ key }` | Age/content gate check |
### 5.4 Edit & Concurrency Model
- **Read:** Client requests a chunk; server returns the binary payload and its
`revision`.
- **Edit:** Client submits a **delta** against a known `base_revision`. The
server applies the delta, increments `revision`, persists, and returns the new
revision.
- **Optimistic concurrency:** If the chunk's current revision differs from the
client's `base_revision`, the server rejects the edit with a conflict error
and the client re-fetches and re-applies. This prevents lost updates when two
clients edit the same chunk.
- **Deltas:** A delta is a JSON patch (RFC 6902 style) applied to the chunk's
logical model. The server validates the patched chunk (schema, bounds,
moderation) before persisting.
### 5.5 Write-Behind Persistence
To amortize disk I/O, the server keeps a **dirty set** of chunks modified in
memory and flushes them to SQLite in batches:
1. `put_chunk` updates the in-memory chunk and marks it `dirty`.
2. A background flusher periodically calls `flush_dirty()`, which writes all
dirty chunks in a single transaction and clears the `dirty` flag.
3. On graceful shutdown, `flush_dirty()` is called to ensure durability.
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
view radius), using `list_chunks` with a bounds query.
- Chunks are cached in memory (LRU) keyed by `key`; the cache is invalidated on
write.
- The binary payload is content-addressed by `checksum`, so unchanged chunks can
be served from cache without re-reading SQLite.
---
## 6. Format Versioning & Migration
- The chunk format `version` field (currently `1`) is checked on decode. A
decoder that encounters a higher version than it supports **rejects** the
chunk with a clear error rather than misreading it.
- The SQLite `schema_version` in `meta` drives database migrations (§4.8).
- Chunk payloads are migrated **lazily**: when a chunk is read and its
`format_version` is older than the current one, the server migrates it to the
current version in memory and marks it dirty so it is rewritten on the next
flush. This avoids a costly full-world migration at startup.
---
## 7. Security & Integrity
- **Server-authoritative writes:** Only the `WorldStore` writes to SQLite;
clients submit deltas that are validated before application.
- **Checksum verification:** Every chunk payload is checksummed (SHA-256) on
write and verified on read. Corruption is detected and the chunk is re-fetched
or flagged.
- **Bounds validation:** Chunk coordinates and object positions are validated
against the world's chunk dimensions and room bounds before persistence.
- **Moderation enforcement:** `content_flags` are checked at load time; mature
chunks are gated behind the age-verification flow (see `content_moderation.md`).
- **Input validation:** All string fields are length-limited and validated
against allowed character sets to prevent injection and oversized payloads.
---
## 8. Open Questions / Future Work
- **Volumetric worlds:** The schema and format already support `CHUNK_SIZE_Z >
1`, but the tooling and streaming for 3D chunks are not yet specified.
- **Chunk compression:** For very large worlds, the binary payload could be
compressed (e.g. zstd) before storage/transfer. The header reserves a `flags`
bitmask for a compression flag.
- **Multi-server sharding:** If a single SQLite file becomes a bottleneck, the
`chunks` table could be sharded by `world_id` across multiple databases. The
key design already supports this (world_id is a leading key component).
- **Delta compression for streaming:** Instead of sending full chunks, the
server could send only the delta between revisions to reduce bandwidth for
frequently-updated chunks.
-47
View File
@@ -1,47 +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/server.tscn"
config/features=PackedStringArray("4.6.2.stable")
[autoload]
Server="*res://scripts/server.gd"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
[input]
move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
move_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
[rendering]
renderer/rendering_method="forward_plus"
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
[node name="Player" type="CharacterBody3D"]
script = ExtResource("1")
-3
View File
@@ -1,3 +0,0 @@
[gd_scene load_steps=1 format=3]
[node name="Server" type="Node"]
-232
View File
@@ -1,232 +0,0 @@
class_name PlayerNode
extends CharacterBody3D
## PlayerNode - Server-side player entity.
##
## Represents a connected player on the server. Handles movement,
## input processing, and state synchronization.
# --- Signals ---
signal health_changed(new_health: int)
signal player_died(player_node: PlayerNode)
# --- Constants ---
const WALK_SPEED := 5.0
const SPRINT_MULTIPLIER := 1.6
const JUMP_VELOCITY := 4.5
const GRAVITY := 9.8
const MAX_FALL_SPEED := 20.0
const FRICTION := 8.0
const AIR_CONTROL := 0.3
# --- Configurable ---
@export var walk_speed: float = WALK_SPEED
@export var sprint_multiplier: float = SPRINT_MULTIPLIER
@export var jump_velocity: float = JUMP_VELOCITY
@export var max_fall_speed: float = MAX_FALL_SPEED
# --- State ---
@export var max_health: int = 100
var current_health: int = 0
var is_alive: bool = true
var is_sprinting: bool = false
var peer_id: int = -1
var player_name: String = ""
var entity_id: int = 0
# --- Input buffer ---
var _input_queue: Array[Dictionary] = []
var _last_processed_seq: int = -1
# --- Movement ---
var _velocity: Vector3 = Vector3.ZERO
func _ready() -> void:
current_health = max_health
velocity = _velocity
# Start alive
is_alive = true
func _physics_process(delta: float) -> void:
if not is_alive:
return
# Process input from queue
_process_input(delta)
# Apply gravity
if not is_on_floor():
velocity.y -= GRAVITY * delta
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
# Apply friction on ground
if is_on_floor():
var move_dir := Vector2(velocity.x, velocity.z)
if move_dir.length() > 0.01:
var friction_factor := clampf(FRICTION * delta, 0.0, 1.0)
velocity.x = move_dir.normalized().x * move_dir.length() * (1.0 - friction_factor)
velocity.z = move_dir.normalized().y * move_dir.length() * (1.0 - friction_factor)
else:
velocity.x = 0.0
velocity.z = 0.0
# Move and slide
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
move_and_slide()
# Update visual position (for remote clients via RPC)
_update_visual_state()
func _process_input(delta: float) -> void:
"""Process buffered input from the server's input queue."""
if _input_queue.is_empty():
return
var input_data: Dictionary = _input_queue.pop_front()
var input_state: Dictionary = input_data.get("input_state", {})
# Calculate movement direction
var direction := Vector3.ZERO
direction.x = input_state.get("move_x", 0.0)
direction.z = input_state.get("move_z", 0.0)
# Sprint check
is_sprinting = input_state.get("sprint", false)
if direction.length() > 0.01:
direction = direction.normalized()
var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed
velocity.x = direction.x * speed
velocity.z = direction.z * speed
else:
# On ground, apply friction (handled in physics_process)
if not is_on_floor():
velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0)
velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0)
# Jump
if input_state.get("jump", false) and is_on_floor():
velocity.y = jump_velocity
func take_damage(amount: int, source_id: int) -> Dictionary:
"""Take damage and return result dict."""
if not is_alive:
return {"success": false, "reason": "already_dead"}
current_health = max(0, current_health - amount)
health_changed.emit(current_health)
if current_health <= 0:
die()
return {"success": true, "health": 0, "dead": true}
return {"success": true, "health": current_health}
func die() -> void:
"""Handle player death."""
is_alive = false
velocity = Vector3.ZERO
player_died.emit(self)
# Respawn after delay
await get_tree().create_timer(3.0).timeout
respawn()
func respawn() -> void:
"""Respawn the player at spawn point."""
current_health = max_health
is_alive = true
velocity = Vector3.ZERO
health_changed.emit(current_health)
# Move to spawn point (default: origin)
position = Vector3.ZERO
func set_input_queue(inputs: Array[Dictionary]) -> void:
"""Set the input queue from the server."""
_input_queue = inputs
func get_state() -> Dictionary:
"""Get current player state for replication."""
return {
"entity_id": entity_id,
"peer_id": peer_id,
"position": position,
"rotation": rotation,
"health": current_health,
"max_health": max_health,
"is_alive": is_alive,
"is_sprinting": is_sprinting,
}
func get_server_state() -> Dictionary:
"""Get current player state for server snapshot broadcast."""
return get_state()
func process_server_input(tick: int) -> void:
"""Called by server tick to process buffered input."""
# This is called from the server's _tick loop.
# The input queue is populated by RPCs from clients.
pass
func _update_visual_state() -> void:
"""Update visual state (for remote interpolation)."""
# In a full implementation, this would update a remote player's
# visual representation. For now, the node itself is the authority.
pass
# --- RPCs ---
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_input(input_state: Dictionary, seq: int) -> void:
"""Receive input from client."""
if not is_alive:
return
# Store for processing in _physics_process
_input_queue.append({
"input_state": input_state,
"seq": seq,
"timestamp": Time.get_ticks_msec(),
})
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_interact(target_id: int) -> void:
"""Handle interaction request from client."""
if not is_alive:
return
# TODO: Implement interaction logic
print("Player %d interacted with entity %d" % [peer_id, target_id])
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void:
"""Handle attack request from client."""
if not is_alive:
return
# TODO: Implement attack logic
print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir])
@rpc("authority", "call_remote", "reliable")
func rpc_set_position(pos: Vector3) -> void:
"""Server authoritative position update."""
position = pos
@rpc("authority", "call_remote", "reliable")
func rpc_set_rotation(rot: Vector3) -> void:
"""Server authoritative rotation update."""
rotation = rot
-1
View File
@@ -1 +0,0 @@
uid://br27owjh2f5xd
-174
View File
@@ -1,174 +0,0 @@
## ServerMain - Dedicated server root node.
## Accepts ENet connections, spawns Player nodes for each peer,
## and runs the simulation tick loop at 20 Hz.
extends Node
## Maximum number of concurrent players.
const MAX_PLAYERS := 16
## Server tick rate (Hz).
const TICK_RATE := 20
## Tick duration in seconds.
const TICK_DURATION := 1.0 / TICK_RATE
## ENet server port.
const SERVER_PORT := 7777
## Network peer used for ENet communication.
var network_peer: ENetMultiplayerPeer
## Map of peer_id -> Player node.
var players: Dictionary = {}
## Current tick counter (server-authoritative).
var tick: int = 0
## Whether the server is running.
var is_running: bool = false
## Accumulated real time for fixed timestep simulation.
var accumulator: float = 0.0
## Player scene resource (loaded once).
var _player_scene: PackedScene
func _ready() -> void:
# Preload the player scene once.
_player_scene = preload("res://scenes/player.tscn")
# Create and assign the ENet server peer.
network_peer = _create_server_peer()
if network_peer:
multiplayer.multiplayer_peer = network_peer
is_running = true
print("[Server] Started on port %d" % SERVER_PORT)
else:
printerr("[Server] Failed to create ENet peer.")
get_tree().quit(1)
# ---------------------------------------------------------------------------
# Server creation
# ---------------------------------------------------------------------------
func _create_server_peer() -> ENetMultiplayerPeer:
var peer := ENetMultiplayerPeer.new()
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
if error != OK:
printerr("[Server] create_server error: %d" % error)
return null
return peer
# ---------------------------------------------------------------------------
# Fixed-timestep simulation loop
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
if not is_running:
return
# Fixed timestep accumulator.
accumulator += delta
while accumulator >= TICK_DURATION:
accumulator -= TICK_DURATION
_tick()
func _tick() -> void:
"""Main server simulation tick (called at TICK_RATE)."""
tick += 1
# Process player inputs and update state.
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("process_server_input"):
player.process_server_input(tick)
# Broadcast snapshot to all clients.
_broadcast_snapshot()
func _broadcast_snapshot() -> void:
"""Broadcast the current authoritative state to all connected clients."""
var snapshot_data := {
"tick": tick,
"players": {},
}
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("get_server_state"):
snapshot_data.players[peer_id] = player.get_server_state()
# Send snapshot to all peers except the server itself (peer_id == 1).
var peer_ids: Array = multiplayer.get_peers()
for pid in peer_ids:
if pid == 1: # 1 is the server itself
continue
rpc_id(pid, "_receive_snapshot", snapshot_data)
# Remote: clients receive snapshots.
@rpc("any_peer", "call_remote", "reliable")
func _receive_snapshot(snapshot_data: Dictionary) -> void:
pass # Handled by client scene.
# ---------------------------------------------------------------------------
# Connection lifecycle
# ---------------------------------------------------------------------------
func _on_peer_connected(peer_id: int) -> void:
print("[Server] Peer %d connected." % peer_id)
if players.size() >= MAX_PLAYERS:
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
rpc_id(peer_id, "_kick", "Server is full.")
return
# Spawn a Player node for this peer.
var player: Node3D = _player_scene.instantiate()
player.name = "Player_%d" % peer_id
player.peer_id = peer_id
add_child(player)
players[peer_id] = player
print("[Server] Spawned player node for peer %d." % peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
print("[Server] Peer %d disconnected." % peer_id)
if players.has(peer_id):
var player: Node3D = players[peer_id]
if player.is_inside_tree():
player.queue_free()
players.erase(peer_id)
# ---------------------------------------------------------------------------
# RPCs
# ---------------------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _kick(reason: String) -> void:
print("[Server] Kicking caller: %s" % reason)
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
@rpc("call_remote", "reliable")
func _disconnect(reason: String) -> void:
print("[Server] Disconnecting: %s" % reason)
get_tree().quit(0)
# ---------------------------------------------------------------------------
# Signal connections
# ---------------------------------------------------------------------------
func _enter_tree() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
-1
View File
@@ -1 +0,0 @@
uid://cwjv826lva2gi