Add world content persistence backend (Postgres chunks, internal API, game-server token auth)

Implements docs/architecture/world_persistence_impl.md §9's backend task:
chunk/room/revision Postgres schema (migration 0005), a Python port of the
world.md §3.6 binary chunk codec with golden-file tests, a WorldStore
service (world.md §5.1 minus flush_dirty/rebuild_index), the public
world-content router (rooms/chunks CRUD, optimistic concurrency, batch
writes) and the internal read-only API for dedicated servers, and
game_servers.py changes: a per-spawn bearer token, --api-url/--server-token
args, and a real "ready"-field wait replacing _await_port, which bound the
UDP port before content existed and failed open (no error) past its
deadline.

create_world now auto-creates the "main" room.

Deviations/findings, not silently decided:
- chunk_codec.py implements real IEEE-754 float32 packing rather than
  mirroring a confirmed bug in scripts/chunk_data_decoder.gd's
  _read_float32/_write_float32, which do a lossy int<->float cast instead
  of a bit reinterpretation (verified against Godot 4.6.2 headless) and
  will corrupt any non-integer position/rotation/scale/bounds value. Not
  fixed here (client-side, out of scope); flagged for the client task.
- world_persistence_impl.md references a world's monotonic
  content_revision (§6/§7.2/§8.1) but never defines where it's stored.
  Resolved as MAX(id) over the append-only world_chunk_revisions table,
  scoped to world_id, rather than adding an undocumented column.
- No existing Godot-side golden fixture/test vectors were found to reuse,
  so tests/fixtures/chunks/*.bin are authored fresh from this
  implementation (see generate_fixtures.py).

Verification: 132/132 backend pytest tests pass against a real ephemeral
Postgres (both reusing an already-running instance and cold via
conftest.py's own docker-compose bootstrap), including migration
upgrade/downgrade/upgrade, codec round-trip + validation-error tests, and
full HTTP round trips for PUT/GET/batch/delete/revisions/rollback and the
internal API's token auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
This commit is contained in:
2026-09-14 19:48:01 +00:00
parent c54ea6f055
commit b6c6ddfbc4
21 changed files with 3056 additions and 19 deletions
@@ -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")