diff --git a/backend/alembic/env.py b/backend/alembic/env.py index c7f731f..bc33b7d 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,7 @@ 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, avatar_loadout # noqa: F401 +from app.models import user, session, world, asset, avatar_loadout, world_content # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/alembic/versions/0005_add_world_content_tables.py b/backend/alembic/versions/0005_add_world_content_tables.py new file mode 100644 index 0000000..f6b1788 --- /dev/null +++ b/backend/alembic/versions/0005_add_world_content_tables.py @@ -0,0 +1,120 @@ +"""add world_rooms, world_chunks, world_chunk_revisions tables + +Revision ID: 0005_add_world_content_tables +Revises: 0004_add_avatar_loadouts_table +Create Date: 2026-09-14 00:00:00.000000 + +See docs/architecture/world_persistence_impl.md §4. Chunk content +(rooms, chunk payloads, chunk revisions) lives in Postgres, purely +additive to the existing ``worlds`` table. +""" +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 = "0005_add_world_content_tables" +down_revision: Union[str, None] = "0004_add_avatar_loadouts_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "world_rooms", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "world_id", sa.Integer(), + sa.ForeignKey("worlds.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("slug", sa.String(64), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column( + "grid", postgresql.JSONB(), nullable=False, + server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}', + ), + sa.Column("bounds", postgresql.JSONB(), nullable=True), + sa.Column("spawn", postgresql.JSONB(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "updated_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False, + ), + sa.UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"), + ) + op.create_index("ix_world_rooms_world_id", "world_rooms", ["world_id"]) + + op.create_table( + "world_chunks", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "world_id", sa.Integer(), + sa.ForeignKey("worlds.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "room_id", sa.Integer(), + sa.ForeignKey("world_rooms.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("chunk_key", sa.String(160), nullable=False, unique=True), + sa.Column("cx", sa.Integer(), nullable=False), + sa.Column("cy", sa.Integer(), nullable=False), + sa.Column("cz", sa.Integer(), nullable=False, server_default="0"), + sa.Column("format_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("payload", sa.LargeBinary(), nullable=False), + sa.Column("payload_len", sa.Integer(), nullable=False), + sa.Column("checksum", sa.String(64), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False, server_default="1"), + sa.Column("content_flags", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("is_mature", sa.Boolean(), nullable=False, server_default="false"), + sa.Column( + "updated_by_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "updated_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False, + ), + sa.UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"), + ) + op.create_index("ix_world_chunks_world_id", "world_chunks", ["world_id"]) + op.create_index("ix_world_chunks_room_id", "world_chunks", ["room_id"]) + op.create_index( + "ix_world_chunks_room_coord", "world_chunks", ["room_id", "cx", "cy", "cz"], + ) + op.create_index( + "ix_world_chunks_mature", "world_chunks", ["world_id"], + postgresql_where=sa.text("is_mature"), + ) + + op.create_table( + "world_chunk_revisions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("chunk_key", sa.String(160), nullable=False), + sa.Column("world_id", sa.Integer(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("action", sa.String(16), nullable=False), + sa.Column( + "actor_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("checksum", sa.String(64), nullable=False), + sa.Column("payload", sa.LargeBinary(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_world_chunk_revisions_chunk_key", "world_chunk_revisions", ["chunk_key"]) + op.create_index("ix_world_chunk_revisions_world_id", "world_chunk_revisions", ["world_id"]) + op.create_index("ix_world_chunk_revisions_created_at", "world_chunk_revisions", ["created_at"]) + + +def downgrade() -> None: + op.drop_table("world_chunk_revisions") + op.drop_table("world_chunks") + op.drop_table("world_rooms") diff --git a/backend/app/config.py b/backend/app/config.py index c5960e3..595b3c3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -104,6 +104,22 @@ class Settings(BaseSettings): # Kill a world's server this long after its last join with no active # players reported. GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300 + # How long ensure_server() waits for a spawned server to report + # "ready": true in its status file before giving up (503 to the + # joining client). See world_persistence_impl.md §7.4 -- content + # loading makes a slow start more likely than the old bare-port-bind + # wait, so this is a separate, longer-by-default budget. + GAME_SERVER_READY_TIMEOUT_SECONDS: float = 20.0 + # The base URL the dedicated Godot server should call back into this + # backend on, for the read-only /api/internal content routes + # (world_persistence_impl.md §4.4 / §7). + GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000" + + # ── World content persistence (world_persistence_impl.md §4.4) ───────── + WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB + WORLD_CHUNK_MAX_OBJECTS: int = 512 + WORLD_CHUNK_MAX_BATCH: int = 64 + WORLD_CHUNK_REVISION_KEEP: int = 20 model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/app/main.py b/backend/app/main.py index 8912b8d..5c139a2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import auth, users, worlds, assets, avatar +from app.routers import auth, users, worlds, assets, avatar, world_content, internal from app.services.game_servers import supervisor @@ -40,8 +40,10 @@ app.add_middleware( 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(world_content.router, prefix="/api/worlds", tags=["world-content"]) app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"]) +app.include_router(internal.router, prefix="/api/internal", tags=["internal"]) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ccde9b6..20aca4a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,5 +3,9 @@ from app.models.session import Session from app.models.world import World from app.models.asset import Asset from app.models.avatar_loadout import AvatarLoadout +from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision -__all__ = ["User", "Session", "World", "Asset", "AvatarLoadout"] +__all__ = [ + "User", "Session", "World", "Asset", "AvatarLoadout", + "WorldRoom", "WorldChunk", "WorldChunkRevision", +] diff --git a/backend/app/models/world.py b/backend/app/models/world.py index 89226ed..9828436 100644 --- a/backend/app/models/world.py +++ b/backend/app/models/world.py @@ -34,3 +34,6 @@ class World(Base): owner = relationship("User", back_populates="worlds") assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan") + # world_content.py / world_persistence_impl.md §4.1. Added here (only) + # so PC1/PC2 code depending on this module is otherwise untouched. + rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan") diff --git a/backend/app/models/world_content.py b/backend/app/models/world_content.py new file mode 100644 index 0000000..c207229 --- /dev/null +++ b/backend/app/models/world_content.py @@ -0,0 +1,153 @@ +"""World content persistence models. + +Implements the schema decided in ``docs/architecture/world_persistence_impl.md`` +§4: chunk content (rooms, chunk payloads, chunk revisions) lives in Postgres, +alongside ``worlds``, rather than in a per-world SQLite file +(``docs/architecture/world.md`` §4, superseded). + +The chunk *payload* format itself (the 48-byte header + sectioned binary +body) is unchanged from ``world.md`` §3.6 and is decoded/encoded by +``app.services.chunk_codec``, not by these models -- these just store the +opaque ``BYTEA``. + +Conventions follow the existing models (``app/models/world.py``, +``app/models/avatar_loadout.py``): declarative ``Column(...)`` style, +``Integer`` surrogate PKs, ``DateTime(timezone=True)`` with +``server_default=func.now()``, JSONB for document-shaped fields. +""" + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + LargeBinary, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + +# Default chunk grid, matching Chunk.CHUNK_SIZE_* in scripts/chunk.gd (the +# client's source of truth for the same constants) and world.md §2.2. +DEFAULT_ROOM_GRID: dict = {"size": [32, 32, 1], "origin": [0, 0, 0]} + +# The room every world gets automatically (world_persistence_impl.md §4.1). +MAIN_ROOM_SLUG = "main" +MAIN_ROOM_NAME = "Main" + + +class WorldRoom(Base): + """A bounded region within a world -- a level, building, or zone + (world.md §2.1). Holds the chunk grid config so a world can later hold + rooms with different grids without touching the ``worlds`` table. + """ + + __tablename__ = "world_rooms" + + id = Column(Integer, primary_key=True, index=True) + world_id = Column( + Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True + ) + slug = Column(String(64), nullable=False) # url-safe; "main" by default + name = Column(String(255), nullable=False) + # {"size": [x, y, z], "origin": [x, y, z]} + grid = Column( + JSONB, nullable=False, + server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}', + ) + bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded + spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0} + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + __table_args__ = ( + UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"), + ) + + world = relationship("World", back_populates="rooms") + chunks = relationship("WorldChunk", back_populates="room", cascade="all, delete-orphan") + + +class WorldChunk(Base): + """One chunk's persisted content -- the atomic load/save/stream unit + (world.md §2.1). ``payload`` is the world.md §3.6 binary blob, stored + verbatim as BYTEA. + + Deltas from world.md §4.4 (SQLite design): a surrogate integer PK + + unique coordinate constraint (rather than ``chunk_key`` as the PK), no + ``dirty`` column (write-behind doesn't apply over Postgres -- see + world_persistence_impl.md §3.3/§5), and content flags folded into this + row as JSONB + an indexed ``is_mature`` boolean rather than a separate + ``content_flags`` table (world.md §4.6). + """ + + __tablename__ = "world_chunks" + + id = Column(Integer, primary_key=True, index=True) + world_id = Column( + Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True + ) # denormalized: world-scoped queries without a join through rooms + room_id = Column( + Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"), nullable=False, index=True + ) + chunk_key = Column(String(160), nullable=False, unique=True) # world.md §2.3 / §4.5 + cx = Column(Integer, nullable=False) + cy = Column(Integer, nullable=False) + cz = Column(Integer, nullable=False, server_default="0") + + format_version = Column(Integer, nullable=False, server_default="1") + payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes + payload_len = Column(Integer, nullable=False) + checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body + revision = Column(Integer, nullable=False, server_default="1") + + content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4 + is_mature = Column(Boolean, nullable=False, server_default="false") + + updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + __table_args__ = ( + UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"), + Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"), + Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")), + ) + + room = relationship("WorldRoom", back_populates="chunks") + + +class WorldChunkRevision(Base): + """Audit trail of chunk writes (world.md §4.7). ``chunk_key``/``world_id`` + are deliberately **not** foreign keys, so deleting a chunk or a world + preserves the audit trail. + + Retention (world_persistence_impl.md §4.3, required): only the newest + ``settings.WORLD_CHUNK_REVISION_KEEP`` rows per ``chunk_key`` keep a + non-null ``payload``; older rows are pruned (payload nulled, metadata + row kept) by ``WorldStore`` in the same transaction as the write that + triggers it. + """ + + __tablename__ = "world_chunk_revisions" + + id = Column(Integer, primary_key=True, index=True) + chunk_key = Column(String(160), nullable=False, index=True) + world_id = Column(Integer, nullable=False, index=True) + revision = Column(Integer, nullable=False) + action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete' + actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + checksum = Column(String(64), nullable=False) + payload = Column(LargeBinary, nullable=True) # full payload at this revision; nulled by retention + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) diff --git a/backend/app/routers/internal.py b/backend/app/routers/internal.py new file mode 100644 index 0000000..861dc06 --- /dev/null +++ b/backend/app/routers/internal.py @@ -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, + }, + ) diff --git a/backend/app/routers/world_content.py b/backend/app/routers/world_content.py new file mode 100644 index 0000000..6575207 --- /dev/null +++ b/backend/app/routers/world_content.py @@ -0,0 +1,451 @@ +"""World content router -- rooms + chunks, for the editor and player client. + +Implements `docs/architecture/world_persistence_impl.md` §8.1. JWT-authenticated +(``CurrentUser``, same as ``app/routers/worlds.py``). All chunk content +reads/writes go through ``WorldStore``; this module is HTTP plumbing + +authorization only. + +Authorization (doc §5.3): + - **Read** (world visibility, room listing, chunk fetch/index/revisions): + same rule as ``GET /api/worlds/{id}`` -- 404 (not 403) if the world is + neither PUBLIC nor owned by the caller. See ``_get_world_readable``. + - **Write** (room create, chunk PUT/DELETE/rollback): caller must be + ``world.owner_id``, matching ``update_world`` -- 403 otherwise, with + *no* visibility gate (this deliberately mirrors ``update_world``, which + also has no visibility check; see ``_get_world_writable``). +""" + +from __future__ import annotations + +import base64 +import binascii +from typing import Annotated + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +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.user import User +from app.models.world import World, WorldVisibility +from app.models.world_content import MAIN_ROOM_SLUG, WorldRoom +from app.services import chunk_codec +from app.services.game_servers import supervisor +from app.services.world_store import Bounds, RevisionConflict, RevisionNotAvailable, WorldStore + +router = APIRouter() + +DbDep = Annotated[AsyncSession, Depends(get_db)] + +# world_persistence_impl.md §8.1: "Bounds are capped (default 32x32 chunks +# per query) and return 422 beyond that." +MAX_BOUNDS_SPAN = 32 +# §8.1 batch write cap, beyond WORLD_CHUNK_MAX_BATCH chunks: "Capped at ... +# 8 MB." +MAX_BATCH_TOTAL_BYTES = 8 * 1024 * 1024 + + +# ─────────────────────────── auth helpers ─────────────────────────── + +async def _get_world_readable(world_id: int, current_user: User, 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") + if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="World not found") + return world + + +async def _get_world_writable(world_id: int, current_user: User, 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") + if world.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not owner of this world") + return world + + +async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str) -> WorldRoom | None: + """world_persistence_impl.md §4.1: lazily create the main room for + worlds that predate this feature, on first read/write.""" + 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 + + +def _is_live(world_id: int) -> bool: + return any(s["world_id"] == str(world_id) for s in supervisor.debug_state()) + + +def _validation_error_body(exc: chunk_codec.ChunkValidationError) -> dict: + return {"detail": "chunk validation failed", "reason": exc.reason} + + +def _conflict_body(exc: RevisionConflict) -> dict: + body: dict = { + "detail": "revision conflict", + "current_revision": exc.current_revision, + "current_checksum": exc.current_checksum, + } + if exc.coord is not None: + body["coord"] = list(exc.coord) + return body + + +# ─────────────────────────── schemas ─────────────────────────── + +class RoomResponse(BaseModel): + id: int + slug: str + name: str + grid: dict + bounds: dict | None + spawn: dict | None + chunk_count: int + + +class RoomCreate(BaseModel): + slug: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-z0-9_-]+$") + name: str = Field(..., min_length=1, max_length=255) + grid: dict | None = None + bounds: dict | None = None + spawn: dict | None = None + + +# ─────────────────────────── rooms ─────────────────────────── + +@router.get("/{world_id}/rooms", response_model=list[RoomResponse]) +async def list_rooms(world_id: int, current_user: CurrentUser, db: DbDep): + await _get_world_readable(world_id, current_user, 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() + return [ + RoomResponse( + id=r.id, slug=r.slug, name=r.name, grid=r.grid, bounds=r.bounds, spawn=r.spawn, + chunk_count=await store.count_chunks(r.id), + ) + for r in rooms + ] + + +@router.post("/{world_id}/rooms", response_model=RoomResponse, status_code=201) +async def create_room(world_id: int, body: RoomCreate, current_user: CurrentUser, db: DbDep): + await _get_world_writable(world_id, current_user, db) + store = WorldStore(db) + if await store.get_room(world_id, body.slug) is not None: + raise HTTPException(status_code=409, detail="a room with this slug already exists") + room = await store.create_room( + world_id, body.slug, body.name, grid=body.grid, bounds=body.bounds, spawn=body.spawn + ) + await db.commit() + await db.refresh(room) + return RoomResponse( + id=room.id, slug=room.slug, name=room.name, grid=room.grid, + bounds=room.bounds, spawn=room.spawn, chunk_count=0, + ) + + +# ─────────────────────────── chunk index ─────────────────────────── + +@router.get("/{world_id}/rooms/{slug}/chunks") +async def list_chunks( + world_id: int, slug: str, current_user: CurrentUser, 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_readable(world_id, current_user, 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") + + if cx_min is not None and cx_max is not None and (cx_max - cx_min + 1) > MAX_BOUNDS_SPAN: + raise HTTPException(status_code=422, detail=f"cx bounds span exceeds {MAX_BOUNDS_SPAN}") + if cy_min is not None and cy_max is not None and (cy_max - cy_min + 1) > MAX_BOUNDS_SPAN: + raise HTTPException(status_code=422, detail=f"cy bounds span exceeds {MAX_BOUNDS_SPAN}") + + 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) + content_revision = await store.content_revision(world_id) + return { + "room": room.slug, + "content_revision": content_revision, + "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 + ], + } + + +# ─────────────────────────── chunk fetch ─────────────────────────── + +@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}") +async def get_chunk( + world_id: int, slug: str, cx: int, cy: int, cz: int, + current_user: CurrentUser, db: DbDep, + accept: Annotated[str | None, Header()] = None, + if_none_match: Annotated[str | None, Header(alias="If-None-Match")] = None, +): + await _get_world_readable(world_id, current_user, 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") + + etag = f'"{chunk.checksum}"' + headers = {"ETag": etag, "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key} + + if if_none_match is not None and if_none_match.strip('"') == chunk.checksum: + return Response(status_code=304, headers=headers) + + wants_json = ( + accept is not None + and "application/json" in accept + and "application/octet-stream" not in accept + ) + if wants_json: + decoded = chunk_codec.decode_binary(chunk.payload) + body = chunk_codec.to_json_dict(decoded, key=key, chunk_checksum=chunk.checksum) + return JSONResponse(content=body, headers=headers) + + return Response(content=chunk.payload, media_type="application/octet-stream", headers=headers) + + +# ─────────────────────────── chunk write ─────────────────────────── + +async def _read_write_payload(request: Request, x_base_revision: int | None) -> tuple[bytes, int]: + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + body = await request.json() + try: + payload_bytes = base64.b64decode(body["payload_b64"], validate=True) + except (KeyError, binascii.Error, ValueError) as exc: + raise HTTPException(status_code=422, detail=f"invalid payload_b64: {exc}") + base_revision = int(body.get("base_revision", 0)) + return payload_bytes, base_revision + payload_bytes = await request.body() + return payload_bytes, (x_base_revision if x_base_revision is not None else 0) + + +@router.put("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}") +async def put_chunk( + world_id: int, slug: str, cx: int, cy: int, cz: int, + request: Request, current_user: CurrentUser, db: DbDep, + x_base_revision: Annotated[int | None, Header(alias="X-Base-Revision")] = None, +): + await _get_world_writable(world_id, current_user, 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") + + payload_bytes, base_revision = await _read_write_payload(request, x_base_revision) + if len(payload_bytes) > settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"payload exceeds WORLD_CHUNK_MAX_PAYLOAD_BYTES ({settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES})", + ) + + try: + chunk = await store.put_chunk( + room=room, cx=cx, cy=cy, cz=cz, payload_bytes=payload_bytes, + base_revision=base_revision, actor_id=current_user.id, + ) + except chunk_codec.ChunkCodecError as exc: + await db.rollback() + return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)}) + except chunk_codec.ChunkValidationError as exc: + await db.rollback() + return JSONResponse(status_code=422, content=_validation_error_body(exc)) + except RevisionConflict as exc: + # Nothing must stick from a failed write. Returning a JSONResponse + # here (rather than raising) means no exception propagates through + # `get_db`, so this rollback has to be explicit -- see the note on + # the batch endpoint below, where this is load-bearing for + # all-or-nothing semantics. + await db.rollback() + return JSONResponse(status_code=409, content=_conflict_body(exc)) + + await db.commit() + await db.refresh(chunk) + + return { + "key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum, + "payload_len": chunk.payload_len, + "updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None, + "is_mature": chunk.is_mature, "live_session": _is_live(world_id), + } + + +# ─────────────────────────── batch write ─────────────────────────── + +class BatchChunkItem(BaseModel): + cx: int + cy: int + cz: int + base_revision: int + payload_b64: str + + +class BatchPutRequest(BaseModel): + chunks: list[BatchChunkItem] + + +@router.put("/{world_id}/rooms/{slug}/chunks") +async def put_chunks_batch( + world_id: int, slug: str, body: BatchPutRequest, current_user: CurrentUser, db: DbDep, +): + await _get_world_writable(world_id, current_user, 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") + + if len(body.chunks) > settings.WORLD_CHUNK_MAX_BATCH: + raise HTTPException( + status_code=422, + detail=f"batch of {len(body.chunks)} exceeds WORLD_CHUNK_MAX_BATCH ({settings.WORLD_CHUNK_MAX_BATCH})", + ) + + items = [] + total_bytes = 0 + for item in body.chunks: + try: + payload_bytes = base64.b64decode(item.payload_b64, validate=True) + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=422, detail=f"invalid payload_b64 for ({item.cx},{item.cy},{item.cz}): {exc}") + total_bytes += len(payload_bytes) + items.append({ + "cx": item.cx, "cy": item.cy, "cz": item.cz, + "base_revision": item.base_revision, "payload_bytes": payload_bytes, + }) + + if total_bytes > MAX_BATCH_TOTAL_BYTES: + raise HTTPException(status_code=413, detail=f"batch exceeds {MAX_BATCH_TOTAL_BYTES} bytes") + + try: + rows = await store.put_chunks_batch(room=room, items=items, actor_id=current_user.id) + except chunk_codec.ChunkCodecError as exc: + # All-or-nothing (world_persistence_impl.md §8.1): earlier items in + # this batch may already be flushed (but never committed) to this + # transaction. Rolling back here is what actually makes the batch + # atomic -- without it, returning a JSONResponse instead of raising + # means no exception reaches `get_db`, and its own transaction + # handling would otherwise commit those earlier writes anyway. + await db.rollback() + return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)}) + except chunk_codec.ChunkValidationError as exc: + await db.rollback() + return JSONResponse(status_code=422, content=_validation_error_body(exc)) + except RevisionConflict as exc: + await db.rollback() + return JSONResponse(status_code=409, content=_conflict_body(exc)) + + await db.commit() + for row in rows: + await db.refresh(row) + + return { + "live_session": _is_live(world_id), + "chunks": [ + { + "key": r.chunk_key, "revision": r.revision, "checksum": r.checksum, + "payload_len": r.payload_len, + "updated_at": r.updated_at.isoformat() if r.updated_at else None, + "is_mature": r.is_mature, + } + for r in rows + ], + } + + +# ─────────────────────────── delete ─────────────────────────── + +@router.delete("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}", status_code=204) +async def delete_chunk( + world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep, +): + await _get_world_writable(world_id, current_user, 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) + await store.delete_chunk(key, current_user.id) + await db.commit() + return Response(status_code=204) + + +# ─────────────────────────── revisions + rollback ─────────────────────────── + +@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions") +async def chunk_revisions( + world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep, +): + # doc: "(owner)" -- same gate as writes. + await _get_world_writable(world_id, current_user, db) + store = WorldStore(db) + key = chunk_codec.build_key(world_id, slug, cx, cy, cz) + revisions = await store.list_revisions(key) + return [ + { + "revision": r.revision, "action": r.action, "actor_id": r.actor_id, + "checksum": r.checksum, "payload_available": r.payload is not None, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in revisions + ] + + +class RollbackRequest(BaseModel): + revision: int + + +@router.post("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback") +async def rollback_chunk( + world_id: int, slug: str, cx: int, cy: int, cz: int, + body: RollbackRequest, current_user: CurrentUser, db: DbDep, +): + await _get_world_writable(world_id, current_user, db) + store = WorldStore(db) + key = chunk_codec.build_key(world_id, slug, cx, cy, cz) + try: + chunk = await store.rollback_chunk(key, body.revision, current_user.id) + except RevisionNotAvailable as exc: + await db.rollback() + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except chunk_codec.ChunkValidationError as exc: + await db.rollback() + return JSONResponse(status_code=422, content=_validation_error_body(exc)) + + await db.commit() + await db.refresh(chunk) + return { + "key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum, + "payload_len": chunk.payload_len, + "updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None, + "is_mature": chunk.is_mature, "live_session": _is_live(world_id), + } diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py index ad82674..8f1960c 100644 --- a/backend/app/routers/worlds.py +++ b/backend/app/routers/worlds.py @@ -13,6 +13,7 @@ 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() @@ -147,7 +148,12 @@ async def create_world( current_user: CurrentUser, db: DbDep, ): - """Create a new world.""" + """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, @@ -155,6 +161,8 @@ async def create_world( 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 diff --git a/backend/app/services/chunk_codec.py b/backend/app/services/chunk_codec.py new file mode 100644 index 0000000..41437b2 --- /dev/null +++ b/backend/app/services/chunk_codec.py @@ -0,0 +1,670 @@ +"""Python port of the chunk binary/JSON codec, `docs/architecture/world.md` §3. + +This is the server-side half of the "single shared format, two +implementations" contract world.md §5.2 and +`world_persistence_impl.md` §5.5/§9 call for. The Godot-side implementation +is `scripts/chunk_data_decoder.gd`; this module must decode anything it +produces and produce bytes it can decode. + +IMPORTANT DEVIATION (see the "Known cross-codec divergence" note at the +bottom of this file): the current `scripts/chunk_data_decoder.gd` does not +actually implement IEEE-754 float32 for the `pos`/`rot`/`scale`/`bounds` +fields -- it performs a lossy numeric `int`/`float` cast instead of a bit +reinterpretation, which both truncates fractional values and does not +write IEEE-754 bits at all. This module implements the format as +*specified* (real ` None: + self.reason = reason + super().__init__(message or reason) + + +# ───────────────────────── Logical model ───────────────────────── + +@dataclass +class TileDef: + id: int + asset_id: str = "" + solid: bool = False + flags: int = 0 + variants: int = 1 + + +@dataclass +class TileLayer: + name: str + layer_id: int = 0 + width: int = 0 + height: int = 0 + depth: int = 1 + palette: list[TileDef] = field(default_factory=list) + tiles: list[int] = field(default_factory=list) # flattened, row-major + + +@dataclass +class ContentFlags: + violence: int = 0 + sexual: int = 0 + language: int = 0 + substance: int = 0 + gambling: int = 0 + horror: int = 0 + hate: int = 0 + custom: int = 0 + mature: bool = False + sources: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "categories": {name: getattr(self, name) for name in CATEGORY_FIELDS}, + "mature": self.mature, + "sources": list(self.sources), + } + + @classmethod + def from_dict(cls, d: dict) -> "ContentFlags": + cats = d.get("categories", {}) + return cls( + **{name: int(cats.get(name, 0)) for name in CATEGORY_FIELDS}, + mature=bool(d.get("mature", False)), + sources=list(d.get("sources", [])), + ) + + +@dataclass +class WorldObject: + id: str + type: str + asset_id: str + pos: tuple[float, float, float] = (0.0, 0.0, 0.0) + rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) + scale: tuple[float, float, float] = (1.0, 1.0, 1.0) + dynamic: bool = False + properties: dict = field(default_factory=dict) + flags: ContentFlags = field(default_factory=ContentFlags) + + +@dataclass +class ChunkBounds: + """Wire representation is min-corner + size (matching the Godot AABB + the reference encoder builds from), not min/max as world.md §3.1's + logical-model prose describes -- both are fully recoverable from each + other; this is just the on-the-wire convention.""" + + min: tuple[float, float, float] = (0.0, 0.0, 0.0) + size: tuple[float, float, float] = (0.0, 0.0, 0.0) + + @property + def max(self) -> tuple[float, float, float]: + return tuple(a + b for a, b in zip(self.min, self.size)) + + +@dataclass +class ChunkPayload: + version: int = CURRENT_VERSION + world_id: str = "" + room_id: str = "" + coord: tuple[int, int, int] = (0, 0, 0) + bounds: ChunkBounds = field(default_factory=ChunkBounds) + tile_layers: list[TileLayer] = field(default_factory=list) + objects: list[WorldObject] = field(default_factory=list) + flags: ContentFlags = field(default_factory=ContentFlags) + created_at: int = 0 + updated_at: int = 0 + revision: int = 0 + author: str = "" + + +def build_key(world_id: str | int, room_slug: str, cx: int, cy: int, cz: int) -> str: + """world_persistence_impl.md §4.5: ``"{world.id}:{room.slug}:{cx}:{cy}:{cz}"``.""" + return f"{world_id}:{room_slug}:{cx}:{cy}:{cz}" + + +# ───────────────────────── Binary primitives ───────────────────────── +# All integers little-endian, matching world.md §3.6.3. Floats are packed +# as real IEEE-754 float32 -- see the module docstring's deviation note. + +def _read_u16(data: bytes, off: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" int: + return struct.unpack_from(" int: + return struct.unpack_from(" float: + return struct.unpack_from(" bytes: + return struct.pack(" bytes: + return struct.pack(" bytes: + return struct.pack(" bytes: + return struct.pack(" bytes: + return struct.pack(" tuple[str, int]: + """Length-prefixed UTF-8 string. Returns (value, next_offset).""" + n = _read_u16(data, off) + off += 2 + s = data[off:off + n].decode("utf-8") + return s, off + n + + +def _w_str(s: str) -> bytes: + b = s.encode("utf-8") + if len(b) > 0xFFFF: + raise ChunkCodecError(f"string field too long to encode ({len(b)} bytes)") + return _w_u16(len(b)) + b + + +def checksum(payload_body: bytes) -> str: + """SHA-256 hex digest of the payload body (world.md §3.6.3).""" + return hashlib.sha256(payload_body).hexdigest() + + +# ───────────────────────── Section codecs ───────────────────────── + +def _encode_metadata(payload: ChunkPayload) -> bytes: + out = bytearray() + out += _w_str(payload.world_id) + out += _w_str(payload.room_id) + out += _w_i32(payload.coord[0]) + out += _w_i32(payload.coord[1]) + out += _w_i32(payload.coord[2]) + out += _w_f32(payload.bounds.min[0]) + out += _w_f32(payload.bounds.min[1]) + out += _w_f32(payload.bounds.min[2]) + out += _w_f32(payload.bounds.size[0]) + out += _w_f32(payload.bounds.size[1]) + out += _w_f32(payload.bounds.size[2]) + return bytes(out) + + +def _decode_metadata(data: bytes, result: ChunkPayload) -> None: + off = 0 + result.world_id, off = _read_str(data, off) + result.room_id, off = _read_str(data, off) + coord = (_read_i32(data, off), _read_i32(data, off + 4), _read_i32(data, off + 8)) + off += 12 + min_pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8)) + size = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20)) + result.coord = coord + result.bounds = ChunkBounds(min=min_pos, size=size) + + +def _encode_tile_layer(layer: TileLayer) -> bytes: + out = bytearray() + out += _w_str(layer.name) + out += _w_u16(layer.layer_id) + out += _w_u16(layer.width) + out += _w_u16(layer.height) + out += _w_u16(layer.depth) + out += _w_u16(len(layer.palette)) + for td in layer.palette: + out += _w_u16(td.id) + out += _w_str(td.asset_id) + out += bytes([1 if td.solid else 0]) + out += _w_u32(td.flags) + out += bytes([td.variants & 0xFF]) + out += _w_u32(len(layer.tiles)) + for tile_id in layer.tiles: + out += _w_u16(tile_id) + return bytes(out) + + +def _decode_tile_layer(data: bytes) -> TileLayer: + off = 0 + name, off = _read_str(data, off) + layer_id = _read_u16(data, off); off += 2 + width = _read_u16(data, off); off += 2 + height = _read_u16(data, off); off += 2 + depth = _read_u16(data, off); off += 2 + palette_count = _read_u16(data, off); off += 2 + palette: list[TileDef] = [] + for _ in range(palette_count): + tid = _read_u16(data, off); off += 2 + asset_id, off = _read_str(data, off) + solid = data[off] != 0; off += 1 + flags = _read_u32(data, off); off += 4 + variants = data[off]; off += 1 + palette.append(TileDef(id=tid, asset_id=asset_id, solid=solid, flags=flags, variants=variants)) + tile_count = _read_u32(data, off); off += 4 + tiles: list[int] = [] + for _ in range(tile_count): + tiles.append(_read_u16(data, off)) + off += 2 + return TileLayer(name=name, layer_id=layer_id, width=width, height=height, + depth=depth, palette=palette, tiles=tiles) + + +def _encode_flags(flags: ContentFlags) -> bytes: + out = bytearray() + for name in CATEGORY_FIELDS: + out.append(getattr(flags, name) & 0xFF) + out.append(1 if flags.mature else 0) + out += _w_u16(len(flags.sources)) + for src in flags.sources: + out += _w_str(src) + return bytes(out) + + +def _decode_flags(data: bytes) -> ContentFlags: + off = 0 + values = {} + for name in CATEGORY_FIELDS: + values[name] = data[off] + off += 1 + mature = data[off] != 0 + off += 1 + source_count = _read_u16(data, off) + off += 2 + sources = [] + for _ in range(source_count): + s, off = _read_str(data, off) + sources.append(s) + return ContentFlags(**values, mature=mature, sources=sources) + + +def _encode_object(obj: WorldObject) -> bytes: + out = bytearray() + out += _w_str(obj.id) + out += _w_str(obj.type) + out += _w_str(obj.asset_id) + for v in obj.pos: + out += _w_f32(v) + for v in obj.rot: + out += _w_f32(v) + for v in obj.scale: + out += _w_f32(v) + out.append(1 if obj.dynamic else 0) + # Canonical JSON: keys sorted lexicographically (world.md §3.6.3). + props_json = json.dumps(obj.properties, sort_keys=True, separators=(",", ":")) + out += _w_str(props_json) + out += _encode_flags(obj.flags) + return bytes(out) + + +def _decode_object(data: bytes) -> WorldObject: + off = 0 + oid, off = _read_str(data, off) + otype, off = _read_str(data, off) + asset_id, off = _read_str(data, off) + pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8)) + off += 12 + rot = ( + _read_f32(data, off), _read_f32(data, off + 4), + _read_f32(data, off + 8), _read_f32(data, off + 12), + ) + off += 16 + scale = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8)) + off += 12 + dynamic = data[off] != 0 + off += 1 + props_str, off = _read_str(data, off) + try: + properties = json.loads(props_str) if props_str else {} + except json.JSONDecodeError as exc: + raise ChunkCodecError(f"object {oid!r}: invalid properties JSON") from exc + obj_flags = _decode_flags(data[off:]) + return WorldObject(id=oid, type=otype, asset_id=asset_id, pos=pos, rot=rot, + scale=scale, dynamic=dynamic, properties=properties, flags=obj_flags) + + +def _encode_revision_meta(payload: ChunkPayload) -> bytes: + out = bytearray() + out += _w_i64(payload.created_at) + out += _w_i64(payload.updated_at) + out += _w_u32(payload.revision) + out += _w_str(payload.author) + return bytes(out) + + +def _decode_revision_meta(data: bytes, result: ChunkPayload) -> None: + off = 0 + result.created_at = _read_i64(data, off); off += 8 + result.updated_at = _read_i64(data, off); off += 8 + result.revision = _read_u32(data, off); off += 4 + result.author, off = _read_str(data, off) + + +def _wrap_section(section_id: int, data: bytes) -> bytes: + return bytes([section_id]) + _w_u32(len(data)) + data + + +# ───────────────────────── Top-level encode/decode ───────────────────────── + +def encode_binary(payload: ChunkPayload) -> bytes: + """Encode a ChunkPayload to the world.md §3.6 wire format.""" + sections = bytearray() + sections += _wrap_section(SECTION_META, _encode_metadata(payload)) + for layer in payload.tile_layers: + sections += _wrap_section(SECTION_TILE, _encode_tile_layer(layer)) + for obj in payload.objects: + sections += _wrap_section(SECTION_OBJECT, _encode_object(obj)) + sections += _wrap_section(SECTION_FLAGS, _encode_flags(payload.flags)) + sections += _wrap_section(SECTION_REVISION_META, _encode_revision_meta(payload)) + + payload_body = bytes(sections) + digest = hashlib.sha256(payload_body).digest() # 32 raw bytes + + header = bytearray() + header += MAGIC + header += _w_u16(CURRENT_VERSION) + header += _w_u16(HEADER_SIZE - 8) # header_len: bytes of header after this field (spec-correct) + header += _w_u32(0) # flags (reserved) + header += _w_u32(len(payload_body)) + header += digest + + return bytes(header) + payload_body + + +def decode_binary(data: bytes) -> ChunkPayload: + """Decode a world.md §3.6 binary chunk. Raises ChunkCodecError on + structurally malformed input (short buffer, bad magic, unsupported + version, truncated payload).""" + if len(data) < HEADER_SIZE: + raise ChunkCodecError(f"data too short for header ({len(data)} bytes)") + + if data[0:4] != MAGIC: + raise ChunkCodecError("bad magic bytes") + + version = _read_u16(data, 4) + if version < 1 or version > CURRENT_VERSION: + raise ChunkCodecError(f"unsupported chunk format version {version}") + + # header_len (offset 6) is intentionally not used for offset math -- + # both this decoder and the Godot one always slice at the fixed + # HEADER_SIZE, matching the reference implementation's actual (if + # spec-inconsistent) behaviour. + payload_len = _read_u32(data, 12) + # bytes 16:48 are the header's advisory checksum -- decode_and_validate + # re-reads it directly and recomputes against the parsed payload body. + + if len(data) - HEADER_SIZE != payload_len: + raise ChunkCodecError( + f"declared payload_len ({payload_len}) does not match actual " + f"remaining bytes ({len(data) - HEADER_SIZE})" + ) + + payload_body = data[HEADER_SIZE:HEADER_SIZE + payload_len] + + result = ChunkPayload(version=version) + + offset = 0 + while offset < len(payload_body): + if offset + 5 > len(payload_body): + raise ChunkCodecError("truncated section header") + section_id = payload_body[offset] + section_len = _read_u32(payload_body, offset + 1) + data_start = offset + 5 + data_end = data_start + section_len + if data_end > len(payload_body): + raise ChunkCodecError("truncated section body") + section_data = payload_body[data_start:data_end] + + if section_id == SECTION_META: + _decode_metadata(section_data, result) + elif section_id == SECTION_TILE: + result.tile_layers.append(_decode_tile_layer(section_data)) + elif section_id == SECTION_OBJECT: + result.objects.append(_decode_object(section_data)) + elif section_id == SECTION_FLAGS: + result.flags = _decode_flags(section_data) + elif section_id == SECTION_REVISION_META: + _decode_revision_meta(section_data, result) + # Unknown section ids are skipped (forward-compat), matching the + # Godot decoder's match-with-no-default behaviour. + + offset = data_end + + return result + + +# ───────────────────────── JSON encoding (world.md §3.5) ───────────────────────── +# Derived, never stored (world_persistence_impl.md §8.1) -- produced here +# from a decoded ChunkPayload for the ``Accept: application/json`` path. + +def to_json_dict(payload: ChunkPayload, *, key: str, chunk_checksum: str) -> dict: + return { + "version": payload.version, + "key": key, + "world_id": payload.world_id, + "room_id": payload.room_id, + "coord": {"cx": payload.coord[0], "cy": payload.coord[1], "cz": payload.coord[2]}, + "bounds": { + "min_x": payload.bounds.min[0], "min_y": payload.bounds.min[1], "min_z": payload.bounds.min[2], + "max_x": payload.bounds.max[0], "max_y": payload.bounds.max[1], "max_z": payload.bounds.max[2], + }, + "tiles": [ + { + "name": layer.name, + "layer_id": layer.layer_id, + "width": layer.width, + "height": layer.height, + "depth": layer.depth, + "palette": [ + {"id": td.id, "asset_id": td.asset_id, "solid": td.solid, + "flags": td.flags, "variants": td.variants} + for td in layer.palette + ], + "tiles": list(layer.tiles), + } + for layer in payload.tile_layers + ], + "objects": [ + { + "id": obj.id, "type": obj.type, "asset_id": obj.asset_id, + "transform": { + "pos": list(obj.pos), "rot": list(obj.rot), "scale": list(obj.scale), + }, + "properties": obj.properties, + "dynamic": obj.dynamic, + "flags": obj.flags.to_dict(), + } + for obj in payload.objects + ], + "flags": payload.flags.to_dict(), + "meta": { + "created_at": payload.created_at, "updated_at": payload.updated_at, + "revision": payload.revision, "author": payload.author, + }, + "checksum": chunk_checksum, + } + + +# ───────────────────────── Validation (world_persistence_impl.md §5.5) ───────────────────────── + +# Not precisely quantified by the design doc beyond "length limits" -- +# these are this implementation's conservative defaults (world.md §7). +MAX_STRING_FIELD_LEN = 256 +MAX_PROPERTIES_JSON_LEN = 16_384 +MAX_SOURCES_PER_FLAGSET = 32 + + +def _check_string_lengths(obj: WorldObject) -> None: + for field_name, value in (("id", obj.id), ("type", obj.type), ("asset_id", obj.asset_id)): + if len(value) > MAX_STRING_FIELD_LEN: + raise ChunkValidationError( + "string_field_too_long", f"object.{field_name} exceeds {MAX_STRING_FIELD_LEN} chars" + ) + props_json = json.dumps(obj.properties) + if len(props_json) > MAX_PROPERTIES_JSON_LEN: + raise ChunkValidationError( + "string_field_too_long", f"object {obj.id!r} properties exceed {MAX_PROPERTIES_JSON_LEN} bytes" + ) + if len(obj.flags.sources) > MAX_SOURCES_PER_FLAGSET: + raise ChunkValidationError("string_field_too_long", f"object {obj.id!r} has too many flag sources") + + +def decode_and_validate( + data: bytes, + *, + expected_key: str, + grid_size: tuple[int, int, int], + max_objects: int, +) -> ChunkPayload: + """Decode + fully validate a client-submitted chunk payload. + + This is the boundary world_persistence_impl.md §5.5 requires: "the + backend must not trust the client". Raises ``ChunkCodecError`` for + structurally malformed bytes (mapped to a 422 by the router with a + generic reason) or ``ChunkValidationError`` with a specific ``reason`` + code for a well-formed-but-invalid payload. + """ + payload = decode_binary(data) # raises ChunkCodecError for structural problems first + + # Checksum is advisory in the header (world.md §7); the value that + # matters is recomputed from the actual payload body we just parsed. + payload_body = data[HEADER_SIZE:] + recomputed = checksum(payload_body) + header_checksum_hex = data[16:48].hex() + if header_checksum_hex != recomputed: + raise ChunkValidationError( + "checksum_mismatch", + "header checksum does not match the recomputed SHA-256 of the payload body", + ) + + actual_key = build_key(payload.world_id, payload.room_id, *payload.coord) + if actual_key != expected_key: + raise ChunkValidationError( + "chunk_key_mismatch", + f"payload key {actual_key!r} does not match URL {expected_key!r}", + ) + + for layer in payload.tile_layers: + if (layer.width, layer.height, layer.depth) != tuple(grid_size): + raise ChunkValidationError( + "layer_dims", + f"tile layer {layer.name!r} dims {(layer.width, layer.height, layer.depth)} " + f"!= room grid {tuple(grid_size)}", + ) + valid_ids = {0} | {td.id for td in layer.palette} + for tile_id in layer.tiles: + if tile_id not in valid_ids: + raise ChunkValidationError( + "tile_out_of_palette", + f"tile layer {layer.name!r} references palette id {tile_id} not in its palette", + ) + if len(layer.tiles) != layer.width * layer.height * layer.depth: + raise ChunkValidationError( + "layer_dims", + f"tile layer {layer.name!r} has {len(layer.tiles)} cells, expected " + f"{layer.width * layer.height * layer.depth}", + ) + + if len(payload.objects) > max_objects: + raise ChunkValidationError( + "too_many_objects", f"{len(payload.objects)} objects exceeds max {max_objects}" + ) + + bmin, bmax = payload.bounds.min, payload.bounds.max + for obj in payload.objects: + _check_string_lengths(obj) + if not all(bmin[i] <= obj.pos[i] <= bmax[i] for i in range(3)): + raise ChunkValidationError( + "object_out_of_bounds", + f"object {obj.id!r} position {obj.pos} outside chunk bounds " + f"[{bmin}, {bmax}]", + ) + + if len(payload.author) > MAX_STRING_FIELD_LEN: + raise ChunkValidationError("string_field_too_long", "meta.author too long") + + return payload + + +# ───────────────────────── Known cross-codec divergence ───────────────────────── +# +# `scripts/chunk_data_decoder.gd`'s `_read_float32`/`_write_float32` do: +# +# static func _write_float32(val: float) -> PackedByteArray: +# var bits := val as int +# return _write_uint32(bits) +# +# static func _read_float32(data: PackedByteArray, offset: int) -> float: +# var bits := _read_uint32(data, offset) +# return bits as float +# +# `as` between int and float in GDScript is a numeric conversion, not a bit +# reinterpretation (verified empirically against Godot 4.6.2 headless: +# `1.5 as int` -> `1`, `1 as float` -> `1.0`; the IEEE-754 bit pattern of +# 1.5f is 0x3FC00000 = 1069547520, not 1). So the shipped client codec +# truncates every float32 field (bounds, object pos/rot/scale) to its +# integer part and stores that integer as a raw int32 -- not as float32 +# bits at all. This is a real bug in already-merged, client-side code +# (scripts/ is out of scope for this task) that breaks the "byte-stable +# across two implementations" guarantee world.md §3.6.3 states. +# +# This module implements the *specified* format (real IEEE-754 float32) +# rather than replicating that bug, because world.md §3.6 is normative and +# says "float32" for these fields, and mirroring the bug would mean the +# backend also corrupts every non-integer transform/bounds value it +# stores. Practical effect until the client-side bug is fixed: payloads +# with integer-only floats will decode identically on both sides; any +# payload with a fractional position/rotation/scale/bounds value will not +# be byte-identical (or numerically identical) between this module and the +# current `scripts/chunk_data_decoder.gd`. This is flagged for the +# client/editor follow-on task, not fixed here. diff --git a/backend/app/services/game_servers.py b/backend/app/services/game_servers.py index 542c9d3..c9af201 100644 --- a/backend/app/services/game_servers.py +++ b/backend/app/services/game_servers.py @@ -13,15 +13,26 @@ project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``); the backend spawns them as subprocesses. Design notes / limits (PC3): - - All worlds currently load the same test level (per-world content is M2/M7). - ``world_id`` still scopes a distinct server process + session. - Port allocation is a linear scan of the configured range. - No crash-restart: if a server process dies, the next join respawns it. + With world content now durable in Postgres (world_persistence_impl.md + D2), this is genuinely safe rather than merely tolerated: the sim + process holds no unsaved world content, so a reap/crash/kill can never + lose world data. + +Per-spawn auth token (world_persistence_impl.md §7.3): each spawned server +gets a random ``secrets.token_urlsafe(32)`` on its command line, which it +uses as a Bearer token against ``/api/internal`` to pull its world's +content read-only before binding its ENet port. The token grants access to +exactly one world and dies with its process (the reaper's +``del self._servers[wid]`` invalidates it; see ``validate_token``). """ from __future__ import annotations import asyncio +import hmac import json +import secrets import socket import subprocess import tempfile @@ -38,6 +49,7 @@ class _WorldServer: port: int proc: subprocess.Popen status_path: Path + token: str started_at: float = field(default_factory=time.time) last_join: float = field(default_factory=time.time) @@ -50,6 +62,9 @@ class _WorldServer: def player_count(self) -> int: return int(self.status().get("players", 0)) + def is_ready(self) -> bool: + return self.status().get("ready") is True + def alive(self) -> bool: return self.proc.poll() is None @@ -104,16 +119,35 @@ class GameServerSupervisor: if srv is None: srv = self._spawn(world_id) self._servers[world_id] = srv - # Give the engine a moment to bind the ENet port before we - # hand the address to a client. - await self._await_port(srv.port) + try: + await self._await_ready(srv) + except TimeoutError: + # Real failure now, not a silent "hope it's fine" -- + # see _await_ready's docstring. Don't leave a possibly + # half-started process registered. + srv.stop() + del self._servers[world_id] + raise RuntimeError( + f"dedicated server for world {world_id} did not become ready in time" + ) srv.last_join = time.time() return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id} + def validate_token(self, world_id: str, token: str) -> bool: + """`require_game_server` dependency support (world_persistence_impl.md + §7.3): true iff *token* is the live token for *world_id*'s server. + A dict lookup, as the doc specifies, plus a constant-time compare + since it's a bearer credential.""" + srv = self._servers.get(str(world_id)) + if srv is None or not srv.alive(): + return False + return hmac.compare_digest(srv.token, token) + # ── internals ──────────────────────────────────────────────────────── def _spawn(self, world_id: str) -> _WorldServer: port = self._free_port() + token = secrets.token_urlsafe(32) status_path = self._runtime_dir / f"world_{world_id}_{port}.json" status_path.unlink(missing_ok=True) cmd = [ @@ -123,9 +157,13 @@ class GameServerSupervisor: "--port", str(port), "--world", str(world_id), "--status-file", str(status_path), + "--api-url", settings.GAME_SERVER_INTERNAL_API_URL, + "--server-token", token, ] proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return _WorldServer(world_id=world_id, port=port, proc=proc, status_path=status_path) + return _WorldServer( + world_id=world_id, port=port, proc=proc, status_path=status_path, token=token + ) def _free_port(self) -> int: used = {s.port for s in self._servers.values()} @@ -140,16 +178,35 @@ class GameServerSupervisor: continue raise RuntimeError("no free game-server port in the configured range") - async def _await_port(self, port: int, timeout: float = 8.0) -> None: - deadline = time.time() + timeout + async def _await_ready(self, srv: _WorldServer) -> None: + """Wait for a ``"ready": true`` field in the status file + (world_persistence_impl.md §7.4). + + This replaces the old ``_await_port``, which had two real bugs: + it treated a bound UDP port as "ready", but with content loading + the port binds *before* the server has fetched its world's rooms + and chunks (world_persistence_impl.md §7.2 step 4) -- so a client + could get handed an address for a server still mid-fetch. And it + *failed open*: past its 8s deadline it simply returned, with no + error, so ``ensure_server`` reported success for a server that may + never have started at all. This raises ``TimeoutError`` instead, + which ``ensure_server`` turns into a real 503 for the joining + client, and also bails out early (same exception) if the process + has already exited. + """ + deadline = time.time() + settings.GAME_SERVER_READY_TIMEOUT_SECONDS while time.time() < deadline: - # ENet is UDP; a bind failure means something (our server) has it. - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - try: - s.bind(("0.0.0.0", port)) - except OSError: - return + if srv.is_ready(): + return + if not srv.alive(): + raise TimeoutError( + f"server process for world {srv.world_id} exited before becoming ready" + ) await asyncio.sleep(0.25) + raise TimeoutError( + f"server for world {srv.world_id} did not report ready within " + f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s" + ) async def _reap_loop(self) -> None: try: diff --git a/backend/app/services/world_store.py b/backend/app/services/world_store.py new file mode 100644 index 0000000..144e26a --- /dev/null +++ b/backend/app/services/world_store.py @@ -0,0 +1,431 @@ +"""World content storage service. + +Implements the `world.md` §5.1 service interface over Postgres, per +`world_persistence_impl.md` §9 -- **minus** ``flush_dirty`` and +``rebuild_index``, which don't apply once there is no write-behind dirty +set and no derived index to rebuild (see that doc's §3.3 and §4.2). + +This is the *only* place chunk content is written (world_persistence_impl.md +D2 / §5). Callers (the routers) own transaction boundaries: methods here +``flush()`` so later statements in the same request see earlier ones and so +integrity errors surface before a caller commits, but they never +``commit()`` -- that mirrors the existing convention in +``app/routers/worlds.py`` (``get_db`` docs: "Handlers are responsible for +committing or rolling back their own transactions"). + +Known gap this module had to resolve on its own (flagged, not silently +decided): `world_persistence_impl.md` §6/§7.2/§8.1 all reference a world's +monotonic ``content_revision`` ("bumped on every chunk write"), but no +column for it is defined anywhere in §4's schema. Rather than add an +undocumented column, ``content_revision()`` below derives it as +``MAX(id)`` over ``world_chunk_revisions`` scoped to the world: that table +is append-only (retention only nulls ``payload``, it never deletes rows), +so the value is genuinely monotonic and increments on every +create/update/delete/rollback. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import load_only + +from app.config import settings +from app.models.world_content import ( + MAIN_ROOM_NAME, + MAIN_ROOM_SLUG, + WorldChunk, + WorldChunkRevision, + WorldRoom, +) +from app.services import chunk_codec + + +class RevisionConflict(Exception): + """Optimistic-concurrency conflict (world.md §5.4). ``current_revision`` + is 0 and ``current_checksum`` is None when the caller's ``base_revision`` + assumed a chunk exists but it doesn't.""" + + def __init__( + self, + current_revision: int, + current_checksum: str | None, + coord: tuple[int, int, int] | None = None, + ) -> None: + self.current_revision = current_revision + self.current_checksum = current_checksum + self.coord = coord + super().__init__(f"revision conflict: current_revision={current_revision}") + + +class RevisionNotAvailable(Exception): + """Raised by ``rollback_chunk`` when the requested revision doesn't + exist, or exists but its payload was pruned by the retention policy + (world_persistence_impl.md §4.3).""" + + +@dataclass +class Bounds: + cx_min: int | None = None + cx_max: int | None = None + cy_min: int | None = None + cy_max: int | None = None + cz: int | None = None + + +class WorldStore: + """Owns chunk/room read/write operations over the async session + handed to it. One instance per request is the expected usage + (``WorldStore(db)`` where ``db`` is the ``DbDep`` session).""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + # ── rooms ──────────────────────────────────────────────────────────── + + async def get_room(self, world_id: int, slug: str) -> WorldRoom | None: + result = await self.db.execute( + select(WorldRoom).where(WorldRoom.world_id == world_id, WorldRoom.slug == slug) + ) + return result.scalar_one_or_none() + + async def list_rooms(self, world_id: int) -> list[WorldRoom]: + result = await self.db.execute( + select(WorldRoom).where(WorldRoom.world_id == world_id).order_by(WorldRoom.id) + ) + return list(result.scalars().all()) + + async def create_room( + self, + world_id: int, + slug: str, + name: str, + *, + grid: dict | None = None, + bounds: dict | None = None, + spawn: dict | None = None, + ) -> WorldRoom: + room = WorldRoom(world_id=world_id, slug=slug, name=name) + if grid is not None: + room.grid = grid + if bounds is not None: + room.bounds = bounds + if spawn is not None: + room.spawn = spawn + self.db.add(room) + await self.db.flush() + await self.db.refresh(room) + return room + + async def get_or_create_main_room(self, world_id: int) -> WorldRoom: + """world_persistence_impl.md §4.1: every world gets a ``main`` room. + Called from ``create_world`` for new worlds, and lazily here for + worlds that predate this migration.""" + room = await self.get_room(world_id, MAIN_ROOM_SLUG) + if room is not None: + return room + try: + return await self.create_room(world_id, MAIN_ROOM_SLUG, MAIN_ROOM_NAME) + except IntegrityError: + # Lost a race with a concurrent creator; the row now exists. + await self.db.rollback() + room = await self.get_room(world_id, MAIN_ROOM_SLUG) + if room is None: + raise + return room + + async def count_chunks(self, room_id: int) -> int: + result = await self.db.execute( + select(func.count()).select_from(WorldChunk).where(WorldChunk.room_id == room_id) + ) + return int(result.scalar_one()) + + async def content_revision(self, world_id: int) -> int: + """See module docstring for why this is derived rather than stored.""" + result = await self.db.execute( + select(func.max(WorldChunkRevision.id)).where(WorldChunkRevision.world_id == world_id) + ) + return int(result.scalar() or 0) + + # ── chunk reads ────────────────────────────────────────────────────── + + async def get_chunk(self, key: str) -> WorldChunk | None: + result = await self.db.execute(select(WorldChunk).where(WorldChunk.chunk_key == key)) + return result.scalar_one_or_none() + + async def get_chunk_by_coord(self, room_id: int, cx: int, cy: int, cz: int) -> WorldChunk | None: + result = await self.db.execute( + select(WorldChunk).where( + WorldChunk.room_id == room_id, WorldChunk.cx == cx, + WorldChunk.cy == cy, WorldChunk.cz == cz, + ) + ) + return result.scalar_one_or_none() + + async def get_chunk_binary(self, key: str) -> bytes | None: + chunk = await self.get_chunk(key) + return chunk.payload if chunk is not None else None + + async def get_chunk_flags(self, key: str) -> dict | None: + chunk = await self.get_chunk(key) + return chunk.content_flags if chunk is not None else None + + async def list_chunks( + self, + world_id: int, + room_id: int | None = None, + bounds: Bounds | None = None, + ) -> list[WorldChunk]: + """Index query -- world.md §5.1's ``list_chunks``. Payload bytes are + deliberately not loaded (world_persistence_impl.md §8.1: "Payloads + are not included").""" + query = select(WorldChunk).options( + load_only( + WorldChunk.id, WorldChunk.world_id, WorldChunk.room_id, WorldChunk.chunk_key, + WorldChunk.cx, WorldChunk.cy, WorldChunk.cz, WorldChunk.revision, + WorldChunk.checksum, WorldChunk.payload_len, WorldChunk.is_mature, + WorldChunk.updated_at, + ) + ).where(WorldChunk.world_id == world_id) + if room_id is not None: + query = query.where(WorldChunk.room_id == room_id) + if bounds is not None: + if bounds.cx_min is not None: + query = query.where(WorldChunk.cx >= bounds.cx_min) + if bounds.cx_max is not None: + query = query.where(WorldChunk.cx <= bounds.cx_max) + if bounds.cy_min is not None: + query = query.where(WorldChunk.cy >= bounds.cy_min) + if bounds.cy_max is not None: + query = query.where(WorldChunk.cy <= bounds.cy_max) + if bounds.cz is not None: + query = query.where(WorldChunk.cz == bounds.cz) + query = query.order_by(WorldChunk.cx, WorldChunk.cy, WorldChunk.cz) + result = await self.db.execute(query) + return list(result.scalars().all()) + + # ── chunk writes ───────────────────────────────────────────────────── + + async def put_chunk( + self, + *, + room: WorldRoom, + cx: int, + cy: int, + cz: int, + payload_bytes: bytes, + base_revision: int, + actor_id: int | None, + ) -> WorldChunk: + """Full-chunk write with optimistic concurrency (world.md §5.4, + world_persistence_impl.md §5.4/§8.2). Validates the payload + server-side (§5.5) before ever touching a row. ``base_revision=0`` + means "create; 409 if it already exists".""" + key = chunk_codec.build_key(room.world_id, room.slug, cx, cy, cz) + grid_size = tuple(room.grid.get("size", [32, 32, 1])) + + # Raises ChunkCodecError / ChunkValidationError -- the router maps + # those to 422. Nothing below trusts the request body's own claims + # about content_flags/is_mature; both come from `decoded`. + decoded = chunk_codec.decode_and_validate( + payload_bytes, + expected_key=key, + grid_size=grid_size, + max_objects=settings.WORLD_CHUNK_MAX_OBJECTS, + ) + body_checksum = chunk_codec.checksum(payload_bytes[chunk_codec.HEADER_SIZE:]) + + result = await self.db.execute( + select(WorldChunk) + .where( + WorldChunk.room_id == room.id, WorldChunk.cx == cx, + WorldChunk.cy == cy, WorldChunk.cz == cz, + ) + .with_for_update() + ) + row = result.scalar_one_or_none() + + if base_revision == 0: + if row is not None: + raise RevisionConflict(row.revision, row.checksum, (cx, cy, cz)) + row = WorldChunk( + world_id=room.world_id, room_id=room.id, chunk_key=key, + cx=cx, cy=cy, cz=cz, revision=1, + ) + self.db.add(row) + action = "create" + else: + if row is None or row.revision != base_revision: + raise RevisionConflict( + row.revision if row else 0, row.checksum if row else None, (cx, cy, cz) + ) + row.revision = row.revision + 1 + action = "update" + + row.format_version = decoded.version + row.payload = payload_bytes + row.payload_len = len(payload_bytes) + row.checksum = body_checksum + row.content_flags = decoded.flags.to_dict() + row.is_mature = decoded.flags.mature + row.updated_by_id = actor_id + + try: + await self.db.flush() + except IntegrityError: + # Lost a create race to a concurrent writer between our SELECT + # and this flush. Roll back and report the real current state. + await self.db.rollback() + existing = await self.get_chunk_by_coord(room.id, cx, cy, cz) + raise RevisionConflict( + existing.revision if existing else 0, + existing.checksum if existing else None, + (cx, cy, cz), + ) + + await self._record_revision(key, room.world_id, row.revision, action, actor_id, body_checksum, payload_bytes) + await self.db.refresh(row) + return row + + async def put_chunks_batch( + self, + *, + room: WorldRoom, + items: list[dict], + actor_id: int | None, + ) -> list[WorldChunk]: + """Batch write (world_persistence_impl.md §8.1). One transaction, + all-or-nothing: none of this ``commit()``s, so a ``RevisionConflict`` + raised partway leaves nothing persisted once the caller's request + handler lets the exception (or the HTTPException it becomes) + propagate through ``get_db``, which rolls back on any exception.""" + rows: list[WorldChunk] = [] + for item in items: + row = await self.put_chunk( + room=room, cx=item["cx"], cy=item["cy"], cz=item["cz"], + payload_bytes=item["payload_bytes"], base_revision=item["base_revision"], + actor_id=actor_id, + ) + rows.append(row) + return rows + + async def delete_chunk(self, key: str, actor_id: int | None) -> None: + row = await self.get_chunk(key) + if row is None: + return + await self._record_revision(key, row.world_id, row.revision, "delete", actor_id, row.checksum, None) + await self.db.delete(row) + await self.db.flush() + + async def rollback_chunk(self, key: str, revision: int, actor_id: int | None) -> WorldChunk: + """Restore a chunk to a previously-recorded revision's payload + (world.md §5.1). Raises ``RevisionNotAvailable`` if that revision + was never recorded, its payload was pruned by retention, or the + chunk has since been deleted outright (nothing to update in place).""" + rev_result = await self.db.execute( + select(WorldChunkRevision) + .where(WorldChunkRevision.chunk_key == key, WorldChunkRevision.revision == revision) + .order_by(WorldChunkRevision.id.desc()) + .limit(1) + ) + rev_row = rev_result.scalar_one_or_none() + if rev_row is None: + raise RevisionNotAvailable(f"no revision {revision} recorded for {key!r}") + if rev_row.payload is None: + raise RevisionNotAvailable(f"revision {revision} of {key!r} was pruned; payload unavailable") + + chunk = await self.get_chunk(key) + if chunk is None: + raise RevisionNotAvailable(f"chunk {key!r} no longer exists; cannot roll back in place") + + room_result = await self.db.execute(select(WorldRoom).where(WorldRoom.id == chunk.room_id)) + room = room_result.scalar_one() + grid_size = tuple(room.grid.get("size", [32, 32, 1])) + decoded = chunk_codec.decode_and_validate( + rev_row.payload, expected_key=key, grid_size=grid_size, + max_objects=settings.WORLD_CHUNK_MAX_OBJECTS, + ) + body_checksum = chunk_codec.checksum(rev_row.payload[chunk_codec.HEADER_SIZE:]) + + chunk.revision = chunk.revision + 1 + chunk.payload = rev_row.payload + chunk.payload_len = len(rev_row.payload) + chunk.checksum = body_checksum + chunk.format_version = decoded.version + chunk.content_flags = decoded.flags.to_dict() + chunk.is_mature = decoded.flags.mature + chunk.updated_by_id = actor_id + + await self.db.flush() + # Tagged "update" (not a fourth action value) to keep the + # documented 'create'|'update'|'delete' action enum intact. + await self._record_revision( + key, chunk.world_id, chunk.revision, "update", actor_id, body_checksum, rev_row.payload + ) + await self.db.refresh(chunk) + return chunk + + async def list_revisions(self, chunk_key: str, limit: int = 50) -> list[WorldChunkRevision]: + """Not in world.md §5.1's interface by name, but required to + implement the revision-history endpoint world_persistence_impl.md + §8.1 lists (``GET .../revisions``).""" + result = await self.db.execute( + select(WorldChunkRevision) + .where(WorldChunkRevision.chunk_key == chunk_key) + .order_by(WorldChunkRevision.revision.desc(), WorldChunkRevision.id.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + async def migrate(self) -> None: + """No-op. `world.md` §4.8's hand-rolled SQLite migration runner is + superseded by Alembic (`backend/alembic/`) now that content lives + in Postgres (world_persistence_impl.md §3.3). Kept only so this + class's shape still matches the §5.1 interface it's implementing + a subset of.""" + return None + + # ── internals ──────────────────────────────────────────────────────── + + async def _record_revision( + self, + chunk_key: str, + world_id: int, + revision: int, + action: str, + actor_id: int | None, + checksum_hex: str, + payload_bytes: bytes | None, + ) -> None: + """Append an audit row and enforce the retention policy + (world_persistence_impl.md §4.3, required): keep the newest + ``WORLD_CHUNK_REVISION_KEEP`` payloads per ``chunk_key``, null out + ``payload`` on older rows, in the same transaction as the write.""" + rev = WorldChunkRevision( + chunk_key=chunk_key, world_id=world_id, revision=revision, action=action, + actor_id=actor_id, checksum=checksum_hex, payload=payload_bytes, + ) + self.db.add(rev) + await self.db.flush() + + keep = settings.WORLD_CHUNK_REVISION_KEEP + keep_ids_result = await self.db.execute( + select(WorldChunkRevision.id) + .where(WorldChunkRevision.chunk_key == chunk_key) + .order_by(WorldChunkRevision.id.desc()) + .limit(keep) + ) + keep_ids = [row[0] for row in keep_ids_result.all()] + if keep_ids: + await self.db.execute( + update(WorldChunkRevision) + .where( + WorldChunkRevision.chunk_key == chunk_key, + WorldChunkRevision.id.notin_(keep_ids), + WorldChunkRevision.payload.isnot(None), + ) + .values(payload=None) + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 93b839e..26a1fae 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -30,7 +30,16 @@ from app.config import settings from app.database import get_db from app.main import app -TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users") +TEST_TABLES = ( + "avatar_loadouts", "assets", + # world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 / + # world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world + # must not lose the audit trail), so it won't be cascade-truncated via + # `worlds` the way world_rooms/world_chunks would be. List all three + # explicitly rather than rely on cascade for some of them. + "world_chunk_revisions", "world_chunks", "world_rooms", + "worlds", "sessions", "refresh_tokens", "users", +) BACKEND_DIR = Path(__file__).resolve().parents[1] _COMPOSE_PROJECT = "agp-pytest" diff --git a/backend/tests/fixtures/chunks/chunk_with_objects.bin b/backend/tests/fixtures/chunks/chunk_with_objects.bin new file mode 100644 index 0000000..b9a00ec Binary files /dev/null and b/backend/tests/fixtures/chunks/chunk_with_objects.bin differ diff --git a/backend/tests/fixtures/chunks/empty_chunk.bin b/backend/tests/fixtures/chunks/empty_chunk.bin new file mode 100644 index 0000000..306ff79 Binary files /dev/null and b/backend/tests/fixtures/chunks/empty_chunk.bin differ diff --git a/backend/tests/fixtures/chunks/generate_fixtures.py b/backend/tests/fixtures/chunks/generate_fixtures.py new file mode 100644 index 0000000..613e891 --- /dev/null +++ b/backend/tests/fixtures/chunks/generate_fixtures.py @@ -0,0 +1,105 @@ +"""Generates the golden-file chunk fixtures in this directory. + +These pin the world.md §3.6 binary format so the Python codec +(app/services/chunk_codec.py) and any future Godot-side test fixture can be +checked for drift (world_persistence_impl.md §5.5's "drift control" +requirement). No Godot-side golden fixture existed at the time this was +written (searched scripts/ and tests/ for AGPC / binary-format test +vectors -- none found), so these are authored here, from this +implementation, as the first golden files for the format. The client/editor +follow-on task should decode these same files with +`ChunkDataDecoder.decode_binary()` and assert the same logical values +(see the note in chunk_codec.py about a confirmed float32 handling bug in +that decoder that will need fixing for full byte parity on non-integer +transforms). + +Run with: `python tests/fixtures/chunks/generate_fixtures.py` from `backend/`. +Fixtures are checked in; this script is for regenerating them if the format +or these examples change, not run as part of the test suite. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # backend/ + +from app.services import chunk_codec as cc + +FIXTURES_DIR = Path(__file__).resolve().parent + + +def _empty_chunk() -> cc.ChunkPayload: + return cc.ChunkPayload( + world_id="7", room_id="main", coord=(0, 0, 0), + bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)), + tile_layers=[], objects=[], + flags=cc.ContentFlags(), + created_at=1_700_000_000, updated_at=1_700_000_000, revision=1, author="fixture-gen", + ) + + +def _simple_tile_chunk() -> cc.ChunkPayload: + layer = cc.TileLayer( + name="ground", layer_id=0, width=4, height=4, depth=1, + palette=[ + cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1), + cc.TileDef(id=1, asset_id="tiles/grass", solid=False, flags=1, variants=4), + cc.TileDef(id=2, asset_id="tiles/stone", solid=True, flags=2, variants=2), + ], + tiles=[0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0], + ) + return cc.ChunkPayload( + world_id="12", room_id="main", coord=(3, -1, 0), + bounds=cc.ChunkBounds(min=(96.0, -32.0, 0.0), size=(32.0, 32.0, 1.0)), + tile_layers=[layer], objects=[], + flags=cc.ContentFlags(violence=0, mature=False, sources=[]), + created_at=1_700_000_000, updated_at=1_700_000_500, revision=2, author="fixture-gen", + ) + + +def _chunk_with_objects() -> cc.ChunkPayload: + layer = cc.TileLayer( + name="ground", layer_id=0, width=2, height=2, depth=1, + palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)], + tiles=[0, 0, 0, 0], + ) + obj1 = cc.WorldObject( + id="obj_0001", type="interactable", asset_id="props/chest", + pos=(101.5, -110.25, 0.5), rot=(0.0, 0.0, 0.0, 1.0), scale=(1.0, 1.0, 1.0), + dynamic=False, + properties={"locked": False, "loot_table": "chest_basic"}, + flags=cc.ContentFlags(violence=0, mature=False, sources=[]), + ) + obj2 = cc.WorldObject( + id="obj_0002", type="spawn_point", asset_id="", + pos=(96.0, -128.0, 1.0), rot=(0.0, 0.7071, 0.0, 0.7071), scale=(1.0, 1.0, 1.0), + dynamic=True, + properties={}, + flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]), + ) + return cc.ChunkPayload( + world_id="12", room_id="main", coord=(3, -4, 0), + bounds=cc.ChunkBounds(min=(96.0, -128.0, 0.0), size=(32.0, 32.0, 1.0)), + tile_layers=[layer], objects=[obj1, obj2], + flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]), + created_at=1_700_000_000, updated_at=1_700_001_000, revision=5, author="agent-bot", + ) + + +FIXTURES = { + "empty_chunk.bin": _empty_chunk, + "simple_tile_chunk.bin": _simple_tile_chunk, + "chunk_with_objects.bin": _chunk_with_objects, +} + + +def main() -> None: + for filename, builder in FIXTURES.items(): + payload = builder() + data = cc.encode_binary(payload) + (FIXTURES_DIR / filename).write_bytes(data) + print(f"wrote {filename} ({len(data)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/fixtures/chunks/simple_tile_chunk.bin b/backend/tests/fixtures/chunks/simple_tile_chunk.bin new file mode 100644 index 0000000..8d45257 Binary files /dev/null and b/backend/tests/fixtures/chunks/simple_tile_chunk.bin differ diff --git a/backend/tests/test_chunk_codec.py b/backend/tests/test_chunk_codec.py new file mode 100644 index 0000000..1af751a --- /dev/null +++ b/backend/tests/test_chunk_codec.py @@ -0,0 +1,219 @@ +"""Tests for app.services.chunk_codec -- the Python port of world.md §3.6. + +Pure unit tests: no database, no app, no network. Golden fixtures live in +tests/fixtures/chunks/ (see generate_fixtures.py there for how/why). +""" + +from pathlib import Path + +import pytest + +from app.services import chunk_codec as cc + +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "chunks" + + +def _read_fixture(name: str) -> bytes: + return (FIXTURES_DIR / name).read_bytes() + + +class TestGoldenFixtures: + """Byte-stability: decode a committed fixture, re-encode it, and the + bytes must match exactly (world.md §3.6.3).""" + + @pytest.mark.parametrize("filename", [ + "empty_chunk.bin", "simple_tile_chunk.bin", "chunk_with_objects.bin", + ]) + def test_round_trip_is_byte_identical(self, filename): + original = _read_fixture(filename) + decoded = cc.decode_binary(original) + re_encoded = cc.encode_binary(decoded) + assert re_encoded == original + + def test_simple_tile_chunk_logical_values(self): + decoded = cc.decode_binary(_read_fixture("simple_tile_chunk.bin")) + assert decoded.world_id == "12" + assert decoded.room_id == "main" + assert decoded.coord == (3, -1, 0) + assert decoded.bounds.min == (96.0, -32.0, 0.0) + assert decoded.bounds.size == (32.0, 32.0, 1.0) + assert len(decoded.tile_layers) == 1 + layer = decoded.tile_layers[0] + assert layer.name == "ground" + assert (layer.width, layer.height, layer.depth) == (4, 4, 1) + assert len(layer.palette) == 3 + assert layer.tiles == [0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0] + assert decoded.revision == 2 + assert decoded.author == "fixture-gen" + + def test_chunk_with_objects_logical_values(self): + decoded = cc.decode_binary(_read_fixture("chunk_with_objects.bin")) + assert len(decoded.objects) == 2 + obj1, obj2 = decoded.objects + assert obj1.id == "obj_0001" + assert obj1.type == "interactable" + assert obj1.pos == (101.5, -110.25, 0.5) + assert obj1.properties == {"locked": False, "loot_table": "chest_basic"} + assert obj2.id == "obj_0002" + assert obj2.dynamic is True + assert obj2.flags.violence == 1 + assert obj2.flags.sources == ["asset:props/chest"] + assert decoded.flags.violence == 1 + assert decoded.flags.sources == ["asset:props/chest"] + + def test_empty_chunk_has_no_layers_or_objects(self): + decoded = cc.decode_binary(_read_fixture("empty_chunk.bin")) + assert decoded.tile_layers == [] + assert decoded.objects == [] + + +class TestChecksum: + def test_checksum_is_sha256_hex(self): + import hashlib + assert cc.checksum(b"") == hashlib.sha256(b"").hexdigest() + assert len(cc.checksum(b"")) == 64 + + def test_header_checksum_matches_recomputed_for_valid_fixture(self): + data = _read_fixture("simple_tile_chunk.bin") + header_checksum_hex = data[16:48].hex() + body = data[cc.HEADER_SIZE:] + assert header_checksum_hex == cc.checksum(body) + + +class TestBuildKey: + def test_matches_world_persistence_impl_4_5_format(self): + assert cc.build_key(12, "main", 3, -1, 0) == "12:main:3:-1:0" + assert cc.build_key("12", "main", 3, -1, 0) == "12:main:3:-1:0" + + +class TestFloat32Fidelity: + """This is the concrete regression test for the cross-codec divergence + documented at the bottom of chunk_codec.py: this module must encode + real IEEE-754 float32, including fractional and negative values, + round-tripping exactly (up to float32 precision) -- unlike + scripts/chunk_data_decoder.gd's current int-truncating implementation.""" + + def test_fractional_and_negative_floats_survive_round_trip(self): + payload = cc.ChunkPayload( + world_id="1", room_id="main", coord=(0, 0, 0), + bounds=cc.ChunkBounds(min=(-17.25, 0.125, -3.0), size=(32.0, 32.0, 1.0)), + objects=[cc.WorldObject( + id="o1", type="prop", asset_id="a", + pos=(1.5, -2.75, 0.001), rot=(0.0, 0.7071068, 0.0, 0.7071068), + scale=(1.0, 1.0, 1.0), + )], + ) + encoded = cc.encode_binary(payload) + decoded = cc.decode_binary(encoded) + assert decoded.bounds.min == pytest.approx((-17.25, 0.125, -3.0), abs=1e-6) + assert decoded.objects[0].pos == pytest.approx((1.5, -2.75, 0.001), abs=1e-6) + assert decoded.objects[0].rot == pytest.approx( + (0.0, 0.7071068, 0.0, 0.7071068), abs=1e-6 + ) + + +class TestMalformedInput: + def test_bad_magic_raises_codec_error(self): + data = bytearray(_read_fixture("empty_chunk.bin")) + data[0:4] = b"XXXX" + with pytest.raises(cc.ChunkCodecError): + cc.decode_binary(bytes(data)) + + def test_too_short_raises_codec_error(self): + with pytest.raises(cc.ChunkCodecError): + cc.decode_binary(b"AGPC\x01\x00") + + def test_unsupported_version_raises_codec_error(self): + data = bytearray(_read_fixture("empty_chunk.bin")) + data[4:6] = (99).to_bytes(2, "little") + with pytest.raises(cc.ChunkCodecError): + cc.decode_binary(bytes(data)) + + def test_payload_len_mismatch_raises_codec_error(self): + data = bytearray(_read_fixture("empty_chunk.bin")) + # Corrupt the declared payload_len (offset 12..16). + data[12:16] = (99999).to_bytes(4, "little") + with pytest.raises(cc.ChunkCodecError): + cc.decode_binary(bytes(data)) + + +class TestValidation: + """decode_and_validate: the server-side "don't trust the client" + boundary (world_persistence_impl.md §5.5).""" + + def _valid_args(self): + return { + "expected_key": "12:main:3:-1:0", + "grid_size": (4, 4, 1), + "max_objects": 512, + } + + def test_valid_payload_passes(self): + data = _read_fixture("simple_tile_chunk.bin") + payload = cc.decode_and_validate(data, **self._valid_args()) + assert payload.coord == (3, -1, 0) + + def test_checksum_mismatch_detected(self): + data = bytearray(_read_fixture("simple_tile_chunk.bin")) + # Corrupt one byte of the header's advisory checksum field + # (offset 16..48) only -- magic/version/payload_len and the whole + # payload body stay untouched, so this exercises exactly the + # "recomputed checksum disagrees with the header" path rather than + # also breaking structural decoding. + data[16] ^= 0xFF + with pytest.raises(cc.ChunkValidationError) as exc_info: + cc.decode_and_validate(bytes(data), **self._valid_args()) + assert exc_info.value.reason == "checksum_mismatch" + + def test_chunk_key_mismatch_detected(self): + data = _read_fixture("simple_tile_chunk.bin") + args = self._valid_args() + args["expected_key"] = "12:main:99:99:0" # doesn't match the payload's own coord + with pytest.raises(cc.ChunkValidationError) as exc_info: + cc.decode_and_validate(data, **args) + assert exc_info.value.reason == "chunk_key_mismatch" + + def test_layer_dims_mismatch_detected(self): + data = _read_fixture("simple_tile_chunk.bin") + args = self._valid_args() + args["grid_size"] = (8, 8, 1) # fixture's layer is 4x4x1 + with pytest.raises(cc.ChunkValidationError) as exc_info: + cc.decode_and_validate(data, **args) + assert exc_info.value.reason == "layer_dims" + + def test_too_many_objects_detected(self): + data = _read_fixture("chunk_with_objects.bin") # has 2 objects + args = self._valid_args() + args["expected_key"] = "12:main:3:-4:0" + args["grid_size"] = (2, 2, 1) + args["max_objects"] = 1 + with pytest.raises(cc.ChunkValidationError) as exc_info: + cc.decode_and_validate(data, **args) + assert exc_info.value.reason == "too_many_objects" + + def test_object_out_of_bounds_detected(self): + payload = cc.ChunkPayload( + world_id="1", room_id="main", coord=(0, 0, 0), + bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)), + objects=[cc.WorldObject(id="o1", type="prop", asset_id="a", pos=(9999.0, 0.0, 0.0))], + ) + data = cc.encode_binary(payload) + with pytest.raises(cc.ChunkValidationError) as exc_info: + cc.decode_and_validate( + data, expected_key="1:main:0:0:0", grid_size=(32, 32, 1), max_objects=10 + ) + assert exc_info.value.reason == "object_out_of_bounds" + + def test_tile_out_of_palette_detected(self): + layer = cc.TileLayer( + name="ground", width=2, height=2, depth=1, + palette=[cc.TileDef(id=1)], # tile 5 below isn't in this palette + tiles=[1, 5, 0, 1], + ) + payload = cc.ChunkPayload(world_id="1", room_id="main", coord=(0, 0, 0), tile_layers=[layer]) + data = cc.encode_binary(payload) + with pytest.raises(cc.ChunkValidationError) as exc_info: + cc.decode_and_validate( + data, expected_key="1:main:0:0:0", grid_size=(2, 2, 1), max_objects=10 + ) + assert exc_info.value.reason == "tile_out_of_palette" diff --git a/backend/tests/test_game_servers.py b/backend/tests/test_game_servers.py new file mode 100644 index 0000000..c40a085 --- /dev/null +++ b/backend/tests/test_game_servers.py @@ -0,0 +1,153 @@ +"""Unit tests for the game_servers.py changes made for world persistence +(world_persistence_impl.md §7.3/§7.4): per-spawn token validation, and the +``_await_ready`` replacement for the old, fail-open ``_await_port``. + +No real Godot process is spawned here (that stays a manual/checkpoint +integration test, per test_world_join.py's docstring) -- these exercise +``GameServerSupervisor`` against a fake process object standing in for +``subprocess.Popen``. +""" + +import json + +import pytest + +from app.config import settings +from app.services.game_servers import GameServerSupervisor, _WorldServer + + +class _FakeProc: + """Stands in for subprocess.Popen: only .poll() is needed by the code + paths under test here.""" + + def __init__(self, exited: bool = False): + self._exited = exited + + def poll(self): + return 0 if self._exited else None + + def terminate(self): + self._exited = True + + def wait(self, timeout=None): + return 0 + + def kill(self): + self._exited = True + + +def _make_srv(tmp_path, token="secret", alive=True) -> _WorldServer: + status_path = tmp_path / "status.json" + return _WorldServer( + world_id="7", port=27000, proc=_FakeProc(exited=not alive), + status_path=status_path, token=token, + ) + + +class TestValidateToken: + def test_unknown_world_is_false(self): + sup = GameServerSupervisor() + assert sup.validate_token("no-such-world", "anything") is False + + def test_correct_token_is_true(self, tmp_path): + sup = GameServerSupervisor() + sup._servers["7"] = _make_srv(tmp_path, token="secret") + assert sup.validate_token("7", "secret") is True + # world_id is looked up as str(world_id), so an int works too -- + # this matters because the FastAPI path param is an int. + assert sup.validate_token(7, "secret") is True + + def test_wrong_token_is_false(self, tmp_path): + sup = GameServerSupervisor() + sup._servers["7"] = _make_srv(tmp_path, token="secret") + assert sup.validate_token("7", "wrong") is False + + def test_token_scoped_to_its_own_world_only(self, tmp_path): + sup = GameServerSupervisor() + sup._servers["7"] = _make_srv(tmp_path, token="secret") + # Same token, different world_id -- must not authenticate (§7.3: + # "a path whose world_id differs from the token's world is 403"). + assert sup.validate_token("8", "secret") is False + + def test_dead_server_token_is_false(self, tmp_path): + sup = GameServerSupervisor() + sup._servers["7"] = _make_srv(tmp_path, token="secret", alive=False) + assert sup.validate_token("7", "secret") is False + + def test_reaping_invalidates_the_token(self, tmp_path): + """§7.3: "dies with its process" -- once removed from _servers + (what the reaper does), the same token no longer validates.""" + sup = GameServerSupervisor() + sup._servers["7"] = _make_srv(tmp_path, token="secret") + assert sup.validate_token("7", "secret") is True + del sup._servers["7"] + assert sup.validate_token("7", "secret") is False + + +class TestAwaitReady: + """Replaces the old _await_port, which (a) treated a bound UDP port as + readiness even though content loading happens before create_server, and + (b) failed open past its deadline with no error at all.""" + + async def test_raises_timeout_error_when_never_ready(self, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3) + sup = GameServerSupervisor() + srv = _make_srv(tmp_path) # status file never written + with pytest.raises(TimeoutError): + await sup._await_ready(srv) + + async def test_returns_once_status_file_reports_ready(self, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 5.0) + sup = GameServerSupervisor() + srv = _make_srv(tmp_path) + srv.status_path.write_text(json.dumps({"ready": True, "players": 0})) + await sup._await_ready(srv) # must not raise / must not hang + + async def test_ready_false_is_not_ready(self, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3) + sup = GameServerSupervisor() + srv = _make_srv(tmp_path) + srv.status_path.write_text(json.dumps({"ready": False, "players": 0})) + with pytest.raises(TimeoutError): + await sup._await_ready(srv) + + async def test_raises_promptly_if_process_already_exited(self, tmp_path, monkeypatch): + # A long deadline, but the process is already dead -- must not wait + # out the full deadline (this is the "no crash restart mid-wait" + # case: don't silently poll a corpse for 20s). + monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 20.0) + sup = GameServerSupervisor() + srv = _make_srv(tmp_path, alive=False) + with pytest.raises(TimeoutError): + await sup._await_ready(srv) + + +class TestSpawnCommandLine: + def test_spawn_includes_api_url_and_server_token(self, monkeypatch, tmp_path): + """world_persistence_impl.md §7.2 step 1 / §7.4: `_spawn` must pass + --api-url and --server-token on the command line.""" + sup = GameServerSupervisor() + monkeypatch.setattr(sup, "_free_port", lambda: 27050) + + captured = {} + + class _Proc: + def poll(self): + return None + + def _fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + return _Proc() + + monkeypatch.setattr( + "app.services.game_servers.subprocess.Popen", _fake_popen + ) + srv = sup._spawn("42") + + cmd = captured["cmd"] + assert "--api-url" in cmd + assert cmd[cmd.index("--api-url") + 1] == settings.GAME_SERVER_INTERNAL_API_URL + assert "--server-token" in cmd + token_in_cmd = cmd[cmd.index("--server-token") + 1] + assert token_in_cmd == srv.token + assert len(srv.token) > 20 # secrets.token_urlsafe(32) -- not a short/guessable value diff --git a/backend/tests/test_world_content.py b/backend/tests/test_world_content.py new file mode 100644 index 0000000..00b444a --- /dev/null +++ b/backend/tests/test_world_content.py @@ -0,0 +1,490 @@ +"""Integration tests for the world content API: rooms + chunks. + +Covers docs/architecture/world_persistence_impl.md §8.1 (public router, +app/routers/world_content.py) and §8.3 (internal router, +app/routers/internal.py), plus the create_world -> auto "main" room +behaviour (§4.1). + +Runs against the real ephemeral Postgres the hermetic conftest.py stands +up -- these are full HTTP round trips through TestClient, not mocked. +""" + +import base64 + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.services import chunk_codec as cc +from app.services.game_servers import supervisor + +GRID_32 = (32, 32, 1) + + +def _auth(client: TestClient, name: str) -> tuple[dict, int]: + r = client.post("/api/auth/register", json={ + "username": name, "email": f"{name}@e.com", "password": "secret123", + "date_of_birth": "1990-01-01", + }) + assert r.status_code == 201, r.text + headers = {"Authorization": f"Bearer {r.json()['access_token']}"} + me = client.get("/api/users/me", headers=headers) + return headers, me.json()["id"] + + +def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int: + r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility}) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def _build_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, *, + grid=GRID_32, revision: int = 1, extra_tag: str = "") -> bytes: + """A small but structurally-real chunk payload for `world_id`/`slug`/coord, + sized to match the room's default 32x32x1 grid.""" + w, h, d = grid + layer = cc.TileLayer( + name="ground", layer_id=0, width=w, height=h, depth=d, + palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)], + tiles=[0] * (w * h * d), + ) + obj = cc.WorldObject( + id=f"obj_{extra_tag or '1'}", type="prop", asset_id="props/rock", + pos=(1.0, 1.0, 0.0), properties={"tag": extra_tag}, + ) + payload = cc.ChunkPayload( + world_id=str(world_id), room_id=slug, coord=(cx, cy, cz), + bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(float(w), float(h), float(d))), + tile_layers=[layer], objects=[obj], revision=revision, author="pytest", + created_at=1_700_000_000, updated_at=1_700_000_000, + ) + return cc.encode_binary(payload) + + +class TestCreateWorldMainRoom: + def test_create_world_auto_creates_main_room(self): + client = TestClient(app) + headers, _ = _auth(client, "cw_main") + wid = _make_world(client, headers) + + r = client.get(f"/api/worlds/{wid}/rooms", headers=headers) + assert r.status_code == 200, r.text + rooms = r.json() + assert len(rooms) == 1 + assert rooms[0]["slug"] == "main" + assert rooms[0]["name"] == "Main" + assert rooms[0]["chunk_count"] == 0 + assert rooms[0]["grid"]["size"] == [32, 32, 1] + + +class TestRoomAuth: + def test_create_room_requires_owner(self): + client = TestClient(app) + owner_headers, _ = _auth(client, "room_owner") + wid = _make_world(client, owner_headers) + other_headers, _ = _auth(client, "room_other") + r = client.post(f"/api/worlds/{wid}/rooms", headers=other_headers, + json={"slug": "annex", "name": "Annex"}) + assert r.status_code == 403 + + def test_list_rooms_of_private_world_is_404_for_non_owner(self): + client = TestClient(app) + owner_headers, _ = _auth(client, "room_priv_owner") + wid = _make_world(client, owner_headers, "private") + other_headers, _ = _auth(client, "room_priv_other") + r = client.get(f"/api/worlds/{wid}/rooms", headers=other_headers) + assert r.status_code == 404 + + +class TestChunkPutGetRoundTrip: + def test_binary_put_then_binary_get_round_trips(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_rt") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 0, 0, 0) + + r = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/0/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["revision"] == 1 + assert body["payload_len"] == len(payload) + assert body["checksum"] == cc.checksum(payload[cc.HEADER_SIZE:]) + assert body["live_session"] is False + + g = client.get( + f"/api/worlds/{wid}/rooms/main/chunks/0/0/0", + headers={**headers, "Accept": "application/octet-stream"}, + ) + assert g.status_code == 200 + assert g.content == payload + assert g.headers["ETag"] == f'"{body["checksum"]}"' + assert g.headers["X-Chunk-Revision"] == "1" + + def test_json_accept_returns_decoded_shape(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_json") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 1, 1, 0) + client.put( + f"/api/worlds/{wid}/rooms/main/chunks/1/1/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + g = client.get( + f"/api/worlds/{wid}/rooms/main/chunks/1/1/0", + headers={**headers, "Accept": "application/json"}, + ) + assert g.status_code == 200 + data = g.json() + assert data["coord"] == {"cx": 1, "cy": 1, "cz": 0} + assert data["world_id"] == str(wid) + assert data["room_id"] == "main" + assert len(data["objects"]) == 1 + + def test_json_put_with_base64_payload(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_json_put") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 2, 2, 0) + r = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/2/2/0", headers=headers, + json={"base_revision": 0, "payload_b64": base64.b64encode(payload).decode()}, + ) + assert r.status_code == 200, r.text + assert r.json()["revision"] == 1 + + def test_if_none_match_returns_304(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_304") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 3, 3, 0) + put = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/3/3/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + checksum = put.json()["checksum"] + g = client.get( + f"/api/worlds/{wid}/rooms/main/chunks/3/3/0", + headers={**headers, "If-None-Match": f'"{checksum}"'}, + ) + assert g.status_code == 304 + + def test_unwritten_coordinate_is_404(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_404") + wid = _make_world(client, headers) + g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/9/9/0", headers=headers) + assert g.status_code == 404 + + +class TestRevisionConflict: + def test_create_conflict_when_chunk_already_exists(self): + client = TestClient(app) + headers, _ = _auth(client, "conflict_create") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 4, 4, 0) + first = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/4/4/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + assert first.status_code == 200 + + second = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/4/4/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + assert second.status_code == 409 + body = second.json() + assert body["detail"] == "revision conflict" + assert body["current_revision"] == 1 + + def test_stale_base_revision_conflict_reports_current_state(self): + client = TestClient(app) + headers, _ = _auth(client, "conflict_stale") + wid = _make_world(client, headers) + payload_v1 = _build_chunk(wid, "main", 5, 5, 0, revision=1) + client.put( + f"/api/worlds/{wid}/rooms/main/chunks/5/5/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload_v1, + ) + payload_v2 = _build_chunk(wid, "main", 5, 5, 0, revision=2, extra_tag="v2") + r2 = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/5/5/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"}, + content=payload_v2, + ) + assert r2.status_code == 200 + assert r2.json()["revision"] == 2 + + # Now retry with the now-stale base_revision=1 again. + payload_v3 = _build_chunk(wid, "main", 5, 5, 0, revision=3, extra_tag="v3") + stale = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/5/5/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"}, + content=payload_v3, + ) + assert stale.status_code == 409 + body = stale.json() + assert body["current_revision"] == 2 + assert body["current_checksum"] == r2.json()["checksum"] + + +class TestBatchWrite: + def test_batch_put_all_succeed(self): + client = TestClient(app) + headers, _ = _auth(client, "batch_ok") + wid = _make_world(client, headers) + chunks = [ + {"cx": cx, "cy": 0, "cz": 0, "base_revision": 0, + "payload_b64": base64.b64encode(_build_chunk(wid, "main", cx, 0, 0)).decode()} + for cx in (10, 11) + ] + r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks}) + assert r.status_code == 200, r.text + body = r.json() + assert len(body["chunks"]) == 2 + assert all(c["revision"] == 1 for c in body["chunks"]) + + idx = client.get(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers) + keys = {c["key"] for c in idx.json()["chunks"]} + assert f"{wid}:main:10:0:0" in keys + assert f"{wid}:main:11:0:0" in keys + + def test_batch_put_is_all_or_nothing_on_conflict(self): + client = TestClient(app) + headers, _ = _auth(client, "batch_conflict") + wid = _make_world(client, headers) + + # Pre-create one of the two coordinates so the batch's base_revision=0 + # for it will conflict. + pre = _build_chunk(wid, "main", 20, 0, 0) + client.put( + f"/api/worlds/{wid}/rooms/main/chunks/20/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=pre, + ) + + chunks = [ + {"cx": 21, "cy": 0, "cz": 0, "base_revision": 0, + "payload_b64": base64.b64encode(_build_chunk(wid, "main", 21, 0, 0)).decode()}, + {"cx": 20, "cy": 0, "cz": 0, "base_revision": 0, # conflicts: already exists at revision 1 + "payload_b64": base64.b64encode(_build_chunk(wid, "main", 20, 0, 0, extra_tag="v2")).decode()}, + ] + r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks}) + assert r.status_code == 409 + + # Nothing from this batch should have been persisted -- (21,0,0) + # must NOT exist even though it was valid and came first. + g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/21/0/0", headers=headers) + assert g.status_code == 404 + + +class TestDelete: + def test_delete_then_get_is_404(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_delete") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 30, 0, 0) + client.put( + f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + d = client.delete(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers) + assert d.status_code == 204 + g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers) + assert g.status_code == 404 + + +class TestRevisionsAndRollback: + def test_revisions_then_rollback(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_rollback") + wid = _make_world(client, headers) + v1 = _build_chunk(wid, "main", 40, 0, 0, revision=1) + r1 = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/40/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=v1, + ) + v2 = _build_chunk(wid, "main", 40, 0, 0, revision=2, extra_tag="v2") + r2 = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/40/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"}, + content=v2, + ) + assert r2.status_code == 200 + + revs = client.get(f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/revisions", headers=headers) + assert revs.status_code == 200 + rev_numbers = [row["revision"] for row in revs.json()] + assert 1 in rev_numbers and 2 in rev_numbers + + rb = client.post( + f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/rollback", + headers=headers, json={"revision": 1}, + ) + assert rb.status_code == 200, rb.text + assert rb.json()["revision"] == 3 # a new revision, restoring v1's content + assert rb.json()["checksum"] == r1.json()["checksum"] + + def test_revisions_requires_owner(self): + client = TestClient(app) + owner_headers, _ = _auth(client, "revisions_owner") + wid = _make_world(client, owner_headers) + payload = _build_chunk(wid, "main", 41, 0, 0) + client.put( + f"/api/worlds/{wid}/rooms/main/chunks/41/0/0", + headers={**owner_headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + other_headers, _ = _auth(client, "revisions_other") + r = client.get(f"/api/worlds/{wid}/rooms/main/chunks/41/0/0/revisions", headers=other_headers) + assert r.status_code == 403 + + +class TestValidationErrors: + def test_malformed_payload_is_422(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_malformed") + wid = _make_world(client, headers) + r = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/0/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=b"not a real chunk", + ) + assert r.status_code == 422 + assert r.json()["reason"] == "malformed" + + def test_wrong_grid_size_is_422_layer_dims(self): + client = TestClient(app) + headers, _ = _auth(client, "chunk_layerdims") + wid = _make_world(client, headers) + # Room's default grid is 32x32x1; build a chunk sized for 4x4x1. + payload = _build_chunk(wid, "main", 0, 0, 0, grid=(4, 4, 1)) + r = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/0/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + assert r.status_code == 422 + assert r.json()["reason"] == "layer_dims" + + def test_oversized_payload_is_413(self, monkeypatch): + from app.config import settings + monkeypatch.setattr(settings, "WORLD_CHUNK_MAX_PAYLOAD_BYTES", 10) + client = TestClient(app) + headers, _ = _auth(client, "chunk_413") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 0, 0, 0) + r = client.put( + f"/api/worlds/{wid}/rooms/main/chunks/0/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + assert r.status_code == 413 + + +class TestInternalApi: + """docs/architecture/world_persistence_impl.md §7/§8.3. No real Godot + process is spawned in this test env (GAME_SERVERS_ENABLED=False); we + register a fake in-memory server entry with a known token to exercise + require_game_server without needing a live process.""" + + @pytest.fixture + def fake_server_token(self): + # NB: a class body can't read a same-named local from the + # enclosing function via `token = token` (class bodies use LOAD_NAME, + # which skips enclosing function scopes) -- use a literal instead. + world_id_str = "internal-test-world" + + class _FakeSrv: + def alive(self_inner): + return True + token = "test-internal-token-12345" + + supervisor._servers[world_id_str] = _FakeSrv() + yield _FakeSrv.token + supervisor._servers.pop(world_id_str, None) + + def test_missing_token_is_401(self): + client = TestClient(app) + r = client.get("/api/internal/worlds/1/manifest") + assert r.status_code == 401 + + def test_wrong_token_is_403(self): + client = TestClient(app) + r = client.get( + "/api/internal/worlds/1/manifest", + headers={"Authorization": "Bearer not-the-real-token"}, + ) + assert r.status_code == 403 + + def test_valid_token_but_wrong_world_id_is_403(self, fake_server_token): + # The fake token is registered under world_id "internal-test-world"; + # a request for a different world_id must not authenticate. + client = TestClient(app) + r = client.get( + "/api/internal/worlds/999999/manifest", + headers={"Authorization": f"Bearer {fake_server_token}"}, + ) + assert r.status_code == 403 + + def test_valid_token_reaches_manifest_for_real_world(self): + client = TestClient(app) + headers, _ = _auth(client, "internal_manifest") + wid = _make_world(client, headers) + + class _FakeSrv: + def alive(self_inner): + return True + token = "another-real-token" + + supervisor._servers[str(wid)] = _FakeSrv() + try: + r = client.get( + f"/api/internal/worlds/{wid}/manifest", + headers={"Authorization": "Bearer another-real-token"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["world_id"] == wid + assert len(body["rooms"]) == 1 + assert body["rooms"][0]["slug"] == "main" + finally: + supervisor._servers.pop(str(wid), None) + + def test_internal_chunk_fetch_returns_binary(self): + client = TestClient(app) + headers, _ = _auth(client, "internal_chunk") + wid = _make_world(client, headers) + payload = _build_chunk(wid, "main", 0, 0, 0) + client.put( + f"/api/worlds/{wid}/rooms/main/chunks/0/0/0", + headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"}, + content=payload, + ) + + class _FakeSrv: + def alive(self_inner): + return True + token = "internal-chunk-token" + + supervisor._servers[str(wid)] = _FakeSrv() + try: + r = client.get( + f"/api/internal/worlds/{wid}/rooms/main/chunks/0/0/0", + headers={"Authorization": "Bearer internal-chunk-token"}, + ) + assert r.status_code == 200 + assert r.content == payload + finally: + supervisor._servers.pop(str(wid), None)