Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11e53d30e4 |
-10
@@ -1,10 +0,0 @@
|
||||
# Godot 4 editor cache/import directory
|
||||
.godot/
|
||||
|
||||
# Godot export artifacts
|
||||
export/
|
||||
*.tmp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -1,52 +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
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -1,209 +0,0 @@
|
||||
"""Asset management router."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.asset import Asset, AssetType
|
||||
from app.models.world import World
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Pydantic schemas ---
|
||||
|
||||
class AssetCreate(BaseModel):
|
||||
"""Schema for creating an asset reference."""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
asset_type: AssetType
|
||||
world_id: Optional[int] = None
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
"""Schema for asset responses."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
asset_type: AssetType
|
||||
file_path: str
|
||||
file_size: Optional[int]
|
||||
mime_type: Optional[str]
|
||||
world_id: Optional[int]
|
||||
owner_id: int
|
||||
is_public: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssetUpdate(BaseModel):
|
||||
"""Schema for updating an asset."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Strip path components and unsafe characters from a filename."""
|
||||
# Remove any directory components (path traversal protection).
|
||||
base = os.path.basename(filename.replace("\\", "/"))
|
||||
# Keep only safe characters.
|
||||
safe = "".join(c for c in base if c.isalnum() or c in "._-")
|
||||
return safe or "file"
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
||||
async def create_asset(
|
||||
payload: AssetCreate,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new asset reference."""
|
||||
# Verify world ownership if world_id is provided
|
||||
if payload.world_id is not None:
|
||||
world = await db.get(World, payload.world_id)
|
||||
if world is None:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
if world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
||||
|
||||
asset = Asset(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
asset_type=payload.asset_type,
|
||||
file_path="", # Will be set after upload
|
||||
owner_id=current_user.id,
|
||||
world_id=payload.world_id,
|
||||
is_public=False,
|
||||
)
|
||||
db.add(asset)
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.get("/", response_model=list[AssetResponse])
|
||||
async def list_assets(
|
||||
current_user: CurrentUser,
|
||||
world_id: Optional[int] = Query(None, description="Filter by world"),
|
||||
asset_type: Optional[AssetType] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List assets with optional filters."""
|
||||
stmt = select(Asset).where(Asset.owner_id == current_user.id)
|
||||
|
||||
if world_id is not None:
|
||||
stmt = stmt.where(Asset.world_id == world_id)
|
||||
if asset_type is not None:
|
||||
stmt = stmt.where(Asset.asset_type == asset_type)
|
||||
|
||||
stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset)
|
||||
result = await db.execute(stmt)
|
||||
assets = result.scalars().all()
|
||||
return assets
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
async def get_asset(
|
||||
asset_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single asset by ID."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
return asset
|
||||
|
||||
|
||||
@router.patch("/{asset_id}", response_model=AssetResponse)
|
||||
async def update_asset(
|
||||
asset_id: int,
|
||||
payload: AssetUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an asset."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(asset, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=204)
|
||||
async def delete_asset(
|
||||
asset_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete an asset."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
await db.delete(asset)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{asset_id}/upload", response_model=AssetResponse)
|
||||
async def upload_asset_file(
|
||||
asset_id: int,
|
||||
current_user: CurrentUser,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a file for an existing asset."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
# Sanitize filename to prevent path traversal.
|
||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
||||
|
||||
# Validate file size against configured maximum.
|
||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
if file_size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes",
|
||||
)
|
||||
|
||||
# Write the file to the upload directory.
|
||||
upload_dir = Path(settings.UPLOAD_DIR)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
stored_name = f"{asset_id}_{safe_filename}"
|
||||
file_path = upload_dir / stored_name
|
||||
file_path.write_bytes(content)
|
||||
|
||||
asset.file_path = str(file_path)
|
||||
asset.mime_type = file.content_type
|
||||
asset.file_size = file_size
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -1,3 +0,0 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,182 +0,0 @@
|
||||
# Creator Studio — Extended Authoring Tools
|
||||
|
||||
> **Feature:** Creator Studio — Extended Authoring Tools
|
||||
> **Status:** Planning
|
||||
> **Owner:** Platform team
|
||||
|
||||
## Feature Overview
|
||||
|
||||
Extends the in-game world editor (M6) toward a real authoring tool with:
|
||||
1. **Asset import pipeline** (glTF models/textures)
|
||||
2. **Script editor** with sandboxed API surface exposed clearly
|
||||
3. **Experience publishing flow** (draft → review → live)
|
||||
4. **Basic analytics** for creators (plays, session length)
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
_As a creator, I can import glTF models and textures into my experience so that I can use custom 3D assets._
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
_As a creator, I can write and test scripts in the editor with autocomplete/docs for the available API surface._
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
_As a creator, I can publish my experience through a draft → review → live pipeline so that players can discover and play it._
|
||||
|
||||
### US-4: Creator Analytics
|
||||
_As a creator, I can see how many plays and average session length my experiences receive so that I can understand player engagement._
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
|
||||
#### T1.1: Backend — glTF Asset Upload with Metadata Extraction
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (extend upload endpoint), `backend/app/models/asset.py` (add glTF-specific metadata fields)
|
||||
- **Scope:** Extend the existing `upload_asset_file` endpoint in `assets.py` to detect `.glb`/`.gltf` files and extract glTF metadata (mesh_count, vertex_count, has_animation, texture_refs, bounding_box) from the embedded JSON. Store extracted metadata in `metadata_json`. Add a `has_gltf_metadata` computed property. The Asset model already has `asset_type = MODEL_3D` and `metadata_json` — this task adds the extraction logic.
|
||||
- **Acceptance:** POST `/api/assets/{id}/upload` with a `.glb` file returns asset with `metadata_json` containing `mesh_count`, `vertex_count`, `has_animation`, `texture_refs`. GET lists include these fields. File stored on disk. Non-glTF files are unaffected.
|
||||
- **Dependencies:** None (builds on existing asset infrastructure)
|
||||
|
||||
#### T1.2: Backend — Public Asset Catalog Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (new endpoint)
|
||||
- **Scope:** Add `GET /api/assets/catalog` endpoint that returns all public glTF assets with their metadata (name, thumbnail_url, mesh_count, vertex_count, tags). Add filtering by tags and pagination. This is what the editor's asset palette queries. Uses the existing `Asset.is_public` field.
|
||||
- **Acceptance:** Catalog endpoint returns paginated list of public assets; filtering by tag works; pagination params respected; returns 401 without auth.
|
||||
- **Dependencies:** T1.1
|
||||
|
||||
#### T1.3: Godot — glTF Import Utility (Editor-Side)
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/gltf_importer.gd` (new), `scripts/asset_catalog.gd` (new)
|
||||
- **Scope:** Godot-side utility that connects to the catalog API, downloads glTF files, and converts them to Godot-compatible resources (`.res` files with embedded mesh data). Provides `import_gltf(url: String) -> Resource` function. Handles texture extraction and mesh merging. Uses the ApiClient autoload (from client-server-wiring plan) for HTTP.
|
||||
- **Acceptance:** `import_gltf()` downloads a glTF from the catalog, converts mesh to `Mesh` resource, returns a ready-to-use Godot resource. Texture files stored in `user://assets/`.
|
||||
- **Dependencies:** T1.2, client-server-wiring ApiClient (task 1 from that plan)
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
|
||||
> **Design note from `scripting_sandbox.md`:** Godot 4 has no built-in GDScript sandbox. The recommended approach is a **data-driven DSL** (JSON/Resource) interpreted by trusted GDScript, not arbitrary code execution. This task follows that recommendation.
|
||||
|
||||
#### T2.1: Backend — Script Model & CRUD Endpoints
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/script.py` (new), `backend/app/routers/scripts.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Script` model (id, name, world_id, owner_id, source_code TEXT, version INT, script_type ENUM[gdscript, dsl], dsl_payload JSON, created_at, updated_at). Add full CRUD endpoints: `POST /api/scripts/` (create), `GET /api/scripts/` (list), `GET /api/scripts/{id}` (get), `PUT /api/scripts/{id}` (update), `DELETE /api/scripts/{id}` (delete). Scripts are owned by users and associated with a world. `dsl_payload` stores the JSON/Resource data for the DSL approach; `source_code` stores raw GDScript if used.
|
||||
- **Acceptance:** All CRUD endpoints work; scripts are owned by creator; list endpoint filters by owner/world; update increments version; DSL and gdscript types supported.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T2.2: Godot — Script API Surface Definition
|
||||
- **Type:** architecture
|
||||
- **Files:** `scripts/editor_script_api.gd` (new)
|
||||
- **Scope:** Define the public API surface available to creator scripts. This is a `RefCounted` class that wraps the Godot engine's capabilities into a safe, documented interface. Includes: `WorldAPI` (get_tile, get_object, set_property), `PlayerAPI` (get_position, get_health), `AudioAPI` (play_sound, play_music), `NetworkAPI` (send_event, broadcast). Each method has documented parameters and return types. Self-documenting via `##` doc comments for autocomplete.
|
||||
- **Acceptance:** `EditorScriptAPI` class exposes all documented methods; methods are callable from user scripts; API surface is self-documenting.
|
||||
- **Dependencies:** None (standalone definition)
|
||||
|
||||
#### T2.3: Godot — In-Game Script Editor UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_script_editor.tscn` (new), `scripts/editor_script_editor.gd` (new)
|
||||
- **Scope:** A `Control`-based UI panel that provides a script editor within the game. Features: code editor with syntax highlighting (basic), autocomplete dropdown, run button (calls backend `/run` endpoint), output console (shows stdout/stderr), API reference panel (shows available methods). Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Editor panel opens from toolbar; code editor accepts input; autocomplete shows API methods; run button executes script and shows output; API reference panel is browsable.
|
||||
- **Dependencies:** T2.1, T2.2
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
|
||||
#### T3.1: Backend — Experience Model & Status Transitions
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/experience.py` (new), `backend/app/routers/experiences.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Experience` model (id, world_id FK→worlds, title, description, status ENUM[DRAFT,SUBMITTED,IN_REVIEW,APPROVED,REJECTED,LIVE], published_at, review_notes, reviewer_id FK→users). Status transitions enforced: DRAFT→SUBMITTED→IN_REVIEW→APPROVED→LIVE; REJECTED→DRAFT (resubmit). Add endpoints: `POST /api/experiences/` (create draft), `POST /api/experiences/{id}/submit` (submit for review), `POST /api/experiences/{id}/approve` (approve), `POST /api/experiences/{id}/reject` (reject), `GET /api/experiences/` (list with status filter). An experience is a **publishing wrapper** around a World — the world's visibility is set to PUBLIC when experience goes LIVE.
|
||||
- **Acceptance:** All CRUD + transition endpoints work; status transitions enforced; rejected can be resubmitted; list filters by status; approve sets world visibility to PUBLIC; only world owner can submit; only moderators can approve/reject.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T3.2: Backend — Review Queue & Moderation Integration
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/experiences.py` (add endpoints)
|
||||
- **Scope:** Add review queue endpoint `GET /api/experiences/review-queue` (returns experiences in IN_REVIEW status). Add `POST /api/experiences/{id}/review` endpoint for moderators to add review notes and change status. Integrates with content moderation: when an experience is submitted, its world's content flags are computed and attached to the review.
|
||||
- **Acceptance:** Review queue returns IN_REVIEW experiences; review endpoint accepts notes and status change; content flags attached on submit; only moderators can review.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
#### T3.3: Godot — Publishing UI Panel
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_publish_panel.tscn` (new), `scripts/editor_publish_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that lets creators manage their experience's publishing state. Features: title/description editor, status badge (DRAFT/SUBMITTED/LIVE), submit button, review notes display, publish history timeline. Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Panel shows current status; submit button calls backend; review notes displayed; publish history shown; status badge updates on refresh.
|
||||
- **Dependencies:** T3.1, T3.2
|
||||
|
||||
#### T3.4: Godot — Experience List & Discovery
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/experience_list.tscn` (new), `scripts/experience_list.gd` (new)
|
||||
- **Scope:** A UI panel that lists all experiences available to the player (LIVE status). Features: search/filter, experience cards (title, description, creator, play count), join button. Queries `GET /api/experiences/?status=LIVE` and displays results.
|
||||
- **Acceptance:** Lists LIVE experiences; search filters by title/description; cards show metadata; join button loads the experience's world.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
### US-4: Creator Analytics
|
||||
|
||||
#### T4.1: Backend — Session & Play Tracking Models
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/analytics.py` (new), `backend/app/routers/analytics.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `PlaySession` model (id, experience_id FK→experiences, player_id FK→users, started_at, ended_at, duration_seconds, chunk_loads INT, interactions_count INT). Create `PlayEvent` model (id, experience_id FK→experiences, player_id FK→users, event_type, payload JSON, created_at). Add endpoints: `POST /api/analytics/session/start` (start session), `POST /api/analytics/session/end` (end session), `POST /api/analytics/event` (log event). Note: `sessions` table is taken by auth tokens; this uses `play_sessions` table name.
|
||||
- **Acceptance:** Session start/end endpoints work; events logged; duration calculated correctly; endpoints accept valid experience/player IDs.
|
||||
- **Dependencies:** None (new models + routers)
|
||||
|
||||
#### T4.2: Backend — Creator Dashboard Analytics Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/analytics.py` (add endpoint)
|
||||
- **Scope:** Add `GET /api/analytics/experience/{id}` endpoint that returns aggregated analytics for an experience: total_plays, unique_players, avg_session_length, total_play_time, plays_over_time (daily breakdown), top_chunks_loaded, top_interactions. Data is aggregated from PlaySession and PlayEvent tables.
|
||||
- **Acceptance:** Returns all aggregated metrics; daily breakdown covers last 30 days; only owner or moderator can view; empty experience returns zeros.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.3: Godot — Analytics Tracking in Game
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/analytics_tracker.gd` (new)
|
||||
- **Scope:** A singleton-like service that tracks player sessions and events during gameplay. Automatically starts session on world load, tracks chunk loads, interactions, and time spent. Sends periodic heartbeat events to backend. Handles session end on world unload or disconnect.
|
||||
- **Acceptance:** Session starts on world load; events logged during play; heartbeat sends every 60s; session ends on world unload; handles disconnect gracefully.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.4: Godot — Creator Analytics Dashboard UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/creator_analytics_panel.tscn` (new), `scripts/creator_analytics_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that displays analytics for the current experience. Features: total plays counter, avg session length, plays over time chart (simple bar chart), top chunks, top interactions. Queries `GET /api/analytics/experience/{id}` and renders data.
|
||||
- **Acceptance:** Panel shows all metrics; chart renders bar chart of daily plays; data refreshes on button press; handles empty data gracefully.
|
||||
- **Dependencies:** T4.2, T4.3
|
||||
|
||||
---
|
||||
|
||||
## Database Migration Plan
|
||||
|
||||
New tables needed (in order):
|
||||
1. `scripts` — script storage
|
||||
2. `experiences` — publishing state (wraps worlds)
|
||||
3. `play_sessions` — session tracking (note: `sessions` table already exists for auth tokens)
|
||||
4. `play_events` — granular events
|
||||
|
||||
Migration file: `backend/alembic/versions/0003_creator_studio.py`
|
||||
|
||||
---
|
||||
|
||||
## Task Dependencies Summary
|
||||
|
||||
```
|
||||
T1.1 (glTF upload) ──→ T1.2 (catalog API) ──→ T1.3 (Godot importer)
|
||||
T2.1 (script model) ──→ T2.3 (editor UI)
|
||||
T2.2 (API surface) ──→ T2.3 (editor UI)
|
||||
T3.1 (experience model) ──→ T3.2 (review queue) ──→ T3.3 (publish UI)
|
||||
T3.1 ──→ T3.4 (discovery list)
|
||||
T4.1 (tracking model) ──→ T4.2 (dashboard endpoint) ──→ T4.4 (analytics UI)
|
||||
T4.1 ──→ T4.3 (game tracker) ──→ T4.4 (analytics UI)
|
||||
```
|
||||
|
||||
## Implementation Order Recommendation
|
||||
|
||||
1. **T1.1** — Foundation: asset upload with glTF metadata extraction
|
||||
2. **T2.1** — Foundation: script model + CRUD
|
||||
3. **T3.1** — Foundation: experience model + status transitions
|
||||
4. **T4.1** — Foundation: analytics tracking model
|
||||
5. **T1.2** — Asset catalog for editor
|
||||
6. **T2.2** — Script API surface definition
|
||||
7. **T3.2** — Review queue integration
|
||||
8. **T4.2** — Analytics dashboard endpoint
|
||||
9. **T1.3** — Godot glTF importer (depends on ApiClient from client-server-wiring)
|
||||
10. **T2.3** — Script editor UI
|
||||
11. **T3.3** — Publishing UI panel
|
||||
12. **T3.4** — Experience discovery list
|
||||
13. **T4.3** — Analytics tracker in game
|
||||
14. **T4.4** — Analytics dashboard UI
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
Bugs hide in the dark,
|
||||
Tests bring them into the light,
|
||||
Code stands strong and clear.
|
||||
@@ -1,24 +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")
|
||||
|
||||
[autoload]
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/stretch/mode="canvas_items"
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -1,4 +0,0 @@
|
||||
extends RefCounted
|
||||
|
||||
func hello():
|
||||
return "from test_script.gd"
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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 +0,0 @@
|
||||
uid://dc6j7kqjitc6g
|
||||
@@ -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 +0,0 @@
|
||||
uid://bv445xrd6f5tk
|
||||
@@ -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 +0,0 @@
|
||||
uid://d4gh4h5iey6xb
|
||||
@@ -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 +0,0 @@
|
||||
uid://8yokqwe23c23
|
||||
@@ -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 +0,0 @@
|
||||
uid://dyjqcstdnr4u1
|
||||
@@ -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,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 +0,0 @@
|
||||
uid://chf6mruev1kc4
|
||||
@@ -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 +0,0 @@
|
||||
uid://bavmpbmthilbx
|
||||
@@ -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 +0,0 @@
|
||||
uid://came2ygke3ajs
|
||||
@@ -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 +0,0 @@
|
||||
uid://d1x05tq8h43gm
|
||||
@@ -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 +0,0 @@
|
||||
uid://cxut2ghbsvobn
|
||||
Reference in New Issue
Block a user