Add world content persistence backend (Postgres chunks, internal API, game-server token auth)
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
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""Internal, dedicated-server-only content API.
|
||||
|
||||
Implements `docs/architecture/world_persistence_impl.md` §7 + §8.3. This is
|
||||
how a spawned Godot dedicated server (`scripts/server.gd`, spawned by
|
||||
`app.services.game_servers`) pulls its world's content read-only over HTTP
|
||||
before binding its ENet port. Mounted at ``/api/internal`` -- a prefix that
|
||||
can also be blocked at the reverse proxy, per the doc's rejected-alternative
|
||||
note in §7.3.
|
||||
|
||||
Gated by ``require_game_server`` (§7.3): a bearer token minted per spawn by
|
||||
``GameServerSupervisor._spawn`` and checked against
|
||||
``supervisor.validate_token(world_id, token)``. There is no user identity
|
||||
here at all -- these routes skip the per-user visibility check public
|
||||
routes apply, and apply no mature-content gating (gating is a client/user
|
||||
concern; the dedicated server still needs the geometry for collision).
|
||||
|
||||
Read-only by construction: there is no write route here, and adding one
|
||||
would violate world_persistence_impl.md D2 (the FastAPI backend is the
|
||||
only writer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.world import World
|
||||
from app.models.world_content import MAIN_ROOM_SLUG
|
||||
from app.services import chunk_codec
|
||||
from app.services.game_servers import supervisor
|
||||
from app.services.world_store import Bounds, WorldStore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
async def require_game_server(
|
||||
world_id: int,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
"""world_persistence_impl.md §7.3. A path whose ``world_id`` differs
|
||||
from the token's world is 403 -- ``validate_token`` enforces that by
|
||||
construction (it looks the token up *by* ``world_id``)."""
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing bearer token")
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
if not token or not supervisor.validate_token(world_id, token):
|
||||
raise HTTPException(status_code=403, detail="invalid or expired server token")
|
||||
|
||||
|
||||
async def _get_world_or_404(world_id: int, db: AsyncSession) -> World:
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if world is None:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
return world
|
||||
|
||||
|
||||
async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str):
|
||||
room = await store.get_room(world_id, slug)
|
||||
if room is None and slug == MAIN_ROOM_SLUG:
|
||||
room = await store.get_or_create_main_room(world_id)
|
||||
await db.commit()
|
||||
return room
|
||||
|
||||
|
||||
@router.get("/worlds/{world_id}/manifest", dependencies=[Depends(require_game_server)])
|
||||
async def manifest(world_id: int, db: DbDep):
|
||||
await _get_world_or_404(world_id, db)
|
||||
store = WorldStore(db)
|
||||
rooms = await store.list_rooms(world_id)
|
||||
if not rooms:
|
||||
rooms = [await store.get_or_create_main_room(world_id)]
|
||||
await db.commit()
|
||||
|
||||
room_payloads = []
|
||||
for r in rooms:
|
||||
room_payloads.append({
|
||||
"slug": r.slug, "name": r.name, "grid": r.grid, "bounds": r.bounds,
|
||||
"spawn": r.spawn, "chunk_count": await store.count_chunks(r.id),
|
||||
})
|
||||
|
||||
return {
|
||||
"world_id": world_id,
|
||||
"content_revision": await store.content_revision(world_id),
|
||||
"rooms": room_payloads,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/worlds/{world_id}/rooms/{slug}/chunks", dependencies=[Depends(require_game_server)])
|
||||
async def internal_list_chunks(
|
||||
world_id: int, slug: str, db: DbDep,
|
||||
cx_min: int | None = None, cx_max: int | None = None,
|
||||
cy_min: int | None = None, cy_max: int | None = None,
|
||||
cz: int | None = None,
|
||||
):
|
||||
await _get_world_or_404(world_id, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz)
|
||||
chunks = await store.list_chunks(world_id, room.id, bounds)
|
||||
return {
|
||||
"room": room.slug,
|
||||
"content_revision": await store.content_revision(world_id),
|
||||
"chunks": [
|
||||
{
|
||||
"key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz,
|
||||
"revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len,
|
||||
"is_mature": c.is_mature,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
for c in chunks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}",
|
||||
dependencies=[Depends(require_game_server)],
|
||||
)
|
||||
async def internal_get_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, db: DbDep):
|
||||
await _get_world_or_404(world_id, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
|
||||
chunk = await store.get_chunk(key)
|
||||
if chunk is None:
|
||||
raise HTTPException(status_code=404, detail="chunk not found")
|
||||
|
||||
return Response(
|
||||
content=chunk.payload, media_type="application/octet-stream",
|
||||
headers={
|
||||
"ETag": f'"{chunk.checksum}"', "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user