b6c6ddfbc4
Implements docs/architecture/world_persistence_impl.md §9's backend task: chunk/room/revision Postgres schema (migration 0005), a Python port of the world.md §3.6 binary chunk codec with golden-file tests, a WorldStore service (world.md §5.1 minus flush_dirty/rebuild_index), the public world-content router (rooms/chunks CRUD, optimistic concurrency, batch writes) and the internal read-only API for dedicated servers, and game_servers.py changes: a per-spawn bearer token, --api-url/--server-token args, and a real "ready"-field wait replacing _await_port, which bound the UDP port before content existed and failed open (no error) past its deadline. create_world now auto-creates the "main" room. Deviations/findings, not silently decided: - chunk_codec.py implements real IEEE-754 float32 packing rather than mirroring a confirmed bug in scripts/chunk_data_decoder.gd's _read_float32/_write_float32, which do a lossy int<->float cast instead of a bit reinterpretation (verified against Godot 4.6.2 headless) and will corrupt any non-integer position/rotation/scale/bounds value. Not fixed here (client-side, out of scope); flagged for the client task. - world_persistence_impl.md references a world's monotonic content_revision (§6/§7.2/§8.1) but never defines where it's stored. Resolved as MAX(id) over the append-only world_chunk_revisions table, scoped to world_id, rather than adding an undocumented column. - No existing Godot-side golden fixture/test vectors were found to reuse, so tests/fixtures/chunks/*.bin are authored fresh from this implementation (see generate_fixtures.py). Verification: 132/132 backend pytest tests pass against a real ephemeral Postgres (both reusing an already-running instance and cold via conftest.py's own docker-compose bootstrap), including migration upgrade/downgrade/upgrade, codec round-trip + validation-error tests, and full HTTP round trips for PUT/GET/batch/delete/revisions/rollback and the internal API's token auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
213 lines
6.5 KiB
Python
213 lines
6.5 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
|
|
|
|
|
|
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: 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,
|
|
)
|
|
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()
|