Files
modbison/backend/app/routers/worlds.py
T
daddy 7ecf0d53bd
checkpoint / checkpoint (push) Failing after 3h14m9s
Complete M11 Creator Studio and M12 Trust & Safety for PC5
- Creator Studio: Visual behavior & property inspector, GLB model importer, binary persistence
- Trust & Safety: 18+ TOS confirmation (DOB removed), creator guidelines & briefing card on join, in-game reporting HUD
- Test Harness: Add --filter, --skip-backend, --skip-godot to run_checkpoint_tests.py
2026-09-17 23:13:57 +00:00

231 lines
7.0 KiB
Python

"""Worlds router - CRUD for game worlds."""
from datetime import datetime
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
from app.services.game_servers import supervisor
from app.services.world_store import WorldStore
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
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
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
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
class WorldResponse(BaseModel):
"""Schema for world responses."""
id: int
name: str
description: str | None
visibility: WorldVisibility
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
play_count: int = 0
last_played_at: datetime | None = None
owner_id: int
created_at: datetime | 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
class JoinResponse(BaseModel):
"""Address of the dedicated server hosting this world."""
world_id: int
host: str
port: int
@router.post("/{world_id}/join", response_model=JoinResponse)
async def join_world(
world_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Ensure a dedicated game server is running for this world and return its
ENet address for the client to connect to.
503 if game-server orchestration isn't enabled on this backend (e.g. a
plain dev/test instance with no Godot binary).
"""
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.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="World not found")
try:
info = await supervisor.ensure_server(str(world_id))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
return JoinResponse(world_id=world_id, host=info["host"], port=info["port"])
@router.post("/", response_model=WorldResponse, status_code=201)
async def create_world(
world_data: WorldCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new world.
Also creates the world's "main" room (world_persistence_impl.md §4.1) --
every world gets one so chunk content has somewhere to live from the
start.
"""
world = World(
name=world_data.name,
description=world_data.description,
owner_id=current_user.id,
visibility=world_data.visibility,
category=world_data.category,
tags=world_data.tags or [],
content_guidelines=world_data.content_guidelines,
)
db.add(world)
await db.flush() # assigns world.id, needed for the room's FK
await WorldStore(db).get_or_create_main_room(world.id)
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()