"""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 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 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 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 [], ) 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()