Compare commits

..

215 Commits

Author SHA1 Message Date
daddy 4ff74ae823 Editor save path: single/batch chunk PUT, 409 retry, non-joined Edit World entry
Implements world_persistence_impl.md §9 client/editor task items 2-3:

- ChunkSaveService (scripts/editor/chunk_save_service.gd): single-chunk
  PUT with base_revision, batch PUT for multi-chunk edits, and a real
  409 -> re-fetch -> retry loop. Full-chunk overwrite (§8.2 D5) means
  "re-apply the edit" is just retrying with the locally-edited payload
  at the freshly-learned revision, not delta replay.
- InGameEditor gets a Save button (+ Ctrl+S) that surfaces live_session
  as an explicit warning instead of ignoring it, per §6 D4.
- "Edit World" entry point on the world list (owner-gated) that calls
  Session.begin_edit() instead of ApiClient.join_world() -- editing
  never joins, opens no ENet connection, spawns no dedicated server.
- game.gd wires Session.editing to fetch the real (0,0,0) chunk over
  HTTP and open the editor against it, falling back to an empty chunk
  for an unwritten coordinate.

Verified against a real backend (register/world/save/reload, a genuine
409 from two racing saves, atomic 2-chunk batch, live_session field) --
new scripts/smoke_test_editor_save.gd (19/19) plus an extended
smoke_test_world_list.gd covering the non-joined entry point (11/11).
Full existing Godot smoke suite (18 files) and backend pytest (132)
still pass unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 23:35:33 +00:00
daddy 31f6974c7a In-game editor MVP: place / move / rotate / scale / delete catalog objects
checkpoint / checkpoint (push) Successful in 2m15s
EditorCore (data-native, shared): ChunkEditorModel over the existing
ChunkPayload, an UndoRedo-backed command stack, SelectionManager,
ToolRegistry with 5 tools (brush/object-place/select/erase/transform --
transform added beyond editor.md's named list since move/rotate/scale
needed a home). InGameEditor UI (2D top-down, per editor.md $8's own
scope guidance -- CHUNK_SIZE_Z=1, no 3D gizmos yet), gated behind a new
F2 build-mode toggle in game.gd. In-memory only by design -- saving to
the backend is the sibling task's scope.

69/69 new smoke assertions (place/move/rotate/scale/delete/paint/erase,
undo AND redo actually verified to revert/replay, a full
encode_binary/decode_binary round-trip after editing, a real UI click
through EditorViewport, and a rejected-unknown-catalog-id case) and the
full sibling smoke suite (16 other suites, backend-dependent ones run
against a real backend + Postgres) all independently re-verified with
zero regressions.
2026-09-14 23:22:12 +00:00
daddy 7c37a0dc2f Add in-game editor MVP: EditorCore + 2D tile/object editing
Implements the in-game editor's shared, data-native core and a
Control-based UI on top of it, per docs/architecture/editor.md §7.1/§7.2,
scoped to 2D per §8's own risk mitigation (the default world is flat,
CHUNK_SIZE_Z=1 -- no 3D gizmos here).

EditorCore (scripts/editor/):
- ChunkEditorModel wraps a ChunkDataDecoder.ChunkPayload directly (no
  parallel data model) and adds a TileLayerData.set_tile() mutator
  alongside the existing get_tile().
- EditorUndoRedoStack wraps Godot's built-in UndoRedo (game-usable, not
  the editor-only EditorUndoRedoManager) so every mutation is a real,
  revertible do/undo Callable pair over ChunkEditorModel's raw setters.
- SelectionManager tracks the selected object id as UI state, deliberately
  outside the undo/redo stack.
- ToolRegistry + 5 tools (BrushTool, ObjectPlaceTool, SelectTool,
  EraseTool, TransformTool -- the 5th covers move/rotate/scale, which the
  doc's tool list doesn't literally name but the task requires) is the
  single choke point every mutation goes through.
- EditorObjectCatalog (assets/editor/object_catalog.json) is a new,
  small "placeable object" catalog -- AvatarCatalog and SpawnCatalog were
  checked first and are both scoped to other concerns, so this mirrors
  their loading convention rather than reusing either.

InGameEditor UI (scripts/editor/ui/): EditorViewport (2D top-down tile
grid + object dots), EditorToolbar, PalettePanel, InspectorPanel, wired
together in InGameEditor. Built from Control nodes in code (matching
game.gd's existing dynamic-UI convention) rather than a hand-authored
.tscn.

Entry point: game.gd gates build mode behind a new toggle_build_mode
input action (F2), freezing the player and opening a fresh empty
ChunkEditorModel. Loading a *real* chunk from the backend for editing,
and saving edits back, are explicitly out of scope -- a sibling task
owns the batch-PUT/conflict-handling save path
(world_persistence_impl.md).

Verification: scripts/smoke_test_editor_core.gd decodes a real backend
fixture (backend/tests/fixtures/chunks/simple_tile_chunk.bin), drives
place/move/rotate/scale/delete/paint/erase entirely through
EditorCore.use_tool()/ToolRegistry (never touching ChunkEditorModel's
raw setters directly), undoes and redoes every step, and round-trips the
result through encode_binary()/decode_binary() to prove the edited model
stays a valid, byte-compatible ChunkPayload. 69/69 assertions pass. Full
existing Godot smoke suite (16 other scripts/smoke_test_*.gd, backend +
dedicated-server-dependent ones included) still passes with no
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 23:18:45 +00:00
daddy 4c95ad79e6 Client persistence plumbing: HttpChunkSource adapter + server.gd real content loading
checkpoint / checkpoint (push) Successful in 2m16s
Implements docs/architecture/world_persistence_impl.md §9's client-side
plumbing: HttpChunkSource wired into ChunkStreamManager.set_server_rpc()
(ETag/304 caching), and server.gd's startup sequence now fetches a
manifest + spawn-neighborhood chunks over the internal API before binding
the ENet port, builds real collision from decoded chunk data, and falls
back to test_level.tscn when the backend is unreachable. This is the
prerequisite both normal play (Enter World) and the upcoming in-game
editor need -- neither had anything real to load/save before this.

Found and fixed 4 real, previously-untested bugs in the process (nothing
had ever exercised encode_binary()/the set_server_rpc() seam before this
task): a missing section header in _build_metadata_section that corrupted
every encode_binary() call, a String.split()/Array[String] type mismatch
in _parse_coord_from_key, a nonexistent data.format_version field read
(should be .version), and (found during review) a client-side min+size/
min+max bounds mismatch identical in kind to the backend bug fixed in
eb39e43, masked by every existing test using a zero chunk position.

Independently re-verified: 11/11 HttpChunkSource smoke assertions, 33/33
codec interop assertions (incl. a new nonzero-bounds regression case), and
the full sibling smoke suite (16 suites) against a real backend + real
GameServerSupervisor-spawned server.gd process -- real content load,
empty-room fallback, and unreachable-backend fallback all confirmed with
actual collision-shape counts, not just HTTP status codes.
2026-09-14 23:00:47 +00:00
daddy 65995f30a6 Fix latent client-side encode_binary bounds min+size/min+max bug (nonzero position)
Found during review of the persistence-plumbing branch: _build_metadata_section
wrote raw bounds.size into the wire's 2nd float triple, but the wire format
(and _parse_metadata_section) treats that triple as max_pos, computing
size = max - min. AABB(Vector3.ZERO, size) masks this since size == max when
position is 0 -- exactly what every existing test used. Any chunk at a real,
nonzero world position would silently corrupt on its own client-local
encode/decode round-trip. Same bug class as the backend's min+size/min+max
mismatch fixed in chunk_codec.py (eb39e43), just on the client side this
time. Added a regression case to smoke_test_chunk_codec_interop.gd using a
real nonzero chunk position (96,-32,0) -- 33/33 passing. Full sibling smoke
suite re-verified clean afterward.
2026-09-14 23:00:17 +00:00
daddy fd88cef32f Wire client chunk streaming + dedicated-server content load to the backend
Implements the client-side half of docs/architecture/world_persistence_impl.md
(the backend API in backend/app/routers/{world_content,internal}.py and
game_servers.py's --api-url/--server-token/_await_ready were already merged):

- scripts/http_chunk_source.gd: new HttpChunkSource adapter satisfying
  ChunkStreamManager.set_server_rpc()'s seam. Fetches chunks over HTTP from
  the public world_content API (JWT via ApiClient), with ETag/If-None-Match
  (304) support backed by a user://chunk_cache/<checksum> cache, and a
  bounds-query list_chunks() helper (§8.1's chunk index). A 404 (unwritten
  coordinate) renders empty instead of erroring, per §8.1.

- scripts/server.gd: startup now parses --api-url/--server-token, and
  before binding the ENet port fetches the world's manifest + the
  spawn-neighbourhood chunks from /api/internal (§7.2/§7.3/§8.3), decodes
  them, and builds real StaticBody3D collision from solid tiles + non-
  walkable objects. Falls back to test_level.tscn if the backend is
  unreachable, the world has no rooms, or the room has zero chunks -- never
  hangs or crashes. Writes "ready"/"content" (plus diagnostic
  chunks_loaded/collision_shapes counts) into the status file that
  game_servers.py's _await_ready() already reads.

- scripts/api_client.gd: exposes a `base_url` getter so other client code
  building its own requests (HttpChunkSource; server.gd doesn't use this
  autoload at all, since its auth model is the per-spawn server token, not
  a player JWT) can target the same backend.

Also fixes three real, previously-dormant bugs this work's first real
exercise of these code paths uncovered (nothing before this called
encode_binary() or fed real data through set_server_rpc()):

- chunk_data_decoder.gd: `_build_metadata_section` never wrapped its
  output with the section id/length header every other section builder
  uses, so `encode_binary()` produced corrupt payloads the decoder read as
  a single garbage-length section ("truncated section body").
- chunk_stream_manager.gd: `_parse_coord_from_key` assigned a
  PackedStringArray (String.split()'s real return type) to an
  Array[String]-typed variable -- a hard crash.
- chunk_stream_manager.gd: `_instantiate_chunk_scene_with_data` read a
  nonexistent `data.format_version` field; ChunkPayload's field is
  `version`.

Adds scripts/smoke_test_http_chunk_source.gd, a real end-to-end smoke test
(register, create world, PUT a real chunk, stream it back through
ChunkStreamManager + HttpChunkSource, plus the unwritten-coordinate and
304-refetch paths).

Verified against a real backend + Postgres:
- Seeded real chunk content, joined through the real
  GameServerSupervisor spawn path: status file reports
  content=loaded, chunks_loaded=1, collision_shapes=33.
- A room with zero chunks and an unreachable backend (closed port) both
  fall back to test_level.tscn cleanly (content=fallback, ready=true, no
  hang/crash).
- Full existing Godot smoke suite (16 suites, including
  smoke_test_enter_world.gd and smoke_test_network_client.gd) passes with
  no regressions.
- Backend pytest (132/132) untouched and still green -- no backend/
  changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 22:54:56 +00:00
daddy eb39e4393d Fix 3 real client/server chunk-codec interop bugs, caught by a new golden-fixture test
checkpoint / checkpoint (push) Successful in 2m13s
No test ever exercised the client ChunkDataDecoder against real byte data
before this (M2 chunk streaming was never fed real content on PC2/PC3).
Added scripts/smoke_test_chunk_codec_interop.gd, decoding the backend's
exact golden fixtures (backend/tests/fixtures/chunks/*.bin) and asserting
byte-identical logical values -- the real cross-language contract this
whole persistence system depends on. It found three bugs on first run:

1. chunk_data_decoder.gd's _read_float32/_write_float32 used `bits as
   float`/`val as int`, a numeric cast in GDScript, not an IEEE-754
   bitcast -- silently corrupted every non-integer position/rotation/
   scale/bounds value (1.5 decoded as 1069547520.0, verified). Fixed with
   PackedByteArray.decode_float/encode_float (real bitcast, same
   little-endian order as the rest of this format).

2. backend/app/services/chunk_codec.py's binary codec wrote bounds as
   min+size, disagreeing with world.md §3's documented min+max wire
   format and with the client's actual (correct) decoder -- corrupted
   every chunk's bounds on the wire despite the backend's own JSON path
   using min+max correctly. Fixed the binary encode/decode to match the
   spec; regenerated the golden fixtures.

3. chunk_data_decoder.gd's object-properties parsing used
   `JSON.parse_string(props_str) or {}` -- GDScript's `or` is a boolean
   operator, not Python/JS-style short-circuit-return, so this silently
   evaluated to a bool for any object with real property data, crashing
   on assignment to the typed `properties: Dictionary` field. Fixed with
   an explicit type check.

Full backend suite (132/132) and 4 sibling client smoke tests re-verified
clean after all three fixes; no other `or {}`/`or []` instances of bug 3's
pattern found elsewhere in the codebase.
2026-09-14 19:56:44 +00:00
daddy 9c086a4b10 Backend: world content persistence endpoints (save/load chunk data)
Implements docs/architecture/world_persistence_impl.md §9's backend scope:
world_rooms/world_chunks/world_chunk_revisions (Postgres, migration 0005),
a Python port of the §3.6 binary chunk codec with full server-side
validation, the public save/load API (batch PUT, X-Base-Revision conflict
detection, revision history) and the internal dedicated-server-only API
(bearer-token gated), game_servers.py per-spawn tokens + a real _await_ready
replacing _await_port (which failed open past its timeout with no error --
confirmed real bug). create_world now auto-creates the main room. 132/132
backend tests pass (independently re-run), alembic upgrade/downgrade/upgrade
round-trips clean (independently re-run).
2026-09-14 19:52:07 +00:00
daddy b6c6ddfbc4 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
2026-09-14 19:48:01 +00:00
daddy 186a79a43e UGC scripting DSL: data-driven verb/condition interpreter
checkpoint / checkpoint (push) Successful in 1m52s
Implements docs/architecture/scripting_sandbox.md's primary recommendation
(a fixed verb/condition/trigger DSL expressed as data, zero attack
surface) rather than the out-of-process sidecar the sibling task had
originally assumed. ScriptBehavior/ScriptRule parse and whitelist rule
data at load time; ScriptInterpreterNode is the only code that ever runs,
dispatching every verb through a closed match() -- never call()-by-name
from data. Verbs: give_item, play_sound, move, rotate, spawn_from_catalog,
set_timer. Triggers: on_interact, on_enter, on_touch, on_timer. Demo
wired into test_level.tscn (ScriptedChest). 50/50 smoke assertions,
independently re-verified, including 9 distinct malformed/unsafe-rule
rejection cases and a real spawn_from_catalog resolving only through the
trusted catalog, never a raw data-supplied path.
2026-09-14 19:34:12 +00:00
daddy 8822a03f53 Add ScriptBehavior DSL: data-driven, non-Turing-complete UGC scripting
Implements the DSL that docs/architecture/scripting_sandbox.md recommends
as the primary (and, for now, sufficient) approach in place of running
untrusted GDScript in-process: a fixed set of trigger/condition/action
verbs, expressed as plain JSON data, interpreted by trusted GDScript with
zero ability to reference engine singletons, call arbitrary methods, or
execute code.

- ScriptBehavior (scripts/scripting/script_behavior.gd): parses and
  validates rules from JSON/Dictionary data. Every trigger, condition
  type/op, and verb is checked against an explicit whitelist at parse
  time; malformed or unknown entries are dropped and recorded in
  rejected_rules, never partially executed. Carries an optional
  content_flags passthrough so this data type isn't hostile to future
  moderation-queue integration (docs/architecture/content_moderation.md).
- ScriptInterpreterNode (scripts/scripting/script_interpreter.gd): the
  only code that executes rules. Triggers: on_interact (proximity +
  "interact" input action), on_enter (edge-triggered Area3D overlap),
  on_touch (continuous overlap), on_timer (physics-clocked Timer). Verbs:
  give_item, play_sound, move, rotate, spawn_from_catalog, set_timer --
  each a fixed, hand-written function; verb dispatch is an explicit
  match(), never call()-by-name from data. move/rotate animate via Tween
  rather than teleporting.
- PlayerInventory (scripts/scripting/player_inventory.gd): a small
  per-player item-count stub for give_item to target, since no real
  inventory/entity-state system exists yet (M9 Economy). Attached to
  scenes/player.tscn as "Inventory".
- SpawnCatalog (scripts/scripting/spawn_catalog.gd): trusted catalog_id ->
  asset_id lookup for spawn_from_catalog, mirroring the AvatarCatalog
  data-driven-JSON convention. Rule data only ever supplies a catalog_id
  key, never a resource path.
- Wired a real demo ("ScriptedChest") into scenes/worlds/test_level.tscn
  with an on_interact rule (give_item + play_sound + rotate), plus an
  "interact" input action (project.godot).
- scripts/smoke_test_script_behavior.gd: exercises every trigger type,
  every verb, and every condition type against the real interpreter and
  the wired demo scene, and proves unknown/malformed/unsafe rules
  (unknown verb, unknown trigger, non-scalar smuggled arguments, etc.)
  are rejected at parse time and never run -- 50/50 checks pass.
2026-09-14 19:32:22 +00:00
daddy c54ea6f055 Design: server-side world persistence architecture (Postgres, FastAPI-owns-writes)
checkpoint / checkpoint (push) Successful in 1m48s
Resolves the two-server-process gap in world.md's original SQLite design:
chunk content lives in Postgres, the FastAPI backend is the sole writer, the
per-world dedicated Godot server pulls content read-only over HTTP before
binding its port. Also flags and fixes (in the design) 3 real bugs in the
existing game_servers.py supervisor: _await_port fails open past its
deadline, _spawn passes no server credentials, player_count silently reads
0 on a missing status file.
2026-09-14 19:20:39 +00:00
daddy b562e9f7b3 Design: server-side world persistence architecture
Decides where chunk/object content lives at rest and who may write it, now
that there are two server-side processes per world (the always-on FastAPI
backend and the per-join, reaper-killed Godot dedicated server).

Decisions: chunk data lives in the existing Postgres DB (not world.md's
per-world SQLite file); the FastAPI backend is the sole writer and the editor
saves to it over HTTP; the dedicated server pulls content read-only at startup
over a loopback /api/internal route with a per-spawn token; full-chunk PUT with
base_revision instead of JSON-Patch deltas; the client streams chunks over HTTP
through the existing ChunkStreamManager.set_server_rpc() seam.

Adds docs/architecture/world_persistence_impl.md and marks the superseded
sections of world.md (SS4, SS5) and editor.md (the request_chunk_edit path).
Design only -- no implementation code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 19:19:10 +00:00
daddy 6809018ef1 Avatar customization: skin colour = swatches + shade slider
checkpoint / checkpoint (push) Successful in 1m45s
The single tone slider could never get back to the default yellow. Replaced
it with a row of preset colour swatches (yellow first, then skin tones and
a few fun colours) plus a "Shade" slider that darkens/lightens the picked
swatch (0.5 = as-is). Picking yellow at mid shade reproduces the baked
default exactly.

Verified: avatar smoke 14/14, fresh-clone, and drove the swatches + shade
in rendered screenshots (green skin, back to yellow, dark tan).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 16:17:07 +00:00
daddy 43a3b888da Avatar customization: skin tone, head swap, and hair now actually apply
checkpoint / checkpoint (push) Successful in 1m45s
Three dead controls on the customization screen:

- Skin tone slider did nothing: neither the visualizer nor AvatarSystem
  ever read loadout.skin_tone. Added AvatarLoader.apply_skin_tone() -- it
  retints the base body's skin surfaces (matched by the placeholder skin
  albedo, i.e. head + arms) -- and both build paths call it. Slider now
  maps across a deep-to-pale skin range.

- "Cyber Head" did nothing: its catalog entry was type "body_part", which
  load_attachment() rejects, and the placeholder body is one mesh so there
  was nothing to hide either. Retyped it as an attachment -> it mounts on
  the Head bone as a cyber visor.

- Mohawk / ponytail sat inside the head: the Head and HeadTop bones were
  well below the visual head (bone at 1.58, head crown at ~2.03). Moved
  Head to the head's centre and HeadTop just above the crown (mesh is
  rigidly bound, so it doesn't move), and retuned the hair/visor/glasses
  meshes and offsets to match.

Verified: avatar smoke 14/14 (source + pack), fresh-clone, 2p multiplayer,
and drove the real customization screen in rendered screenshots (equip
head+mohawk+mid skin, then ponytail+glasses+pale skin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 16:10:46 +00:00
daddy 181ba6ae02 Avatar customization: drag-to-rotate preview, fill the dead space, seam fix
checkpoint / checkpoint (push) Successful in 1m45s
- The preview is now a turntable: drag it to rotate the avatar and see all
  sides; it slowly auto-spins after ~2.5s of no input. Starts facing the
  camera so you see the face. "drag to rotate" hint under the viewport.
- The big grey void to the right of the controls is gone -- the preview
  panel now expands to fill the leftover width; the controls dock to the
  right edge at a fixed width.
- Camera reframed to fit the whole ~2 m body; softer preview lights.
- Placeholder avatar: torso/arms/legs now share one 0.46 m depth so a limb
  always covers the torso slice behind it -- kills the bright "cyan seam"
  (it was a lit sliver of the torso side peeking past the thinner arm).

Verified headless (boot, 2p multiplayer, avatar smoke source+pack,
fresh-clone) + eyeballed the customization screen and the avatar seams in
rendered screenshots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 15:18:26 +00:00
daddy 4200a8b358 Roblox-style camera: Classic + Shift Lock + scroll zoom + first person
checkpoint / checkpoint (push) Successful in 1m46s
Matches Roblox's default controls so the feel doesn't stray:

Classic (default): mouse cursor is free; hold LMB/RMB and drag to orbit
the camera (RMB also turns the body to face the camera while held). The
camera stays where you leave it -- no auto-recenter. Body auto-rotates
toward its movement direction otherwise.

Scroll wheel zooms between 0.4 and 9.0 m; at/below 0.6 m it switches to
first person (mouse locks, camera at eye height, own avatar hidden so the
head doesn't fill the view).

Shift Lock (toggle Left Shift): mouse locks to a centre crosshair, camera
shifts 0.85 m to the right shoulder, body facing is slaved to the camera,
and WASD strafes / backpedals.

Movement stays camera-relative throughout. net_input.look_yaw is still
the body yaw, so remote players face the right way in every mode.

Verified headless (boot, 2p multiplayer, avatar smoke source+pack,
fresh-clone) and eyeballed all three modes in rendered screenshots
(classic behind / shift-lock over-shoulder+crosshair / first person).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:52:21 +00:00
daddy 1e917e196b Free-orbit third-person camera
checkpoint / checkpoint (push) Successful in 1m45s
The mouse now orbits the camera around the player a full 360 in yaw
(unclamped) with a wide pitch range -- swing it around to look at the
avatar's face. The body no longer snaps to the camera yaw; instead it
turns smoothly to face its movement direction (lerp_angle at
BODY_TURN_SPEED), so controls feel the same: W is still "away from
camera", A/D still strafe relative to view.

Implementation: CameraPivot is set top_level so it doesn't inherit the
body's rotation; _update_camera() places it at the head each frame and
points it by _camera_yaw / _camera_pitch. Movement direction is built
from a yaw-only camera basis. net_input.look_yaw is now the body's
movement facing, so remote players render facing the way they walk.

Verified: headless boot clean, 2p multiplayer still connects/moves,
avatar smoke 14/14 (source + exported pack), fresh-clone PASS, and
eyeballed the orbit in rendered screenshots (behind -> side -> face).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:33:49 +00:00
daddy eb4da62f99 Fix ESC-crashes-the-game, avatar facing backward, invisible build stamp
checkpoint / checkpoint (push) Successful in 1m47s
ESC: player.gd changed scene directly while game.gd._exit_tree closed the
socket, which re-fired server_disconnected -> _on_server_lost ran mid-
teardown (add_child on an exiting node, await on a null get_tree(), a
second change_scene_to_file). Moved ESC to game.gd as one ordered path:
_leave_world() sets a _leaving guard, disconnects the _on_server_lost
handlers before closing the connection, then changes scene. _on_server_lost
and _exit_tree now respect the guard. Verified headless: ESC -> world_list,
no errors; 2p multiplayer still connects.

Avatar faced +Z but a Godot node's forward is -Z, so walking forward
showed the character its own face. Rebuilt the placeholder facing -Z (face
plates, eyes, jaw, toes, and front-mounted attachments all moved to
negative Z; backpack/ponytail to +Z). The procedural walk was already
written for -Z forward, so it's unchanged.

Build stamp was there but tiny/low-contrast bottom-left -- bumped to 20px
opaque yellow with a heavy outline, moved to the top-left strip.

limb_animator: guard _process with is_instance_valid(_skel) so a frame
landing during avatar teardown can't touch a freed skeleton.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:24:45 +00:00
daddy 3fb45f0ef2 Avatar: tune Roblox proportions + walk cycle from real rendered screenshots
checkpoint / checkpoint (push) Successful in 1m47s
Now that cloudbison can render (Radeon GPU via Xvfb, or llvmpipe software),
added scripts/tools/screenshot_rig.gd + scenes/dev/ so a scene can be booted,
run a few frames, and its rendered frame saved to PNG -- caught three things
the headless smoke never could:

- limbs floated detached from the torso: widened the torso and overlapped the
  arm/leg boxes into it, and pulled limb depth in behind the torso front so
  they stop z-fighting.
- proportions were scrawny: reworked to Roblox R6 ratios (2-stud torso, 2-stud
  arms/legs, 1-stud head) with the head clearly pegged on top.
- the walk swing was a 40-deg lunge: STRIDE 0.7->0.42 rad, separate 0.32 arm
  swing, KNEE 1.0->0.5. Reads as a walk now, not a crouch.

test_level lighting was near-black on the shaded side (ambient was disabled) --
colour ambient at 0.8 + a stronger key light, so the avatar reads in-world.

avatar_preview.tscn (AGP_PREVIEW_WALK=1) renders the assembled avatar walking
in place for quick visual checks.

Verified: avatar smoke 14/14 source + exported pack; fresh-clone boot PASS;
eyeballed in rendered screenshots (game scene + walk sequence).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:06:46 +00:00
daddy 1b1260f371 Avatar: fix inside-out black shell + add a procedural walk cycle
checkpoint / checkpoint (push) Successful in 1m52s
The blocky body rendered fully black from every angle: _append_box wound
its faces clockwise-from-outside, so every computed normal pointed inward
-- an inside-out shell, lit from behind on all sides, and the level had
ambient light disabled so nothing lifted it. Fixed the winding (normals
now verify outward 234:36) and switched test_level's environment to a
soft colour ambient so shaded sides read grey, not pure black.

Limbs were static because the rig had no animation at all. Added
scripts/avatar/limb_animator.gd: a clip-free walk cycle that swings the
upper arm/leg bones with ground speed, folds the knees on the rear
stride, and eases back to rest when idle. Wired into the local player
(speed from velocity) and remote players in game.gd (speed from the
per-snapshot authoritative position delta).

Verified: avatar smoke 14/14 from source and exported pack; fresh-clone
boot PASS; game + menu boot headless clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 13:07:27 +00:00
daddy 9731615c34 Roblox-style blocky avatar + actually-visible build stamp
checkpoint / checkpoint (push) Successful in 1m52s
Avatar: rebuild the placeholder base body as a Roblox-classic (R6) figure --
six chunky solid box parts (head, torso, two arms, two legs) skinned one
part per bone onto the same 24-bone canonical rig, arms hanging at the
sides instead of a T-pose, plus a painted-on smile face. Classic
yellow/blue/green "noob" palette, flat single-colour surfaces (the old
capsule-segment chain read as a banded scarecrow). Attachments resized to
match the new proportions; head/hair/glove primitives are now boxes too
(dropped ~130KB spheres to ~3KB each).

Build stamp: it never rendered because `set_anchors_and_offsets_preset()`
plus `.position` on a zero-size Label collapses the rect to negative width.
Switched to a bottom-anchored full-width strip with explicit offsets and
text alignment, brighter colour + black outline. Shows on the main menu
(bottom-right) and in-world HUD (bottom-left) as `build PC3 <commit>`.

Verified: avatar smoke 14/14 from source and against the exported pack;
game.tscn + main_menu.tscn boot headless with no script errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 02:00:48 +00:00
daddy 6ad91b895c Fix: avatars are capsules in the exe -- use load().instantiate(), not runtime GLTFDocument
checkpoint / checkpoint (push) Successful in 1m47s
Playtest log had the smoking gun:
  WARNING: player.gd: no avatar to show; keeping the placeholder capsule
  ERROR: Parameter "data.tree" is null. at: get_tree

avatar_loader.gd read raw .glb bytes and ran GLTFDocument.append_from_buffer
+ generate_scene() at runtime. In a release export that path produced NO
Skeleton3D (works fine in the editor / headless-from-source), so
load_base_body() returned null and the player fell back to the capsule.
The queue_free() calls in that path also errored (loader runs detached from
any tree).

Fix: load(path).instantiate() the Godot-imported PackedScene (the .scn that
actually ships in the pack, with the skeleton + skin set up by the import
pipeline). The raw-bytes GLTFDocument path stays available for future
user-uploaded models but isn't used for the bundled catalog.

- run_checkpoint_tests.py: also runs the avatar smoke against the EXPORTED
  pack, not just source -- that's what catches release-only resource
  differences. Verified: 14/14 against export/game.pck.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 00:58:49 +00:00
daddy 44bfcb1871 Build stamp via assets/build_info.json (reliable: same path as the catalog)
checkpoint / checkpoint (push) Successful in 1m39s
config/version didn't survive export cleanly. Instead checkpoint_ops writes
assets/build_info.json at build time (a plain JSON file, packed by the same
include_filter as the avatar catalog) and the menu + in-world HUD read it
with FileAccess. Shows 'build PC3 <commit>' so playtest reports say exactly
which build is running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 00:41:57 +00:00
daddy 3639fb3132 Fix multiplayer state: stacked players, ghosts after rejoin; add a build stamp
checkpoint / checkpoint (push) Successful in 1m40s
- Clients now hard-snap to the server's spawn position on the first
  snapshot. player.gd starts everyone on the fixed PlayerSpawn marker but
  the server spreads spawns out -- so one client saw players ~1m apart
  (correct) while the other saw them stacked (its local player still on the
  marker, never corrected because the ~1m drift was under the old 1.5m
  threshold). Threshold also tightened to 0.6m.
- game.gd._exit_tree() closes the ENet connection cleanly when you leave a
  world, so the server fires peer_disconnected and removes your player.
  Before, leaving just changed scene and the server never knew -- everyone
  else kept seeing a frozen "ghost".
- server.gd: once-a-second sweep of players whose peer is no longer
  connected, as a backstop for a missed disconnect.
- Build stamp: checkpoint_ops bakes "<PC> <commit>" into
  application/config/version at export; the menu and in-world HUD show
  "build PC3 <hash>" so playtest reports aren't ambiguous about which
  build is running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 00:39:30 +00:00
daddy da4e1420ee Fix: rejoining stacked players on top of each other (one fell through)
checkpoint / checkpoint (push) Successful in 1m40s
All players spawned at the exact same PlayerSpawn point and were on the
same collision layer, so two overlapping CharacterBody3Ds triggered a
physics depenetration shove that could launch one through the 1-unit-thick
floor.

- server.gd: golden-angle spawn spread -- each join lands ~1m+ from the
  others, growing outward, never exactly repeating.
- server_player.tscn + player.tscn: players on collision layer 2, mask 1
  (world only) -- players no longer collide with each other at all
  (fine for a social game; also removes the shove).
- player_spawner.gd + player.gd: hard safety net -- if y < -10 (any
  tunnel-through), snap back to the spawn instead of falling forever.

Verified: 3 server players spawn 1.15m+ apart, none fall, all rest on the
floor. 2p multiplayer + network_client smokes green.

Client-side -- rebuild the exe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 00:00:27 +00:00
daddy 80f24bfd01 Avatars actually show; camera stops hugging the player
checkpoint / checkpoint (push) Successful in 1m40s
- SpringArm3D was colliding with the player's own capsule (same layer) ->
  camera yanked up against the head. add_excluded_object(self) in _ready +
  gentler default pitch, shorter arm, lower pivot.
- AvatarSystem.build_current_avatar() is now self-sufficient: loads the
  catalog itself and falls back to a bare default loadout if login didn't
  prime one. So the in-world player always shows an avatar, never the
  capsule, as long as the assets are packed (they are now).
- New AvatarSystem.assemble_avatar(): builds a standalone avatar with a
  throwaway loader, touching no shared state -- so it's safe to call once
  for the local player AND once per remote peer. (load_avatar()'s shared
  loader reset()s and freed the previous avatar on every call.)
- game.gd: remote players render as the assembled avatar too (shared default
  look, tinted per-peer, name tag) instead of a plain capsule.

Client-side -- rebuild the exe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 23:52:10 +00:00
daddy 54bc763dff Fix backwards controls + the exe's bare-capsule avatar
checkpoint / checkpoint (push) Successful in 1m41s
Two real bugs from the last change, both only visible in a real run:

1. Controls reversed: the third-person SpringArm3D had a 180° Y rotation
   that put the camera IN FRONT of the player looking back -- a mirror, so
   W read as backward and mouse pitch as inverted. SpringArm3D extends its
   child along +Z, not -Z; with no rotation the camera sits behind the
   player (+Z world) and looks -Z straight at them. Verified the vectors
   headless: camera behind, W decreases Z, mouse-up looks up.

2. Bare capsule instead of an avatar in the .exe: avatar_catalog.gd reads
   assets/avatars/catalog.json with FileAccess.open(), but the export used
   `all_resources` + empty include_filter, which does NOT pack plain .json
   files. ensure_catalog() failed -> no loadout -> build_current_avatar()
   returned null -> capsule stayed visible. From source it worked because
   res:// is the real filesystem. Fixed: include_filter="*.json" in both
   export presets (verified catalog.json + the .glb pack are now in the
   exported .pck).

- scripts/verify_export_contents.gd + a checkpoint-runner step: export a
  pack and assert the non-resource runtime files are inside it. This is
  the guard for the "works from source, broken in the .exe" class.

Client-side -- rebuild the exe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 23:30:00 +00:00
daddy 87f78b0226 Third-person camera + fix the avatar customize preview
checkpoint / checkpoint (push) Successful in 1m34s
You couldn't see your own character because the camera was first-person
(inside the avatar's head), and the customize screen's 3D preview rendered
nothing.

- player.tscn: camera now hangs off a SpringArm3D on a CameraPivot, 4 m
  behind + above the player, pulled in on collision. player.gd: yaw still
  turns the body, pitch orbits the arm (starts tilted down to frame the
  avatar).
- avatar_customization_ui.gd: the preview SubViewport had own_world_3d=false,
  so it shared the Control-only main world -- no scenario, nothing rendered.
  Set own_world_3d=true, add a WorldEnvironment with ambient fill + a
  key/fill light pair, make the camera current, tweak framing.
- smoke_test_player: assert the third-person SpringArm rig (18/18).
- smoke_test_enter_world: wait longer for the scene change -- a real
  POST /join spawns a dedicated server, which is slow (9/9).

Client-side change -- rebuild the exe to get it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 23:13:07 +00:00
daddy bd1bdc417a tests: assert players stay on the floor in the game-flow multiplayer test too
checkpoint / checkpoint (push) Successful in 1m33s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:59:58 +00:00
daddy 7ccdd3063a Fix: players fall through the floor in multiplayer
checkpoint / checkpoint (push) Failing after 1m34s
scenes/server_player.tscn was a bare CharacterBody3D with NO CollisionShape3D,
so on the server move_and_slide() never hit the ground and is_on_floor() was
always false -> the server player fell forever, and the client (which snaps to
the server's authoritative position past a drift threshold) fell with it.

Give the server player the same CapsuleShape3D (r=0.35, h=1.6) as the client.
The 2-player test now also asserts the server keeps the player on the ground
(on_floor / min_y), which is how this slipped through -- the tests only
checked "position changes" and "sees the other player".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:58:55 +00:00
daddy 67473dddcd CI is live: Gitea Actions + host act_runner, first green run
checkpoint / checkpoint (push) Successful in 1m33s
docs/ci.md updated to reflect the running state + the one sudo command to
make the runner a permanent systemd service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:38:45 +00:00
daddy d76bf4da19 CI: run the checkpoint suite in a venv (host Python is PEP 668 externally-managed)
checkpoint / checkpoint (push) Successful in 1m36s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:36:24 +00:00
daddy 4df1caa279 CI: JS-action-free workflow (host runner needs no Node)
checkpoint / checkpoint (push) Failing after 1s
actions/checkout is a JavaScript action; a host-mode act_runner needs
 on PATH to run it, which the AGP box doesn't have. Replace it with
a plain git fetch from the local Gitea using the injected ${{ github.token }}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:35:45 +00:00
daddy 02747cc266 CI: move workflow to .gitea/workflows/ (the instance is Gitea, not Forgejo)
checkpoint / checkpoint (push) Failing after 10s
The self-hosted git is gitea/gitea:latest (1.26), which reads
.gitea/workflows/ + .github/workflows/, not .forgejo/. Updated docs/ci.md
with the real enable steps (app.ini [actions], act_runner host-mode
register via `gitea actions generate-runner-token`, no web UI needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:32:50 +00:00
daddy 187ec90440 PC3: checkpoint doc for what's built (M4 + M5 + foundation; M6-M10 not started)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:23:52 +00:00
daddy fb52c4fd08 PC3 Phase 3.3 fix: RATE_LIMIT_ENABLED flag so the smoke suite isn't throttled
Every checkpoint smoke test registers users against one backend from one
client IP, so the per-IP auth limit tripped mid-suite (main_menu_full_flow
-> 2/12). Add RATE_LIMIT_ENABLED (default true); run_checkpoint_tests.py
sets it false for its backend. test_rate_limit.py forces it on in-fixture
so the limiter is still really tested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:22:52 +00:00
daddy bc3901da68 PC3 Phase 3.3: rate-limit the auth endpoints
app/rate_limit.py: an in-process sliding-window limiter (single-worker
backend, so no Redis needed -- flagged for if that changes). /register and
/login are capped at 10 attempts / minute / client IP (X-Forwarded-For
aware), returning 429 + Retry-After past that. Blunts credential
brute-force, which the backend had no protection against.

- conftest.py resets the limiter between tests (every TestClient request
  looks like one IP).
- tests/test_rate_limit.py: threshold, shared bucket, per-key window. 77 passed.

CORS reviewed while here: already an explicit origin allowlist
(settings.CORS_ORIGINS), not "*" -- and the Godot client isn't a browser
so it doesn't apply there anyway. Left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:20:18 +00:00
daddy 7bd8124199 PC3 Phase 3.2: real age verification (date of birth, not a checkbox)
POST /api/auth/register now requires a real date_of_birth (ISO YYYY-MM-DD).
The server computes age (auth.calculate_age) and rejects under-18s with 403;
a future/nonsensical date is 400. The DOB is stored and surfaced only to
that user (/api/auth/me).

Still self-attested (a user can enter a false DOB) -- but a concrete,
stored, legally-meaningful data point and the seam for a future ID-check
pass. Encryption-at-rest for the column and the Tier-2 hook are not done;
the limitation is stated plainly in docs/architecture/content_moderation.md 5.2.

- auth.py: RegisterRequest.date_of_birth (date); MIN_AGE=18; register()
  computes + enforces; UserResponse.date_of_birth typed `date | None`
  (the old `str | None` would have 500'd /me once a DOB was actually stored).
- api_client.gd / main_menu.gd: the Over18 checkbox is now a
  "Date of birth (YYYY-MM-DD)" field with a client-side sanity check.
- tests: test_auth_age_gate.py rewritten around the endpoint + age math
  (adult/minor/exactly-18/future-dob/stored); ~25 over_18 references across
  6 backend test files + 6 game smoke tests updated. Backend 74 passed;
  game smokes green (auth 6/6, menu_full_flow 12/12, e2e 4/4, etc.);
  fresh-clone boot PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:18:38 +00:00
daddy 855311e731 PC3 Phase 3.1: hermetic backend tests + fresh-clone boot check + CI workflow
- backend/tests/conftest.py: pytest_configure brings up an ephemeral
  Postgres via docker compose + runs migrations when DATABASE_URL isn't
  already reachable, and tears it down after. `cd backend && pytest` now
  works from a clean checkout with zero manual setup (72 passed in ~28s);
  no docker and no DB -> the whole suite skips with a clear message instead
  of erroring 60+ times.
- scripts/verify_fresh_clone.sh: exports a pristine tree (git archive, so no
  .godot), imports from scratch, headless-boots the main menu + game scene,
  fails on any SCRIPT ERROR / failed autoload. Catches the stale-class-cache
  class of bug. PASS.
- run_checkpoint_tests.py runs both the 2p multiplayer test and the
  fresh-clone check.
- .forgejo/workflows/checkpoint.yml + docs/ci.md: the CI workflow (runs the
  checkpoint suite on push/PR) and how to enable it -- it needs a Forgejo
  Actions `host` runner registered on the AGP box, a one-time operator step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:12:49 +00:00
daddy 50c6a31c25 PC3 Phase 2: orchestration under checkpoint control + networking doc
- pm/checkpoint_ops.py (framework): playtest backend now launches with
  GAME_SERVERS_ENABLED + GODOT_BIN + GAME_PROJECT_DIR + GAME_SERVER_HOST so
  playtesters actually share a world.
- docs/architecture/networking.md 7a/7b: the orchestration + client-play
  design as built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:08:02 +00:00
daddy dba865ae4b PC3 Phase 2.3: networked play path in the game
- Session autoload: carries {world_id, host, port} from the world browser
  to the game scene.
- world_list.gd "Enter World": POST /api/worlds/{id}/join -> Session.begin
  -> load game.tscn. On 503 (backend has no orchestration) falls back to
  local single-player; other errors surface on the card.
- api_client.gd: join_world().
- game.gd (new, on game.tscn root): local single-player by default; when
  Session.active, connects a NetworkClient to the world's dedicated server
  and runs networked -- forwards the local Player's input each physics
  frame, snaps to the server position past a generous drift threshold
  (the Player keeps predicting locally with real collision), renders other
  players from snapshots (labelled capsules, lerped -- avatar appearance
  sync is a follow-up, the server doesn't replicate loadouts yet), and
  returns to the world list on disconnect.
- player.gd: exposes net_input (the input frame it applied) and
  apply_server_correction().
- network_client.gd: send_input() + get_own_server_state() for callers that
  do their own collision-aware prediction.
- scripts/mp_game_flow_client.gd + run_game_multiplayer_test.sh: two real
  clients each register -> join via the API -> game.tscn -> networked;
  A sees B and moves. PASS.

Verified end to end against an orchestration-enabled backend. Non-networked
smokes green (enter_world 9/9, world_list 5/5, player 15/15,
avatar_in_world 10/10, main_menu_full_flow 12/12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:07:06 +00:00
daddy 260b7a38da PC3 Phase 2.2: game-server orchestration + POST /api/worlds/{id}/join
- backend/app/services/game_servers.py: an in-process supervisor. One
  headless Godot dedicated-server process per world, keyed by world_id,
  spawned on demand from the join endpoint, port-allocated from a
  configured range, with a background reaper that kills a world's server
  once its status file reports 0 players for the idle timeout. Off by
  default (GAME_SERVERS_ENABLED); the checkpoint/playtest launcher enables it.
- POST /api/worlds/{id}/join -> {world_id, host, port}; 404 for
  unknown/hidden worlds, 503 when orchestration is disabled. Auth required.
- main.py lifespan starts/stops the supervisor.
- config.py: GODOT_BIN, GAME_PROJECT_DIR, GAME_SERVER_HOST, port range,
  idle timeout.
- tests/test_world_join.py (auth / 404 / visibility / 503) -- suite 72 passed.
- checkpoint runner also runs the 2p multiplayer shell test.

Verified end to end: enabled orchestration, POST /join spawned the Godot
server, a real client connected to the returned address and moved; backend
shutdown reaped the process.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:02:55 +00:00
daddy 682f1f89f7 PC3 Phase 2.1: server-authoritative sim + prediction/reconciliation with replay
Made the multiplayer core actually work (it had never run a real sim):

- project.godot: physics pinned to 30 Hz -- the shared sim/snapshot/prediction rate.
- server.gd: loads the world's level (real ground + collision), spawns a
  server Player per peer at the level's PlayerSpawn, broadcasts a full
  snapshot every physics frame with each peer's state AND last_seq (the last
  input seq applied for that peer). Accepts `--port/--world/--status-file`
  args (status file = player count for a future idle reaper); defaults keep
  `godot res://scenes/server.tscn` working standalone.
- player_spawner.gd (server Player): rewrote -- drains its whole input queue
  each physics frame in seq order, integrates gravity/walk/jump with the
  shared constants, move_and_slide(), tracks last_applied_seq. No more no-op
  process_server_input().
- network_client.gd: rewrote reconciliation -- drops inputs the server has
  acked (last_seq), re-simulates the still-in-flight ones from the
  authoritative state, snaps only on real drift. Exposes get_remote_states()
  (peer_id -> latest snapshot state) for the game to render other players.
  predict_and_send() now also sends body yaw.
- proxies trimmed to the RPC set the rewrite actually uses.
- scripts/mp_test_client.gd + run_multiplayer_2p_test.sh: two real client
  processes vs one real server -- client A sees client B and its own
  movement round-trips. PASS.
- run_checkpoint_tests.py: also run the 2p shell test.

smoke_test_network_client 4/4, player 15/15, avatar_in_world 10/10, all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:00:18 +00:00
daddy 587a15a52e PC3: checkpoint runner starts a dedicated server for the network-client smoke
smoke_test_network_client.gd needs a separate `godot scenes/server.tscn`
process on ENet 7777; the runner never started one, so that test has been
failing in every checkpoint run (the only failure in an otherwise-green
suite). Start it just for that test, tear it down after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:53:05 +00:00
daddy e59cf158e5 PC3 Phase 1.4-1.6: avatars wired into the game end to end
- api_client.gd: get_avatar_loadout() / save_avatar_loadout()
- main_menu.gd: on successful auth (fresh or session-restore), load the
  catalog + fetch the saved loadout into AvatarSystem so the in-world
  player renders as the player's avatar. Best-effort: a slow/failed prime
  never gates getting in (UI goes authenticated first, prime runs after).
  New "Customize Avatar" button (enabled only when signed in).
- avatar_system.gd: local-player convenience API -- ensure_catalog(),
  set_loadout_from_dict() (from the backend's GET shape), has_loadout(),
  build_current_avatar().
- player.gd: swaps the placeholder capsule for the assembled avatar
  (feet-aligned to the capsule bottom) when a loadout is primed; silent
  fallback to the capsule otherwise. Camera stays first-person for now
  (third-person is a noted follow-up).
- avatar_visualizer.gd: rewrote it -- the old version referenced
  AvatarLoader.SlotResult / load_slot() which never existed, so it didn't
  compile and took avatar_customization_ui.gd down with it. Now a thin
  wrapper over AvatarLoader for the preview.
- avatar_customization_ui.gd: seeds from the live loadout, saves through to
  AvatarSystem + PUT /api/avatar/loadout (local file kept as fallback),
  fixed a Camera3D.look_at() before-in-tree crash.
- scenes/avatar_customization.tscn rebuilt (old one used the Godot-3
  `ViewportContainer` node type) + avatar_customization_screen.gd wrapper
  that returns to the menu on close.
- smoke_test_avatar_in_world.gd: primed loadout -> game.tscn -> player
  shows the assembled avatar, capsule hidden, rig + bone mounts present.

Verified: cold-cache smokes green (player 15/15, avatar_system 14/14,
avatar_in_world 10/10, main_menu_ui 13/13); against a live backend
main_menu_full_flow 12/12, main_menu_back 20/20, world_list 4/4,
enter_world 9/9, e2e 4/4; plus a manual register->default-loadout->
customize->save->re-fetch-persisted round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:50:41 +00:00
daddy af85048deb PC3 Phase 1.3: backend avatar loadout persistence
New avatar_loadouts table (one row per user, loadout stored as a single
JSONB document matching the client's AvatarLoadout.to_json() shape) and
GET/PUT /api/avatar/loadout.

The server has no copy of the asset catalog, so it validates only the
envelope (JSON object, non-empty base_body, object slots, size cap) --
which-asset-in-which-slot validation stays client-side. New accounts get a
server default (base_human_male_01, no slots) with is_default:true so the
client can tell "never customised" from "customised back to base".

- models/avatar_loadout.py + 0004 migration + User.avatar_loadout relation
- routers/avatar.py wired into main.py
- tests/test_avatar_loadout.py (7 tests: default, save/read, replace,
  validation, auth) -- full suite 68 passed
- lifted the duplicated _override_db fixture from 3 test files into
  conftest.py (new test files need it too)
- docs/architecture/avatar.md §9a: the persistence design

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:42:10 +00:00
daddy d6cd3293ae PC3 Phase 1.2: placeholder avatar asset pack + make the loader actually assemble
assets/avatars/catalog.json referenced 14 GLB files that were never in the
repo, so the avatar system could not load a single avatar. And the loader's
assembly path had never run against a real skeleton -- it called
Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a
way that dropped the skin binding.

- scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via
  GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical
  rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art
  swaps in as a pure content change (catalog paths unchanged).
- assets/avatars/*.glb: the generated pack (checked in).
- avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound
  Skeleton3D that generate_scene() already produces, instead of rebuilding it.
  Attachments now mount via BoneAttachment3D to the catalog's mount_bone and
  are actually added to the avatar tree (they were parsed then dropped).
  Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin,
  and the Array[String] type mismatch on the accessory list.
- smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully
  because the files don't exist" section with a real assembly check --
  Skeleton3D present, full 24-bone rig, canonical bones resolve by name,
  visible body mesh, equipped items mounted to bones. 14/14 from a cold cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:38:00 +00:00
daddy f51c49fcfa PC3 Phase 1.1: game boots clean from a bare clone
Root cause: a stale .godot/global_script_class_cache.cfg was force-committed
to the repo (last regenerated before the avatar system merged), and Godot
does not rebuild the global class registry on a plain headless scene run --
only an editor scan / `--import` does. So every cross-file `class_name`
reference in the avatar module failed to resolve from a fresh clone, and the
AvatarSystem autoload failed to instantiate entirely.

- Untrack .godot/ and sandbox_probe/.godot/ (already in .gitignore; were
  force-added during old WIP checkpoints)
- Reference sibling avatar scripts via preload() constants instead of their
  global class_name, so the module parses independent of scan order. Keeps
  the class_name declarations for editor/tooling use.
- avatar_loadout.gd's static from_json() factory self-references via a
  preload const too (a script can't resolve its own class_name mid-compile
  on a cold cache).
- smoke_test_avatar_system.gd: same preload treatment so it runs from a bare
  `--script` invocation.

Verified from a wiped .godot/: main_menu.tscn and game.tscn boot with zero
script errors; smoke_test_avatar_system 10/10, smoke_test_player 15/15,
smoke_test_main_menu_ui 13/13, smoke_test_test_level PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:27:57 +00:00
daddy 3596922696 Fix real flake in smoke_test_network_client.gd's prediction-buffer check
Found while re-verifying the full PC2 test suite from a fresh clone:
the "prediction buffer has entries" check failed ~1 in 3 runs. Root
cause is a genuine race in the test, not in prediction/reconciliation
itself -- a server snapshot RPC can land on the 60-frame loop's final
`await process_frame`, triggering a legitimate reconciliation that
clears the buffer (network_client.gd's documented behavior) right
before the check reads it. The desync-correction check right after it
already proves reconciliation logic works correctly on its own, so
this was never actually catching a real bug -- just occasionally
racing a real correction event.

Fix: one more predict_and_send() right before the check guarantees a
fresh buffer entry regardless of whether a correction just fired.
Verified 5/5 clean runs against a real dedicated server after the fix
(previously observed a failure within 3 runs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 01:44:28 +00:00
agent-bot 1526f3a636 Merge pull request 'Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage' (#53) from agent/896fbb97de1d into main 2026-08-21 01:35:28 +00:00
coder-bot b6e146e4b2 Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage
Merges the pre-existing avatar system implementation (catalog, slot
registry, loadout, glTF-based loader, customization UI, visualizer --
~2300 lines across scripts/avatar/) onto current main, which had
diverged significantly since this branch was last touched (PC1 UI
fixes, PC2's playable level/player/multiplayer work).

Fixes found while integrating:
- project.godot had wholesale-replaced the [autoload] section again
  (same recurring pattern as the multiplayer server/client branches),
  dropping the ApiClient autoload entirely. Restored it additively
  alongside AvatarSystem.
- All other real conflicts (main_menu.tscn/.gd, main.tscn, .godot
  cache) were pure "this branch re-created a stale duplicate of
  something main already has" -- resolved by taking main's version,
  confirmed via `git diff` that this task's own changes never touched
  those files' actual logic.

New: scripts/smoke_test_avatar_system.gd -- confirms AvatarSystem is a
real autoload, the committed catalog.json parses, loadout equip/
validate/JSON-round-trip works against real catalog entries, and --
since assets/avatars/*.glb model files don't exist yet -- that
AvatarLoader.load_base_body() fails gracefully (null + load_error
signal) rather than crashing when given a real catalog entry with no
backing file. 10/10 passing.

Verified no regressions: all 10 pre-existing Godot smoke suites still
pass against a live backend, and all 61 backend pytest tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 01:34:51 +00:00
coder-bot 59ca5a4c6e Merge main into agent/896fbb97de1d
# Conflicts:
#	.godot/editor/filesystem_cache10
#	.godot/editor/project_metadata.cfg
#	.godot/global_script_class_cache.cfg
#	.godot/uid_cache.bin
#	project.godot
#	scenes/main.tscn
#	scenes/main_menu.tscn
#	scripts/main_menu.gd
#	scripts/main_menu.gd.uid
2026-08-21 01:26:13 +00:00
reviewer-bot b422e41a0f Merge pull request 'Client-side position sync + prediction/reconciliation' (#52) from agent/806cbbff5213 into main 2026-08-21 01:22:34 +00:00
claude-checkpoint-tooling bd44ecba14 Client-side position sync + prediction/reconciliation
Real client-side networking for the player controller, verified with a
genuine two-process integration test (an actual dedicated server process
+ a real ENet-connected client), per docs/architecture/networking.md §5.

scripts/network_client.gd: NetworkClient -- predicts movement locally on
input (same movement constants as scripts/player_spawner.gd), sends input
to the server via RPC, and reconciles against the server's authoritative
snapshots when they diverge beyond a threshold.

scripts/network_receiver_proxy.gd + network_player_proxy.gd: the RPC
plumbing that makes this actually route correctly, which took real
debugging to get right:
- Godot's rpc_id() resolves the receiving node by matching the sending
  node's NodePath between peers. scripts/server.gd is the SceneTree root
  node "Server" when the dedicated server runs, and spawns each player as
  "Player_<peer_id>" under itself -- so the client needs its own node
  tree at those same paths purely for RPC routing.
- Godot also enforces an RPC "node checksum": both peers must declare the
  *same set* of @rpc methods at a matching path, or every RPC at that
  path fails, not just a genuinely missing one. Every @rpc method on
  scripts/server.gd's root and scripts/player_spawner.gd had to be
  mirrored here (mostly as no-op stubs), not just the ones this class
  actually calls.

Known, explicitly-scoped simplification (stated in the code, not hidden):
scripts/server.gd's snapshot doesn't currently echo back a per-peer "last
input seq processed", so reconciliation corrects against the most recent
prediction rather than true tick-exact rollback-and-replay
(networking.md §5.2 step 4). A real follow-up once the server echoes
that ack, not faked here.

Verified: real two-process test (godot --headless --path . scenes/
server.tscn as an actual persistent process, a second process running
scripts/smoke_test_network_client.gd against it over real ENet) --
4/4 passing: connects, predicts locally before any server round trip,
sends real input, and a deliberately-desynced position (moved 9999 units
away) gets corrected by the very next real snapshot. Full existing
client suite (10 suites) + 61 backend pytest still passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 01:21:37 +00:00
coder-bot bae326e14c WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:18:34 +00:00
coder-bot 140db9df91 Recovered orphaned WIP before switching to a new task 2026-08-21 01:17:16 +00:00
coder-bot 2dff83bae3 WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:17:13 +00:00
coder-bot 1a8821e3c8 WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:14:07 +00:00
reviewer-bot 019dfca14f Merge pull request 'Address review feedback: Implement Godot dedicated server scene + player spawning' (#51) from agent/3bba30f838d1 into main 2026-08-21 01:00:23 +00:00
claude-checkpoint-tooling 7be50e3313 Fix real scene collision + a real regression from removing the Server autoload
Two genuine bugs, found by reproducing rather than trusting the branch's
own state:

1. scenes/player.tscn collided with PC2's client-side player scene at the
   exact same path (server-authoritative PlayerNode vs. the local
   single-player character) -- whichever merged last would silently
   overwrite the other. The branch's own last commit had literally
   committed unresolved <<<<<<< conflict markers into the file instead of
   resolving them. Renamed the server's version to
   scenes/server_player.tscn, restored scenes/player.tscn to match main's
   real client scene exactly, repointed scripts/server.gd's preload.

2. scenes/server.tscn's root Node had no script attached at all -- it only
   ever worked before because project.godot had `Server` registered as a
   project-wide autoload (the exact wholesale-overwrite this task was
   already asked to fix). Once that autoload was correctly removed,
   nothing attached server.gd to the scene tree anymore and the dedicated
   server silently did nothing. Attached scripts/server.gd directly to
   scenes/server.tscn's root node instead.

Verified: godot --headless --import/--quit both clean, full 10-suite
client smoke test all passing, and the dedicated server actually listens
on UDP port 7777 when launched explicitly (`godot --path . scenes/server.tscn`)
-- confirmed with `ss -ulnp`, not just trusting the startup print (first
checked with `ss -tlnp`, which only shows TCP and falsely suggested it
wasn't listening at all; ENet is UDP).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 00:59:24 +00:00
coder-bot 29de1c5c3a Merge main into agent/3bba30f838d1 2026-08-21 00:56:17 +00:00
claude-checkpoint-tooling 92c10fa657 Add smoke_test_enter_world.gd + doc update that never made it into PR #50
Both were verified (9/9 passing) before PR #50 merged but got lost in the
handoff between my push and the concurrent live agent session reusing the
same workspace -- PR #50's actual merged diff only had the world_list.gd/
player.gd code changes, missing the new test file and the doc update.
Caught while auditing everything feeding PC2 for real completeness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 00:53:55 +00:00
reviewer-bot 8357643072 Merge pull request 'PC2: Wire "Enter World" from the browse list to the playable level' (#50) from agent/9e7930c1b631 into main 2026-08-21 00:47:26 +00:00
architect-bot 736ebce0d0 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-21 00:44:47 +00:00
coder-bot b60480798f WIP checkpoint: PC2: Wire "Enter World" from the browse list to the playable level 2026-08-21 00:43:45 +00:00
reviewer-bot 77f7511a4f Merge pull request 'PC2: Basic visible player character representation + local movement controller' (#49) from agent/9bfb90314053 into main 2026-08-21 00:41:53 +00:00
coder-bot 8be962248d Merge main into agent/9bfb90314053
# Conflicts:
#	scenes/worlds/test_level.tscn
2026-08-21 00:39:28 +00:00
claude-checkpoint-tooling 7790670559 WIP: add Camera3D, fix jump/forward-direction bugs, add mouse capture, build game.tscn composition 2026-08-21 00:39:13 +00:00
architect-bot c4eab8db92 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-21 00:20:21 +00:00
coder-bot 4d754ae48e WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-21 00:19:51 +00:00
coder-bot e2a77b055f WIP checkpoint: PC2: Basic visible player character representation 2026-08-21 00:09:44 +00:00
coder-bot 5966fa9044 WIP checkpoint: PC2: Basic visible player character representation 2026-08-21 00:06:54 +00:00
coder-bot d1d03df2e6 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-21 00:01:24 +00:00
architect-bot 4dda636ed0 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-21 00:01:15 +00:00
coder-bot 945c498a31 WIP checkpoint: PC2: Basic visible player character representation 2026-08-21 00:01:08 +00:00
reviewer-bot be0acb1862 Merge pull request 'PC2: Build a basic playable test level scene' (#48) from agent/0c49a36fc7db into main 2026-08-21 00:00:12 +00:00
coder-bot 8118f3074b WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 23:46:29 +00:00
architect-bot 4276a89a6a WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-20 23:31:27 +00:00
architect-bot 5803f47069 WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 23:19:05 +00:00
coder-bot e33b91d976 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 23:17:43 +00:00
coder-bot 1c3c57dbd0 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-20 23:17:09 +00:00
coder-bot fdc43bd5f1 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 23:01:55 +00:00
architect-bot 406ff3457e WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 22:50:48 +00:00
coder-bot 3f7acf857c WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 22:41:53 +00:00
coder-bot 6648b2bba5 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 22:31:46 +00:00
coder-bot bfcbd0486f WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 22:30:31 +00:00
architect-bot f70b5b2ee5 WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 22:28:06 +00:00
architect-bot 2fa7b1f475 WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 22:26:08 +00:00
coder-bot 7f753cb1b8 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 22:17:48 +00:00
coder-bot 1be947f9ef WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 22:05:30 +00:00
reviewer-bot 66adcff145 Merge pull request 'PC2: Design the test level & player spawn approach' (#46) from agent/a6deeb84eace into main 2026-08-20 21:58:55 +00:00
coder-bot 88197ffe8f WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-20 21:28:00 +00:00
coder-bot 38d97a3051 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 21:27:57 +00:00
coder-bot 174d3c2630 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-20 21:11:52 +00:00
coder-bot b86e4053bd WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 21:11:25 +00:00
coder-bot 07f655815f WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-20 21:09:33 +00:00
architect-bot 2ffcd9cd3f WIP checkpoint: PC2: Design the test level & player spawn approach 2026-08-20 21:09:30 +00:00
claude-checkpoint-tooling aeccc78143 Fix login form fields reappearing after Play -> Back (real playtest bug)
_show_authenticated() (the session-restore path, used when returning to
main_menu.tscn -- e.g. after clicking Back from world_list) only hid
EmailEdit/Over18Check, unlike _on_auth_complete() (the fresh-login path)
which hides the full form: UsernameEdit, PasswordEdit, EmailEdit,
Over18Check, AuthButton, AuthModeLabel. Play was correctly enabled and
the welcome message was correctly visible, so a check that stopped at
those two passed clean -- but the actual login fields were still on
screen, which is what a real user sees as "it's making me log in again."
Confirmed via live PC1 playtest today.

Reported by: real playtest, 2026-08-20 (yesterday's automated regression
test for this exact Play -> Back flow only asserted Play/welcome/auth
buttons row -- strengthened smoke_test_main_menu_back.gd to also check
the individual form fields, and confirmed it fails exactly this way with
the bug reintroduced before restoring the fix).

Verified against a live backend: 61/61 pytest (unaffected), 8/8 Godot
smoke tests (including 20/20 on the strengthened back-nav test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 19:21:40 +00:00
claude-checkpoint-tooling 1c64dd4967 Fix broken main_menu.gd on main + land Play->Back session persistence
Two things, found together while investigating a stuck blocker task:

1. URGENT: main_menu.gd currently fails to parse at all on main
   ("Identifier '_dob_edit' not declared in the current scope"). PR #44
   (hide-all-fields-on-auth-success) and PR #45 (DOBEdit -> Over18Check
   checkbox) each merged cleanly on their own -- Gitea's merge reported
   mergeable:true for both -- but together they left a reference to the
   old _dob_edit var on a line neither PR's diff touched, so no conflict
   was ever flagged. Same failure mode in smoke_test_main_menu_full_flow.gd
   (dob_edit, would have failed to even parse in the checkpoint suite).
   Fixed both to reference _over_18_check / over_18_check.

2. Lands the Play -> Back session-persistence fix (PR #43, task
   28357bca192d) that was stuck behind review the whole time main didn't
   have it yet: signal auth_state_ready, _show_authenticated() /
   _reset_to_logged_out() helpers, and _ready() restoring the
   authenticated UI (not resetting it) when ApiClient already holds a
   valid token. Plus the real fix a review round caught on top of that
   (task 9186405a6373): _show_authenticated() was hiding AuthPanel while
   claiming the welcome label inside it was visible -- a no-op UI-wise,
   since WelcomeLabel.visible=true does nothing once its parent is
   hidden. AuthPanel now stays visible; the regression test
   (smoke_test_main_menu_back.gd, new) asserts is_visible_in_tree(), not
   just .visible, so this class of bug can't pass silently again.

Verified against a live backend: 61/61 pytest, 7/7 Godot smoke tests
(including the new smoke_test_main_menu_back.gd, 12/12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 18:15:32 +00:00
reviewer-bot 4dd6c7c8af Merge pull request 'Main menu: hide the login/register form once authenticated, keep only the welcome message' (#44) from agent/470f875e0416 into main 2026-08-20 17:08:45 +00:00
reviewer-bot e90b394f03 Merge pull request 'Registration: replace date-of-birth field with a simple 18+ confirmation' (#45) from agent/f4c371c981ba into main 2026-08-20 16:51:43 +00:00
claude-checkpoint-tooling 33b0c3f1fb Merge main into agent/470f875e0416 to resolve PR #44 conflicts
# Conflicts:
#	scripts/main_menu.gd
2026-08-20 16:47:44 +00:00
coder-bot 2b88fc476e Merge remote WIP checkpoint; keep the verified over_18 fix (ModeToggle stays removed per PR #42)
# Conflicts:
#	backend/app/routers/auth.py
2026-08-20 16:27:41 +00:00
claude-checkpoint-tooling 0ecc16d963 Registration: replace date-of-birth field with a simple 18+ confirmation
The register form collected and validated an actual date of birth just to
throw it away (backend never stored it beyond a nullable column). Per the
roadmap note: don't collect/store DOB right now, just get a client-side
"I am 18 or older" confirmation. RegisterRequest.date_of_birth (str) is now
over_18 (bool); the endpoint rejects over_18=false with 400 same as before.
calculate_age() is kept (unused by /register now) since real DOB collection
is coming back in a future pass and deleting it broke test_auth_age_gate.py's
import for no benefit.

Frontend: DOBEdit LineEdit replaced with an Over18Check CheckBox; api_client.gd's
register() now takes a bool instead of a date string. All Godot callers
(smoke_test_auth/e2e/world_list/main_menu_ui/main_menu_full_flow) and backend
test files updated to match. Full suite verified against a live backend:
61/61 pytest, 6/6 Godot smoke tests (33 individual assertions), all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 16:25:27 +00:00
coder-bot 4ec71b87b9 WIP checkpoint: Registration: replace date-of-birth field with a simple 18+ confirmation 2026-08-20 15:10:43 +00:00
reviewer-bot dc0c133af8 Merge pull request 'Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle)' (#42) from agent/0b9e2c5ae6ec into main 2026-08-20 15:06:42 +00:00
coder-bot 8f1a1110e2 WIP checkpoint: Main menu: hide the login/register form once authenticated, keep only the welcome message 2026-08-20 15:01:02 +00:00
coder-bot 0f7531008f WIP checkpoint: Registration: replace date-of-birth field with a simple 18+ confirmation 2026-08-20 14:58:15 +00:00
coder-bot 12fef51020 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 14:03:57 +00:00
coder-bot af50819a16 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 14:01:20 +00:00
coder-bot 16bb87c6b0 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 13:56:44 +00:00
claude-checkpoint-tooling 8fc9d378e4 Fix: world cards render at ~1px wide once real data exists
Reported directly by the user after seeding a test world through the
browse-worlds screen: "screen is blank now". Two compounding layout
bugs, both real:

1. world_list.tscn's root VBoxContainer had no anchors at all, only
   size_flags -- but its parent (the scene's root node) is a plain
   Control, not a Container, so size_flags on a Control's direct child
   are meaningless there. Without anchors it never actually filled the
   screen, so it (and everything under it) collapsed toward zero size.

2. size_flags_horizontal/vertical used the raw integer 4 everywhere
   expand-fill was clearly intended (ScrollContainer, its WorldList
   child, HeaderHBox, TitleLabel) -- but 4 is Control.SIZE_SHRINK_CENTER
   in Godot 4, not SIZE_EXPAND_FILL (which is 3). ScrollContainer with
   SHRINK_CENTER takes only its own minimum size instead of expanding,
   cascading a ~1px width down through every card and every label.
   Confirmed the actual enum values by asking Godot directly rather
   than assuming.

Both are exactly the kind of bug a headless smoke test checking only
"did cards get added to the tree" can't catch -- child_count was
correctly non-zero, nothing crashed, the content was just rendered
invisibly thin. Verified the fix is real by deliberately reintroducing
bug #2 and confirming the test now correctly fails (card width 25px,
below the 200px sanity threshold) before restoring it.

Also replaced the card background: it was a ColorRect manually moved to
child index 0 of a VBoxContainer, which doesn't work as an overlay --
a VBoxContainer lays out ALL its children in sequence, so the "moved to
front" ColorRect rendered as an empty dark bar ABOVE the text, not
behind it. Now a proper PanelContainer with a StyleBoxFlat background,
the idiomatic way to get a colored panel behind auto-laid-out content
in Godot.

smoke_test_world_list.gd strengthened to actually catch this class of
bug going forward: seeds a real world via POST /api/worlds/ first (the
checkpoint runner's fresh DB meant the populated-card path never ran in
CI at all before this, only empty-list did), and asserts each card's
real computed width, not just that cards exist.

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests, both platform exports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:35:46 +00:00
claude-checkpoint-tooling 3679e4dbc4 Fix: Login/Register unreachable from the main menu -- no button opened it
Reported directly by the user playtesting the exported exe: Play was
permanently disabled and there was no way to log in or register. Root
cause: main_menu.tscn wired Play and Quit, but nothing was ever
connected to _on_login_button_pressed()/_on_register_button_pressed() --
the AuthPanel, its form, and all its backend-calling logic were fully
correct, just completely unreachable. The script's own top comment even
said "(the existing Play/Quit buttons)" as if Play/Quit somehow doubled
as login triggers, which they never did -- a sign nobody ever actually
clicked through the running scene, only checked things at the API level.

Fix: added a real AuthButtonsRow (LoginButton, RegisterButton) wired to
the existing handlers, which now hide themselves once auth succeeds.
Also fixed grab_focus() targeting the AuthPanel (which can't accept
focus, just a background warning) to focus the username field instead.

Verified via two new smoke tests (smoke_test_main_menu_ui.gd,
smoke_test_main_menu_full_flow.gd) using a *real signal emission* on
the buttons, not a direct method call -- that distinction is exactly
what would have caught the original bug. Full click-through confirmed
against a real backend: open register form, fill it, submit, Play
actually becomes enabled, welcome message shows, auth buttons hide.

Also documents a real headless-testing gotcha found while verifying
this: a synthetic Button.pressed.emit() on an await-using handler
doesn't reliably get driven forward in --script/SceneTree mode the way
real GUI input dispatch does -- confirmed by comparing direct-call vs
emit() behavior side by side. Not a bug in the actual click-driven flow,
a test-harness quirk to route around (call the handler directly,
awaited, instead of emitting the signal).

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests (including the 2 new ones, picked
up automatically by run_checkpoint_tests.py's glob).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:13:38 +00:00
claude-checkpoint-tooling c33b10ac46 Embed the .pck into the exported binary instead of a separate file
export_presets.cfg had binary_format/embed_pck=false on both presets,
so every export produced two files (agp-game.exe + agp-game.pck) that
have to stay together and correctly named or Godot refuses to start:
"Couldn't load project data ... Is the .pck missing?" -- hit for real
running a downloaded PC1 playtest exe today (only the .exe got copied
over, not the .pck).

Verified both platforms now produce a single self-contained binary:
Windows PE32+ (104,580,904 bytes, no separate .pck) and Linux ELF64
(71,140,152 bytes, no separate .pck).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:03:19 +00:00
claude-checkpoint-tooling 8c54129bcd Update PC1 playtest instructions for the new dashboard controls
Replaces the manual multi-step shell commands (docker compose, alembic,
uvicorn with env vars, manual teardown) with the one-click dashboard
flow at /checkpoint/PC1 in ~/agp (commit 261c45f): Start Backend / Stop
& Clean Up, and Build / Download a pre-configured Windows .exe. No
terminal commands needed on either machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:51:27 +00:00
reviewer-bot 409e78baa5 Merge pull request 'Update test_inventory.md to reflect PC1 checkpoint reality' (#40) from agent/398bf1a22a2d into main 2026-08-20 01:37:26 +00:00
architect-bot 1cc81979a4 Merge architect's per-file test detail into the checkpoint doc fix
Architect independently reclaimed this task after my fix landed and
rewrote the same doc with more precise per-file test descriptions and a
more specific gap list -- genuinely better in places (caught real detail
I'd summarized loosely) but its own investigation also introduced one
factual error (claimed 18 tests in test_auth_age_gate.py; verified via
pytest --collect-only, it's 17) and dropped two things worth keeping:
the exact commit hash for reproducibility, and the Client export section
(it never looked at the export step).

Merged: kept the architect's richer per-file coverage descriptions and
gap analysis, restored the verified counts/commit hash/export section/
--quit gotcha from my original.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:37:01 +00:00
agent-bot f8e5de9293 Merge pull request 'Add PC1 checkpoint record + playtest instructions' (#41) from docs/pc1-checkpoint-record into main 2026-08-20 01:17:30 +00:00
claude-checkpoint-tooling 3a7b2ab306 Add PC1 checkpoint record + playtest instructions
Validation results from the 2026-08-20 checkpoint run (fresh clone,
commit 927f9bd: migrations, 68/68 pytest, all 4 Godot smoke tests, both
platform exports) plus manual playtest instructions for backend on
cloudbison / client on pinkbison. First of docs/checkpoints/PC<N>.md,
one per Package Checkpoint going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:17:18 +00:00
claude-checkpoint-tooling afc2211d70 Update test_inventory.md to reflect PC1 checkpoint reality
Was written 2026-08-19 and badly stale by the time the full PC1
checkpoint validation ran 2026-08-20: said 38 backend tests (actually
68), said zero integration/endpoint tests exist (4 real integration
test files landed since), and said nothing had merged for Godot smoke
tests (4 real ones did).

Verified for real, fresh clone of main, commit 927f9bd: migrations +
68/68 pytest + all 4 Godot smoke tests + both platform exports (Linux
ELF64 + Windows PE32+, confirmed valid via file) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 00:47:18 +00:00
reviewer-bot 927f9bd95e Merge pull request 'End-to-end headless smoke test: register -> login -> fetch worlds' (#39) from agent/775379a9bedc into main 2026-08-20 00:26:58 +00:00
reviewer-bot 2d1f0111c3 Merge pull request 'Write auth endpoint tests (pytest)' (#38) from agent/daf820da4497 into main 2026-08-19 23:33:04 +00:00
tester-bot fcfb767c49 End-to-end headless smoke test: register -> login -> fetch worlds
Tester had already written a complete, correct test (4/4 steps pass
against a real backend) but stalled investigating DB/migration setup
after its own verification run silently exited with no result -- the
task spec's suggested invocation included --quit, which kills a --script
SceneTree test before its awaited HTTP calls finish (same Godot
--script-mode gotcha documented elsewhere this session).

Renamed smoke_test.gd -> smoke_test_e2e.gd: the task spec named it
without the checkpoint runner's expected smoke_test_*.gd suffix, so
scripts/run_checkpoint_tests.py's glob would have silently never picked
it up. Also fixed the --quit flag in the file's own usage comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:30:52 +00:00
tester-bot e2e771d2f8 WIP checkpoint: End-to-end headless smoke test + manual verify 2026-08-19 23:15:22 +00:00
reviewer-bot 8a5d3aabea Merge pull request 'Wire browse-worlds screen to GET /api/worlds' (#36) from agent/1de295a0da42 into main 2026-08-19 23:12:27 +00:00
coder-bot 3dd5416e81 Merge origin/main into agent/1de295a0da42
Resolves conflict in main_menu.gd's play button: kept this branch's real
world_list.tscn transition over main's stale TODO placeholder (main's
version predates this fix; nothing else has touched the play button
since). Brings in PR #37's /auth/refresh endpoint and its tests cleanly,
no other conflicts.
2026-08-19 23:11:05 +00:00
tester-bot c5a5476620 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:50:22 +00:00
tester-bot 06604a24a8 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:49:19 +00:00
reviewer-bot 5c21e5051c Merge pull request 'Implement /auth/refresh endpoint' (#37) from agent/82c3ebd03921 into main 2026-08-19 22:39:42 +00:00
coder-bot 3cb4d01073 Fix access token collision and cross-file username collision
- auth.py: _create_access_token had no jti claim, so two refreshes
  within the same wall-clock second produced byte-identical access
  tokens (same failure mode already fixed for refresh tokens earlier)
- test_refresh_endpoint.py: usernames (jwttoken, refreshuser, etc.)
  collided with test_auth_endpoints.py's own test usernames once run
  in the same pytest session against a shared DB with no per-test
  isolation -- exactly how run_checkpoint_tests.py invokes pytest.
  Namespaced this file's usernames with a refep_ prefix.

Full backend suite (63 tests) passes clean end-to-end against a real
Postgres with real migrations applied.
2026-08-19 22:15:55 +00:00
coder-bot 3e81d60268 WIP checkpoint: Implement /auth/refresh endpoint 2026-08-19 22:03:00 +00:00
coder-bot b3d2d71789 WIP checkpoint: Implement /auth/refresh endpoint 2026-08-19 21:58:48 +00:00
coder-bot b3227f5b78 Wire browse-worlds screen to GET /api/worlds
- main_menu.gd: Play button now navigates to world_list.tscn
- world_list.gd: fix invalid theme_override_constants property-path syntax
  (use add_theme_constant_override), fix @onready node paths missing
  /HeaderHBox/, cast Variant dict values to String to avoid type-inference
  errors when cards actually render
- world_list.tscn: wire up Back/Refresh button signal connections (scene
  had zero [connection] blocks, buttons were dead)
- backend: fix real bug where World.visibility enum sent member names
  ("PUBLIC") to Postgres instead of lowercase values, breaking both GET
  and POST /api/worlds/ for any real query
- backend: fix WorldResponse.created_at typed as str instead of datetime,
  causing a second 500 on GET once the enum bug was fixed
- add smoke_test_world_list.gd: headless scene-level smoke test that
  registers a user, instantiates the real scene, and confirms world cards
  render from live backend data (not just an API-level check)
2026-08-19 21:57:39 +00:00
coder-bot 2ba6bc6b9f WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:27:24 +00:00
reviewer-bot 7d89e860fd Merge pull request 'Implementation: Login/Register UI wired to /api/auth' (#35) from agent/1bc0bc15886d into main 2026-08-19 21:27:13 +00:00
coder-bot 03da22aa9e WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
coder-bot 55a65bb7b1 WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
coder-bot 62e15d0c87 WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
reviewer-bot cbe5e992dd Merge pull request 'Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions' (#33) from agent/424ce35e1b38 into main 2026-08-19 21:12:42 +00:00
reviewer-bot d4998d7359 Merge pull request 'Pin bcrypt<4.1 to fix passlib 1.7.4 compatibility' (#34) from fix/pin-bcrypt-version into main 2026-08-19 21:12:07 +00:00
coder-bot ef78caa4de Fix broken integration test fixture (dead imports, sync/async mismatch, cross-request connection reuse, username collision)
Verified end-to-end before this fix: the actual deliverable (login/register
UI + ApiClient wiring) already worked correctly -- confirmed via the real
Godot headless smoke test (scripts/smoke_test_auth.gd), 6/6 passing against
a live backend. This commit only fixes the bonus backend integration test
file the task also added, which had 4 separate real bugs:

1. Imported override_get_db/SessionLocal from app.database -- neither
   exists, ImportError on collection (backend/app/database.py only ever
   exposed get_db + the async engine/session factory).
2. Base.metadata.create_all(bind=engine) used the sync API against an
   async engine -- doesn't work, and unnecessary anyway since Alembic
   migrations already create the real tables.
3. Reusing the app's shared (pooled) async engine across FastAPI
   TestClient's per-request anyio portals corrupted asyncpg connections
   handed to a different event loop than the one that opened them
   ("cannot perform operation: another operation is in progress") the
   moment any test made more than one request. Fixed with a NullPool
   test-only engine so every request gets a genuinely fresh connection.
4. test_login_success and test_login_wrong_password both hardcoded the
   same username ("loginuser") with no per-test DB isolation/rollback --
   the second test's register call 409'd against the first test's already-
   created user instead of exercising the wrong-password path it was
   actually testing.

Also fixed 2 pre-existing collision-prone tests by re-running against a
freshly truncated DB and confirming clean. Full suite: 49/49 passing
(38 pre-existing + 11 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:11:00 +00:00
claude-checkpoint-tooling 87575eec43 Pin bcrypt<4.1 to fix passlib 1.7.4 compatibility
passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed. Every
hash_password/verify_password call was raising 'password cannot be longer
than 72 bytes' -- a misleading downstream symptom of the real
AttributeError: module 'bcrypt' has no attribute '__about__'.

Without this pin, pip install -r requirements.txt silently grabs whatever
the latest bcrypt is and breaks register/login outright -- confirmed this
broke 3 separate pieces of work this session (manual register curl tests,
and now a real Godot smoke test) purely from environment drift, not from
any actual code bug. Verified the fix works from a genuinely clean venv +
pip install, not just patching the already-installed shared venv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:06:41 +00:00
coder-bot 6d5051bbb2 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 20:23:02 +00:00
coder-bot 72f39bc547 WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:58:20 +00:00
coder-bot 69b6f40eb9 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
coder-bot 4d467c0c9e WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
coder-bot e43bc9ff10 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
coder-bot 5c91bb3f34 WIP checkpoint: Implementation: Login/Register UI wired to /api/auth 2026-08-19 19:51:31 +00:00
architect-bot ca7924d5e1 WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
architect-bot 758307bbee WIP checkpoint: Address review feedback: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot 478638d4ff WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot 7b074a01ed WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot c68877239f WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
coder-bot ed6811a438 WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions 2026-08-19 19:49:41 +00:00
reviewer-bot f64ffd6422 Merge pull request 'Write headless Godot export CI script' (#32) from agent/a7d462fe1322 into main 2026-08-19 17:16:46 +00:00
reviewer-bot 7e28e2b525 Merge pull request 'Add test inventory + checkpoint test runner' (#31) from docs/test-inventory-and-runner into main 2026-08-19 17:01:42 +00:00
coder-bot 99be8f1dab Write headless Godot export CI script
Adds export_presets.cfg (Linux/X11 + Windows Desktop, x86_64) -- didn't
exist at all before this, which is why the task got stuck: you can't run
godot --export-release without presets already defined, and Godot only
auto-generates this file interactively through the editor. Hand-wrote a
minimal valid one and verified both platforms actually export and produce
real binaries (not just that the command exits 0):
- export/linux/agp-game.x86_64 -- confirmed real ELF 64-bit executable via `file`
- export/windows/agp-game.exe -- 104MB PE binary

scripts/build.sh wraps both exports, documents the exact Godot version +
export template requirement, and was run end-to-end (not just written) to
confirm it works as a real CI entrypoint would invoke it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:54:50 +00:00
reviewer-bot b3e66fc072 Merge pull request 'Architecture: ApiClient autoload (HTTP + JWT + error contract)' (#29) from agent/5b3da417b50c into main 2026-08-19 15:45:01 +00:00
claude-checkpoint-tooling 634b2e39c0 Add test inventory + checkpoint test runner
docs/testing/test_inventory.md: honest, current list of what automated
coverage actually exists (38 backend pytest tests across 2 files, 0 Godot
tests merged yet) and what doesn't (no integration/endpoint tests at all,
no coverage yet for the in-review RS256/moderation work) -- verified
against a genuinely fresh checkout + fresh Postgres, not just read off the
file tree.

scripts/run_checkpoint_tests.py: runs the whole inventory in one shot --
throwaway Postgres, migrations, pytest, then starts the backend server and
runs every scripts/smoke_test_*.gd against it headlessly. Verified working
both with and without a smoke test present (temporarily tested against
PC1's not-yet-merged scripts/api_client.gd + smoke_test_api.gd, removed
before this commit -- that PR carries its own files).

docs/testing/README.md: what running this at a Package Checkpoint review
does and doesn't prove, and how to grow the inventory as real coverage
gets added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:18:49 +00:00
reviewer-bot 1c75367c87 Merge pull request 'Address review feedback: Plan: MVP Vertical Slice: Client-Server Wiring' (#28) from agent/76aa20cd2e45 into main 2026-08-19 15:11:39 +00:00
coder-bot f5a7162285 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-19 14:56:44 +00:00
coder-bot 61dedcf73e WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 14:47:50 +00:00
coder-bot 580b95635f WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 14:44:50 +00:00
architect-bot ef21bf9d69 Fix smoke test: resolve ApiClient via get_node, defer one frame before first request
Two Godot --script-mode-only quirks, neither a bug in ApiClient itself
(verified working against a real backend, health_check returns 200):
1. Bare autoload identifiers don't resolve at compile time in --script/
   SceneTree runs (the node exists at runtime, root.has_node() is true,
   but referencing it as a plain global name fails to compile) -- use
   get_node() instead.
2. A freshly add_child()'d HTTPRequest isn't reliably "inside tree" yet
   at _initialize() time, since --script mode hasn't processed a frame
   yet -- one process_frame await fixes it.

Verified: full smoke test now passes end-to-end against a live backend
(docker compose postgres + alembic migrations + uvicorn), confirming
ApiClient.health_check() genuinely round-trips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:40:51 +00:00
architect-bot e06cd2a23d WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:31:35 +00:00
architect-bot 77f9b74286 WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:12:56 +00:00
planner-bot 6238078565 WIP checkpoint: Address review feedback: Plan: MVP Vertical Slice: Client-Server Wiring 2026-08-19 14:12:45 +00:00
architect-bot 6108c63c46 WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
architect-bot 109c6ef1a0 WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
architect-bot 33c5236a2d WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
architect-bot 4bb4e9fb8a WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) 2026-08-19 14:11:48 +00:00
architect-bot ffbc4849a7 Recovered orphaned WIP before switching to a new task 2026-08-19 14:11:47 +00:00
coder-bot a81d29c882 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 14:11:03 +00:00
coder-bot 4236c4792b WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 13:59:23 +00:00
reviewer-bot a29cfc74af Merge pull request 'US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction' (#27) from agent/25d5046bb30c into main 2026-08-19 13:58:16 +00:00
coder-bot cf8fd5a6fd WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 13:45:39 +00:00
coder-bot fc9a39de93 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 13:38:29 +00:00
reviewer-bot aa2f79b125 Merge pull request 'Plan: MVP Vertical Slice: Client-Server Wiring' (#26) from agent/21694e349045 into main 2026-08-19 13:13:32 +00:00
planner-bot 8551d14591 docs: MVP vertical slice planning doc for client-server wiring
- Architecture task: ApiClient autoload (HTTP + JWT + error contract)
- Implementation task: Login/Register UI wired to /api/auth
- Implementation task: Browse worlds screen wired to GET /api/worlds
- Validation task: End-to-end smoke test (login + list worlds)
2026-08-19 12:39:33 +00:00
architect-bot 073813f3e7 WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:37:46 +00:00
architect-bot cc971ee9f5 WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:32:46 +00:00
architect-bot 54abe7ee96 WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:32:06 +00:00
coder-bot a7cf385d0d WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:31:05 +00:00
coder-bot b5525665dd WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:31:05 +00:00
agent-bot ff610c0466 Merge pull request 'Fix migration 0001_initial double CREATE TYPE + port collision' (#25) from fix/migration-0001-double-enum-and-port-collision into main 2026-08-18 21:57:01 +00:00
claude-mvp-fix acbc518b4a Fix migration 0001_initial: double CREATE TYPE crashes every clean bootstrap
world_visibility and asset_type were each declared twice: once explicitly
via `.create(checkfirst=True)`, and again implicitly by SQLAlchemy when
create_table() compiled the inline postgresql.ENUM() column type (which
defaults create_type=True). On a genuinely empty database this raises
asyncpg.DuplicateObjectError mid-transaction and the migration never
completes -- verified this was true 100% of the time from a fresh
container, meaning the backend has never successfully bootstrapped from
scratch. Fixed by passing create_type=False on the inline column
declarations, since the type is already created explicitly above.

Also parameterized both services' host ports (POSTGRES_HOST_PORT /
REDIS_HOST_PORT, defaulting to the original 5432/6379) so concurrent
agent workspaces sharing this same docker-compose.yml can run isolated
databases without colliding on the host port.
2026-08-18 21:55:34 +00:00
architect-bot 36fd406925 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 16:44:02 +00:00
coder-bot ddfe5dfaad Recovered orphaned WIP before switching to a new task 2026-08-18 15:54:33 +00:00
coder-bot dcaaf96d81 WIP checkpoint: fix static registry, uint32 type, GLTF API calls, ARRAY_BONES, missing slot handler
- avatar_slot_registry.gd: _slot_data/_slot_definitions made static (every
  caller uses AvatarSlotRegistry.method() on the class, never an instance,
  so the old instance _init() populating them was never invoked); moved
  registry build to static func _static_init()
- avatar_catalog.gd: compute_loadout_hash() return type uint32 -> int
  (GDScript has no fixed-width uint type)
- avatar_loader.gd: GLTFDocument.parse() (doesn't exist) -> the real
  append_from_buffer()+generate_scene() API; Error.parse_error() (doesn't
  exist) -> error_string(); Mesh.ARRAY_JOINTS (doesn't exist) ->
  Mesh.ARRAY_BONES; removed a dead 'mesh is MultiMesh' check that can
  never be true (MeshInstance3D.mesh is always Mesh, never MultiMesh);
  fixed _merge_node_into_avatar to recurse into a Skeleton3D's children
  (skinned meshes are commonly children of the skeleton in a GLB export,
  the old code returned before ever reaching them)
- avatar_customization_ui.gd: added the missing _on_slot_button_pressed
  handler (referenced by a button connect, never defined -- just calls
  the existing _select_slot())

Verified: godot --headless --import + --quit is fully clean, and the
avatar_customization.tscn scene now instantiates successfully.

Remaining, scoped separately: avatar_visualizer.gd calls a
AvatarLoader.load_slot() static async API and SlotItem/SlotResult types
that don't exist -- AvatarLoader's real API (matching the project's own
avatar.md spec: one canonical skeleton, attachments retargeted onto it)
is instance-based load_base_body()/load_attachment(). AvatarVisualizer
needs rewriting to use that real API; it's the one out of step with spec,
not AvatarLoader.
2026-08-18 14:47:08 +00:00
coder-bot 803405aba4 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 14:03:07 +00:00
coder-bot ea6958c11e Recovered orphaned WIP before switching to a new task 2026-08-18 13:50:01 +00:00
coder-bot 61ee81a974 WIP checkpoint: fix script-attach typo, missing class_name, var name mismatch
- scenes/avatar_customization.tscn: script_extense -> script (typo meant
  the customization UI script was never actually attached to the scene)
- scripts/avatar/avatar_loadout.gd: add missing class_name AvatarLoadout
  (avatar_system.gd references the type everywhere; only this file among
  the avatar scripts lacked its class_name)
- scripts/main_menu.gd: _ready() referenced _customize_button/_settings_button/
  _quit_button but the @onready vars are named without the leading
  underscore
2026-08-18 13:44:41 +00:00
coder-bot 62f12e55db WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 13:09:57 +00:00
coder-bot 4c0af03573 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 13:01:14 +00:00
coder-bot 689c51e179 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 00:47:59 +00:00
coder-bot 5cfbcd8efd WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 00:36:39 +00:00
coder-bot 27461d9b5e WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-18 00:25:17 +00:00
coder-bot a5dc5cb3b6 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:20:39 +00:00
coder-bot ea5dd63cd7 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:10:56 +00:00
coder-bot 5ebd60b0bb WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:04:48 +00:00
coder-bot b2a64a8933 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:00:37 +00:00
coder-bot 9fcea730b9 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 22:00:14 +00:00
coder-bot 5b3fc10649 WIP checkpoint: Implement avatar loader and customization UI in Godot 2026-08-17 21:59:50 +00:00
architect-bot 6018396be2 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:26:42 +00:00
architect-bot ee6513b8ad Recovered orphaned WIP before switching to a new task 2026-08-17 18:16:22 +00:00
architect-bot 37a311b0c5 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:15:38 +00:00
agent-bot 11e812cb6b WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 5097cdd9f4 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 2224c34aa8 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 2c5f6fcb04 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 7cd41dc920 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot c5d2eb6bd2 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot b650572426 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 7d84dd5ec8 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
195 changed files with 20070 additions and 502 deletions
+39
View File
@@ -0,0 +1,39 @@
name: checkpoint
# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke
# tests, multiplayer, fresh-clone boot) on every push and PR.
#
# Runs on a `host`-label act_runner on the AGP box: Docker, ~/godot/godot,
# and Python 3 are already there. Deliberately uses no JavaScript actions
# (actions/checkout etc.) so the host needs no Node install -- checkout is a
# plain git clone from the local Gitea. See docs/ci.md.
on:
push:
branches: [main]
pull_request:
jobs:
checkpoint:
runs-on: host
steps:
- name: Checkout
run: |
set -eu
rm -rf repo && mkdir repo && cd repo
git init -q
git remote add origin "http://x:${{ github.token }}@localhost:3000/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Python env
working-directory: repo
run: |
set -eu
python3 -m venv .venv
.venv/bin/pip install --quiet --upgrade pip
.venv/bin/pip install --quiet -r backend/requirements.txt
- name: Checkpoint suite
working-directory: repo
run: .venv/bin/python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690
+4
View File
@@ -8,3 +8,7 @@ export/
# OS
.DS_Store
Thumbs.db
# Godot UID files (regenerated from path)
*.uid
assets/build_info.json
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bm01jsuomi50i"
path="res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"
[deps]
source_file="res://assets/avatars/accessory_belt_01.glb"
dest_files=["res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dvou487niwnjt"
path="res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"
[deps]
source_file="res://assets/avatars/accessory_necklace_01.glb"
dest_files=["res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dknbtq33pssf4"
path="res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"
[deps]
source_file="res://assets/avatars/back_backpack_01.glb"
dest_files=["res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://brfp6g6pcbbw7"
path="res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"
[deps]
source_file="res://assets/avatars/base_human_male_01.glb"
dest_files=["res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
+115
View File
@@ -0,0 +1,115 @@
{
"assets": {
"base_human_male_01": {
"type": "base_body",
"slot": "base_body",
"path": "res://assets/avatars/base_human_male_01.glb",
"version": "1.0.0",
"display_name": "Human Male Base"
},
"head_cyber_01": {
"type": "attachment",
"slot": "head",
"path": "res://assets/avatars/head_cyber_01.glb",
"version": "1.0.0",
"display_name": "Cyber Head",
"mount_bone": "Head"
},
"hair_mohawk_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_mohawk_01.glb",
"version": "1.0.0",
"display_name": "Mohawk",
"mount_bone": "HeadTop"
},
"hair_ponytail_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_ponytail_01.glb",
"version": "1.0.0",
"display_name": "Ponytail",
"mount_bone": "HeadTop"
},
"face_glasses_01": {
"type": "attachment",
"slot": "face",
"path": "res://assets/avatars/face_glasses_01.glb",
"version": "1.0.0",
"display_name": "Glasses",
"mount_bone": "Jaw"
},
"chest_bandit_01": {
"type": "attachment",
"slot": "chest",
"path": "res://assets/avatars/chest_bandit_01.glb",
"version": "1.0.0",
"display_name": "Bandit Shirt",
"mount_bone": "Chest"
},
"back_backpack_01": {
"type": "attachment",
"slot": "back",
"path": "res://assets/avatars/back_backpack_01.glb",
"version": "1.0.0",
"display_name": "Backpack",
"mount_bone": "Chest"
},
"pants_cargo_01": {
"type": "attachment",
"slot": "pants",
"path": "res://assets/avatars/pants_cargo_01.glb",
"version": "1.0.0",
"display_name": "Cargo Pants",
"mount_bone": "Hips"
},
"left_hand_glove_01": {
"type": "attachment",
"slot": "left_hand",
"path": "res://assets/avatars/left_hand_glove_01.glb",
"version": "1.0.0",
"display_name": "Left Glove",
"mount_bone": "LeftHand"
},
"right_hand_glove_01": {
"type": "attachment",
"slot": "right_hand",
"path": "res://assets/avatars/right_hand_glove_01.glb",
"version": "1.0.0",
"display_name": "Right Glove",
"mount_bone": "RightHand"
},
"left_foot_boot_01": {
"type": "attachment",
"slot": "left_foot",
"path": "res://assets/avatars/left_foot_boot_01.glb",
"version": "1.0.0",
"display_name": "Left Boot",
"mount_bone": "LeftFoot"
},
"right_foot_boot_01": {
"type": "attachment",
"slot": "right_foot",
"path": "res://assets/avatars/right_foot_boot_01.glb",
"version": "1.0.0",
"display_name": "Right Boot",
"mount_bone": "RightFoot"
},
"accessory_belt_01": {
"type": "attachment",
"slot": "accessory",
"path": "res://assets/avatars/accessory_belt_01.glb",
"version": "1.0.0",
"display_name": "Belt",
"mount_bone": "Root"
},
"accessory_necklace_01": {
"type": "attachment",
"slot": "accessory",
"path": "res://assets/avatars/accessory_necklace_01.glb",
"version": "1.0.0",
"display_name": "Necklace",
"mount_bone": "Root"
}
}
}
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bva2etlpa8act"
path="res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"
[deps]
source_file="res://assets/avatars/chest_bandit_01.glb"
dest_files=["res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ctwcxf552y325"
path="res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"
[deps]
source_file="res://assets/avatars/face_glasses_01.glb"
dest_files=["res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://j4dc6l3l0j6f"
path="res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"
[deps]
source_file="res://assets/avatars/hair_mohawk_01.glb"
dest_files=["res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dp4vqp2trrank"
path="res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"
[deps]
source_file="res://assets/avatars/hair_ponytail_01.glb"
dest_files=["res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bkp35fnns36xb"
path="res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"
[deps]
source_file="res://assets/avatars/head_cyber_01.glb"
dest_files=["res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bgfjpdu8kg0aj"
path="res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"
[deps]
source_file="res://assets/avatars/left_foot_boot_01.glb"
dest_files=["res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dtq7tgio1iymd"
path="res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"
[deps]
source_file="res://assets/avatars/left_hand_glove_01.glb"
dest_files=["res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://yo204q1m6hs3"
path="res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"
[deps]
source_file="res://assets/avatars/pants_cargo_01.glb"
dest_files=["res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://30ij0eapm6ti"
path="res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"
[deps]
source_file="res://assets/avatars/right_foot_boot_01.glb"
dest_files=["res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bcrtxtp47dv8h"
path="res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"
[deps]
source_file="res://assets/avatars/right_hand_glove_01.glb"
dest_files=["res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
+40
View File
@@ -0,0 +1,40 @@
{
"objects": {
"crate_small": {
"asset_id": "props/crate_small.glb",
"type": "prop",
"display_name": "Small Crate",
"category": "props"
},
"rock_01": {
"asset_id": "props/rock_01.glb",
"type": "prop",
"display_name": "Rock",
"category": "nature"
},
"tree_pine_01": {
"asset_id": "props/tree_pine_01.glb",
"type": "prop",
"display_name": "Pine Tree",
"category": "nature"
},
"lamp_post_01": {
"asset_id": "props/lamp_post_01.glb",
"type": "prop",
"display_name": "Lamp Post",
"category": "props"
},
"chest_01": {
"asset_id": "props/chest.glb",
"type": "interactable",
"display_name": "Treasure Chest",
"category": "interactables"
},
"spawn_point": {
"asset_id": "",
"type": "spawn_point",
"display_name": "Spawn Point",
"category": "gameplay"
}
}
}
@@ -0,0 +1,17 @@
{
"content_flags": {
"violence": "none",
"mature": false
},
"rules": [
{
"trigger": {"type": "on_interact"},
"conditions": [],
"actions": [
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
{"verb": "play_sound", "sound_id": "chest_open"},
{"verb": "rotate", "target": "self", "degrees": 90, "axis": "y", "duration": 0.4}
]
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"templates": {
"small_crate": {
"asset_id": "props/crate_small.glb",
"display_name": "Small Crate"
}
}
}
+7 -4
View File
@@ -4,10 +4,13 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT
SECRET_KEY=change-me-to-a-secure-random-string
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# JWT / Auth
ALGORITHM=RS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
# Directory where the RSA keypair is loaded/generated at runtime.
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
RSA_KEY_DIR=~/.agp/keys
# App
APP_NAME=AGP Backend
+1 -1
View File
@@ -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, report, moderation_action, appeal # 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.
+3 -2
View File
@@ -69,7 +69,8 @@ def upgrade() -> None:
sa.Column(
"visibility",
postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility"
"public", "private", "friends_only", name="world_visibility",
create_type=False,
),
nullable=True,
),
@@ -89,7 +90,7 @@ def upgrade() -> None:
"asset_type",
postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other",
name="asset_type",
name="asset_type", create_type=False,
),
nullable=False,
),
@@ -0,0 +1,36 @@
"""add refresh_tokens table
Revision ID: 0003_add_refresh_tokens_table
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-07-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0003_add_refresh_tokens_table"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the refresh_tokens table."""
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
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),
)
def downgrade() -> None:
"""Drop the refresh_tokens table."""
op.drop_table("refresh_tokens")
@@ -0,0 +1,39 @@
"""add avatar_loadouts table
Revision ID: 0004_add_avatar_loadouts_table
Revises: 0003_add_refresh_tokens_table
Create Date: 2026-09-05 00:00:00.000000
"""
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 = "0004_add_avatar_loadouts_table"
down_revision: Union[str, None] = "0003_add_refresh_tokens_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the avatar_loadouts table (one row per user)."""
op.create_table(
"avatar_loadouts",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("data", postgresql.JSONB(), nullable=False),
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),
)
def downgrade() -> None:
"""Drop the avatar_loadouts table."""
op.drop_table("avatar_loadouts")
@@ -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")
+121 -5
View File
@@ -1,10 +1,56 @@
"""Application configuration via pydantic-settings."""
from functools import lru_cache
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey,
RSAPublicKey,
generate_private_key,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
load_pem_public_key,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
"""Load existing RSA keys or generate a new pair.
Keys are stored as PEM files in *key_dir*. The directory is created
if it does not exist. Files are named ``private.pem`` and
``public.pem`` and are **not** committed to git.
"""
private_path = key_dir / "private.pem"
public_path = key_dir / "public.pem"
if private_path.exists() and public_path.exists():
private_pem = private_path.read_bytes()
public_pem = public_path.read_bytes()
else:
# Generate a new 2048-bit RSA key pair
private_key = generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
Encoding.PEM,
PrivateFormat.PKCS8,
NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
Encoding.PEM,
PublicFormat.SubjectPublicKeyInfo,
)
key_dir.mkdir(parents=True, exist_ok=True)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
return private_pem, public_pem
class Settings(BaseSettings):
# App
APP_NAME: str = "AGP Backend"
@@ -18,18 +64,62 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "RS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# RS256 key storage. Defaults to a directory outside the repo so the
# generated keypair is never accidentally committed.
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
# CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
# Per-IP auth rate limiting (app/rate_limit.py). Disabled by the
# checkpoint smoke suite, which runs every test from one client IP.
RATE_LIMIT_ENABLED: bool = True
# File uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100
GLTF_MAX_FILE_SIZE_MB: int = 200
# ── Game-server orchestration ──────────────────────────────────────────
# The join flow (POST /api/worlds/{id}/join) ensures a headless Godot
# dedicated-server process exists for a world and returns its address.
# Disabled by default so nothing tries to spawn Godot in environments
# that don't have it (plain pytest, CI without the engine); the
# checkpoint/playtest launcher turns it on.
GAME_SERVERS_ENABLED: bool = False
# Path to the Godot 4 binary. "godot" resolves via PATH.
GODOT_BIN: str = str(Path.home() / "godot" / "godot")
# Godot project directory (the game repo root -- backend/ is a subdir).
GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2])
# The host clients should connect to for ENet. Defaults to the LAN IP the
# launcher bakes into the exe; overridable for local testing.
GAME_SERVER_HOST: str = "127.0.0.1"
# Port range for per-world dedicated servers.
GAME_SERVER_PORT_MIN: int = 27000
GAME_SERVER_PORT_MAX: int = 27099
# 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",
@@ -41,6 +131,32 @@ class Settings(BaseSettings):
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@property
def rsa_key_dir(self) -> Path:
return Path(self.RSA_KEY_DIR)
@property
def private_pem(self) -> bytes:
"""Return the private key PEM bytes (loads/generates on first call)."""
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
return private_pem
@property
def public_pem(self) -> bytes:
"""Return the public key PEM bytes (loads/generates on first call)."""
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
return public_pem
@property
def private_key(self) -> RSAPrivateKey:
"""Return the deserialized RSA private key object."""
return load_pem_private_key(self.private_pem, password=None)
@property
def public_key(self) -> RSAPublicKey:
"""Return the deserialized RSA public key object."""
return load_pem_public_key(self.public_pem)
@lru_cache()
def get_settings() -> Settings:
+1 -1
View File
@@ -27,7 +27,7 @@ async def get_current_user(
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
token, settings.public_pem, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
+9 -3
View File
@@ -6,15 +6,18 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets
from app.routers import auth, users, worlds, assets, avatar, world_content, internal
from app.services.game_servers import supervisor
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown events."""
# Startup: initialize database, connect to Redis, etc.
supervisor.start()
yield
# Shutdown: close connections, cleanup
await supervisor.shutdown()
app = FastAPI(
@@ -37,7 +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")
+6 -4
View File
@@ -2,8 +2,10 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.report import Report
from app.models.moderation_action import ModerationAction
from app.models.appeal import Appeal
from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
]
-67
View File
@@ -1,67 +0,0 @@
"""Appeal model for moderation actions."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Enum,
ForeignKey,
Integer,
String,
Text,
DateTime,
CheckConstraint,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Appeal(Base):
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, index=True)
moderation_action_id = Column(
Integer,
ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
reason = Column(Text, nullable=False)
status = Column(
Enum("pending", "approved", "rejected", name="appeal_status", create_type=False),
nullable=False,
default="pending",
)
reviewed_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
# Relationships
moderation_action = relationship("ModerationAction", back_populates="appeals")
reviewer = relationship(
"User",
foreign_keys=[reviewed_by],
back_populates="reviewed_appeals",
)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'approved', 'rejected')",
name="check_appeal_status",
),
)
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# The default a brand-new account gets before they touch the customization
# screen. Kept in sync with assets/avatars/catalog.json (the base_body id) and
# with the client's AvatarLoadout.to_json() shape.
DEFAULT_LOADOUT: dict = {
"base_body": "base_human_male_01",
"slots": {},
}
class AvatarLoadout(Base):
"""A user's equipped avatar composition.
One row per user. The whole loadout is stored as a single JSON document
matching the client's ``AvatarLoadout.to_json()`` shape
(``{"base_body": str, "slots": {...}, "skin_tone"?: {...}}``) rather than
exploded into columns -- the server has no copy of the asset catalog, so
deep validation (does this asset exist, is it valid for this slot) stays
on the client. The server only enforces the envelope: valid JSON object
with a non-empty ``base_body``.
"""
__tablename__ = "avatar_loadouts"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
data = Column(JSONB, nullable=False)
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)
user = relationship("User", back_populates="avatar_loadout")
-118
View File
@@ -1,118 +0,0 @@
"""Moderation action model."""
from datetime import datetime
from enum import Enum as PyEnum
from typing import Optional
from sqlalchemy import (
Column,
Enum,
ForeignKey,
Integer,
String,
Text,
DateTime,
func,
)
from sqlalchemy.dialects.postgresql import TIMESTAMPTZ
from sqlalchemy.orm import relationship
from app.database import Base
class TargetType(str, PyEnum):
"""Type of entity being reported or moderated."""
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, PyEnum):
"""Category of report reason."""
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, PyEnum):
"""Status of a report."""
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, PyEnum):
"""Resolution outcome of a report."""
ACTION_TAKEN = "action_taken"
NO_ACTION = "no_action"
class ModerationActionType(str, PyEnum):
"""Type of moderation action."""
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationAction(Base):
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, index=True)
moderator_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
target_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
target_type = Column(
Enum(TargetType, name="target_type_enum", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
action = Column(
Enum(ModerationActionType, name="moderation_action_type_enum", create_type=False),
nullable=False,
)
reason = Column(Text, nullable=True)
report_id = Column(
Integer,
ForeignKey("reports.id", ondelete="SET NULL"),
nullable=True,
)
expires_at = Column(TIMESTAMPTZ, nullable=True)
revoked_at = Column(TIMESTAMPTZ, nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
)
# Relationships
moderator = relationship(
"User",
foreign_keys=[moderator_id],
back_populates="moderation_actions_given",
)
target_user = relationship(
"User",
foreign_keys=[target_user_id],
back_populates="moderation_actions_received",
)
report = relationship("Report", back_populates="moderation_actions")
appeals = relationship(
"Appeal",
back_populates="moderation_action",
cascade="all, delete-orphan",
)
-146
View File
@@ -1,146 +0,0 @@
"""Report model for user-generated moderation reports."""
from datetime import datetime
from typing import Optional
import sqlalchemy as sa
from sqlalchemy import (
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.dialects.postgresql import TIMESTAMPTZ
from sqlalchemy.orm import relationship
from app.database import Base
class ReportStatus:
"""Valid statuses for a Report."""
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution:
"""Possible resolution outcomes for a Report."""
ACTION_TAKEN = "action_taken"
NO_ACTION = "no_action"
class TargetType:
"""Types of entities that can be reported."""
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory:
"""Categories for why something was reported."""
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
reporter_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
target_type = Column(
Enum(
TargetType.USER,
TargetType.WORLD,
TargetType.ASSET,
TargetType.CHAT_MESSAGE,
name="target_type",
create_type=False,
),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
Enum(
ReasonCategory.HARASSMENT,
ReasonCategory.HATE_SPEECH,
ReasonCategory.SEXUAL_CONTENT,
ReasonCategory.VIOLENCE,
ReasonCategory.SPAM,
ReasonCategory.SELF_HARM,
ReasonCategory.OTHER,
name="reason_category",
create_type=False,
),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
Enum(
ReportStatus.OPEN,
ReportStatus.IN_REVIEW,
ReportStatus.RESOLVED,
ReportStatus.DISMISSED,
name="report_status",
create_type=False,
),
nullable=False,
default=ReportStatus.OPEN,
)
resolved_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
resolution = Column(
Enum(
Resolution.ACTION_TAKEN,
Resolution.NO_ACTION,
name="resolution",
create_type=False,
),
nullable=True,
)
created_at = Column(
TIMESTAMPTZ,
nullable=False,
server_default=sa.func.now(),
)
updated_at = Column(
TIMESTAMPTZ,
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
)
resolved_at = Column(TIMESTAMPTZ, nullable=True)
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolver = relationship("User", foreign_keys=[resolved_by])
moderation_actions = relationship(
"ModerationAction",
back_populates="report",
cascade="all, delete-orphan",
)
appeals = relationship(
"Appeal",
back_populates="report",
cascade="all, delete-orphan",
)
+21
View File
@@ -3,6 +3,7 @@ import uuid
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -18,3 +19,23 @@ class Session(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions")
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# RS256 JWTs are ~420+ chars; 512 is sufficient and matches the migration.
token = Column(String(512), unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, nullable=False, default=True, server_default="true")
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
user = relationship("User", back_populates="refresh_tokens")
+7 -24
View File
@@ -1,20 +1,13 @@
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Enum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class UserRole:
"""Valid user roles."""
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -24,28 +17,18 @@ class User(Base):
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
role = Column(
Enum(UserRole.USER, UserRole.MODERATOR, UserRole.ADMIN, name="user_role", create_type=False),
nullable=False,
default=UserRole.USER,
)
banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
moderation_actions_given = relationship(
"ModerationAction",
foreign_keys="ModerationAction.moderator_id",
back_populates="moderator",
)
moderation_actions_received = relationship(
"ModerationAction",
foreign_keys="ModerationAction.target_user_id",
back_populates="target_user",
avatar_loadout = relationship(
"AvatarLoadout",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
@property
+9 -1
View File
@@ -21,7 +21,12 @@ class World(Base):
description = Column(Text, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
visibility = Column(
ENUM(WorldVisibility, name="world_visibility", create_type=False),
ENUM(
WorldVisibility,
name="world_visibility",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
default=WorldVisibility.PUBLIC,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -29,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")
+153
View File
@@ -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)
+73
View File
@@ -0,0 +1,73 @@
"""Lightweight in-process rate limiting.
The backend runs single-worker (required by the game-server supervisor), so a
process-local sliding-window counter is sufficient and needs no Redis. Keyed
by client IP. Used to blunt credential brute-force on the auth endpoints.
If the backend is ever scaled to multiple workers/hosts this must move to a
shared store (Redis) -- flagged here rather than silently under-enforcing.
"""
from __future__ import annotations
import time
from collections import defaultdict, deque
from fastapi import HTTPException, Request, status
from app.config import settings
class SlidingWindowLimiter:
def __init__(self, max_hits: int, window_seconds: float) -> None:
self.max_hits = max_hits
self.window = window_seconds
self._hits: dict[str, deque[float]] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
q = self._hits[key]
cutoff = now - self.window
while q and q[0] < cutoff:
q.popleft()
if len(q) >= self.max_hits:
retry = max(1, int(q[0] + self.window - now))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts. Try again shortly.",
headers={"Retry-After": str(retry)},
)
q.append(now)
def reset(self) -> None:
self._hits.clear()
def _client_key(request: Request) -> str:
# X-Forwarded-For first hop when behind the reverse proxy, else the socket.
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else "unknown"
# 10 auth attempts / minute / IP -- generous for a human, painful for a
# brute-forcer.
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
async def auth_rate_limit(request: Request) -> None:
"""FastAPI dependency: rate-limit auth attempts per client IP.
A no-op when ``RATE_LIMIT_ENABLED`` is false -- the checkpoint smoke
suite runs every test against one backend from one client IP, so the
per-IP cap would trip mid-suite. The dedicated ``test_rate_limit``
module exercises the real behaviour regardless of this flag.
"""
if not settings.RATE_LIMIT_ENABLED:
return
_auth_limiter.check(f"auth:{_client_key(request)}")
def reset_all() -> None:
"""Test hook."""
_auth_limiter.reset()
+36 -2
View File
@@ -1,5 +1,6 @@
"""Asset management router."""
import json
import os
from pathlib import Path
from typing import Optional
@@ -13,6 +14,7 @@ from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType
from app.models.world import World
from app.services import gltf as gltf_service
from pydantic import BaseModel, Field
router = APIRouter()
@@ -64,6 +66,14 @@ def _sanitize_filename(filename: str) -> str:
return safe or "file"
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
"""Detect the asset type from filename and content."""
if filename and gltf_service.looks_like_gltf(content, filename):
return AssetType.MODEL_3D
# Fallback: let the caller decide based on mime_type or default.
return AssetType.OTHER
# --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201)
@@ -183,10 +193,16 @@ async def upload_asset_file(
# Sanitize filename to prevent path traversal.
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
# Validate file size against configured maximum.
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
# Read the file content.
content = await file.read()
file_size = len(content)
# Determine which size limit to apply.
if gltf_service.looks_like_gltf(content, file.filename):
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
else:
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
if file_size > max_bytes:
raise HTTPException(
status_code=413,
@@ -200,10 +216,28 @@ async def upload_asset_file(
file_path = upload_dir / stored_name
file_path.write_bytes(content)
# Detect asset type from content/filename.
detected_type = _detect_asset_type(file.filename, content)
# Update asset record.
asset.file_path = str(file_path)
asset.mime_type = file.content_type
asset.file_size = file_size
# If this is a glTF file, extract and store metadata.
if detected_type == AssetType.MODEL_3D:
try:
metadata = gltf_service.extract_gltf_metadata(content)
asset.metadata_json = json.dumps(metadata)
# Also set the asset type to model_3d if it was auto-detected.
if asset.asset_type == AssetType.OTHER:
asset.asset_type = AssetType.MODEL_3D
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f"Invalid glTF file: {exc}",
)
await db.commit()
await db.refresh(asset)
return asset
+179 -33
View File
@@ -1,55 +1,61 @@
"""Authentication router - login, register, token refresh."""
import uuid
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, field_validator
from jose import jwt
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr
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.session import RefreshToken
from app.models.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.rate_limit import auth_rate_limit
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
## Minimum age to register. This platform is adults-only.
MIN_AGE = 18
# --- Pydantic schemas ---
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
access_token_expires_minutes: int
class RefreshRequest(BaseModel):
refresh_token: str
class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
# Real date of birth -- the server computes age from it and rejects
# anyone under MIN_AGE. Still self-attested (no document check yet), but
# a concrete, stored, legally-meaningful data point rather than a
# throwaway checkbox, and the seam for a future hard ID-verification
# pass. See docs/architecture/content_moderation.md 5.2.
date_of_birth: date
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
date_of_birth: date | None = None
is_active: bool
created_at: datetime | None = None
@@ -59,12 +65,43 @@ class UserResponse(BaseModel):
# --- Helpers ---
def _create_access_token(data: dict) -> str:
"""Create a signed access token using RS256.
Includes a unique ``jti`` claim so that concurrent/same-second calls
(e.g. back-to-back /refresh requests) still produce distinct tokens —
without it, two tokens minted for the same user within the same
second are byte-identical since ``sub``/``exp`` are the only claims.
"""
to_encode = data.copy()
to_encode["jti"] = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def _create_refresh_token(data: dict) -> str:
"""Create a signed refresh token using RS256.
Includes a unique ``jti`` claim so that concurrent calls produce
distinct tokens even when issued within the same second.
"""
to_encode = data.copy()
to_encode["jti"] = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -80,6 +117,10 @@ def hash_password(password: str) -> str:
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Used by /register (PC3 Phase 3) to compute age from the submitted
date_of_birth and enforce the 18+ gate. See
docs/architecture/content_moderation.md 5.2.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
@@ -97,22 +138,41 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
return age
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
"""Create and persist a refresh token for the given user."""
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
refresh_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user_id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc)
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
db.add(refresh_token_record)
return refresh_token_value
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(auth_rate_limit)])
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token.
"""Register a new user and return access + refresh tokens.
Requires date_of_birth and rejects users under 18.
Requires a date of birth; the server computes age and rejects anyone
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
verification design and its self-attestation limitation.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
age = calculate_age(req.date_of_birth)
if age < 0 or req.date_of_birth > date.today():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
detail="Invalid date of birth",
)
if age < MIN_AGE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be at least {MIN_AGE} to use this platform",
)
# Check if username already exists
@@ -135,19 +195,29 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=dob,
date_of_birth=req.date_of_birth,
)
db.add(user)
await db.commit()
await db.flush() # flush to get user.id before creating refresh token
await db.refresh(user)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.post("/login", response_model=TokenResponse)
@router.post("/login", response_model=TokenResponse, dependencies=[Depends(auth_rate_limit)])
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token.
"""Authenticate user and return access + refresh tokens.
Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
@@ -167,8 +237,84 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
detail="User account is inactive",
)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
"""Exchange a valid refresh token for a new access token.
Validates the refresh token against the database:
- Must exist in the refresh_tokens table
- Must not be expired (expires_at > now)
- Must be active (is_active == true)
On success, returns a new access token. The refresh token itself is
NOT rotated (the client keeps using the same refresh token).
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
# Step 1: Decode and validate the JWT signature/claims
try:
payload = jwt.decode(
req.refresh_token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
except JWTError:
raise credentials_exception
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
raise credentials_exception
try:
user_id = int(user_id_str)
except (ValueError, TypeError):
raise credentials_exception
# Step 2: Look up the refresh token in the database
result = await db.execute(
select(RefreshToken).where(
RefreshToken.token == req.refresh_token,
RefreshToken.user_id == user_id,
)
)
stored_token = result.scalar_one_or_none()
if stored_token is None:
raise credentials_exception
# Step 3: Check if the token is expired
if datetime.now(timezone.utc) >= stored_token.expires_at:
raise credentials_exception
# Step 4: Check if the token is active
if not stored_token.is_active:
raise credentials_exception
# Step 5: Issue a new access token
access_token = _create_access_token({"sub": str(user_id)})
return TokenResponse(
access_token=access_token,
refresh_token=req.refresh_token, # Return the same refresh token
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.get("/me", response_model=UserResponse)
+78
View File
@@ -0,0 +1,78 @@
"""Avatar router - the current user's persisted avatar loadout.
The loadout is a single JSON document matching the client's
``AvatarLoadout.to_json()`` shape. The server has no copy of the asset
catalog, so it only validates the envelope (JSON object, non-empty
``base_body``); which assets are valid for which slot is the client's job.
"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.avatar_loadout import AvatarLoadout, DEFAULT_LOADOUT
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# A loadout document is small; reject anything absurd outright.
MAX_LOADOUT_BYTES = 16 * 1024
class LoadoutResponse(BaseModel):
data: dict[str, Any]
is_default: bool = Field(
description="True when the user has never saved a loadout and this is "
"the server default."
)
class LoadoutUpdate(BaseModel):
data: dict[str, Any]
def _validate_envelope(data: dict[str, Any]) -> None:
base_body = data.get("base_body")
if not isinstance(base_body, str) or not base_body.strip():
raise HTTPException(
status_code=422,
detail="loadout.data must have a non-empty string 'base_body'",
)
slots = data.get("slots", {})
if not isinstance(slots, dict):
raise HTTPException(status_code=422, detail="loadout.data.slots must be an object")
if len(str(data)) > MAX_LOADOUT_BYTES:
raise HTTPException(status_code=413, detail="loadout document too large")
@router.get("/loadout", response_model=LoadoutResponse)
async def get_loadout(current_user: CurrentUser, db: DbDep):
"""Return the current user's saved loadout, or the server default if they
have never saved one."""
row = await db.get(AvatarLoadout, current_user.id)
if row is None:
return LoadoutResponse(data=dict(DEFAULT_LOADOUT), is_default=True)
return LoadoutResponse(data=row.data, is_default=False)
@router.put("/loadout", response_model=LoadoutResponse)
async def put_loadout(payload: LoadoutUpdate, current_user: CurrentUser, db: DbDep):
"""Replace the current user's loadout."""
_validate_envelope(payload.data)
row = await db.get(AvatarLoadout, current_user.id)
if row is None:
row = AvatarLoadout(user_id=current_user.id, data=payload.data)
db.add(row)
else:
row.data = payload.data
await db.commit()
await db.refresh(row)
return LoadoutResponse(data=row.data, is_default=False)
+146
View File
@@ -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,
},
)
+451
View File
@@ -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),
}
+46 -2
View File
@@ -1,5 +1,6 @@
"""Worlds router - CRUD for game worlds."""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -11,6 +12,8 @@ from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.world import World, WorldVisibility
from app.services.game_servers import supervisor
from app.services.world_store import WorldStore
router = APIRouter()
@@ -40,7 +43,7 @@ class WorldResponse(BaseModel):
description: str | None
visibility: WorldVisibility
owner_id: int
created_at: str | None = None
created_at: datetime | None = None
model_config = {"from_attributes": True}
@@ -105,13 +108,52 @@ async def get_world(
return world
class JoinResponse(BaseModel):
"""Address of the dedicated server hosting this world."""
world_id: int
host: str
port: int
@router.post("/{world_id}/join", response_model=JoinResponse)
async def join_world(
world_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Ensure a dedicated game server is running for this world and return its
ENet address for the client to connect to.
503 if game-server orchestration isn't enabled on this backend (e.g. a
plain dev/test instance with no Godot binary).
"""
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="World not found")
try:
info = await supervisor.ensure_server(str(world_id))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
return JoinResponse(world_id=world_id, host=info["host"], port=info["port"])
@router.post("/", response_model=WorldResponse, status_code=201)
async def create_world(
world_data: WorldCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new world."""
"""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,
@@ -119,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
View File
+676
View File
@@ -0,0 +1,676 @@
"""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 `<f` IEEE-754 float32, matching every other field in the
spec and matching what any conformant decoder would assume). See the note
for what this means for cross-codec byte parity today.
Nothing here talks to a database or the FastAPI app; it is a pure,
side-effect-free codec so it can be golden-file tested in isolation.
"""
from __future__ import annotations
import hashlib
import json
import struct
from dataclasses import dataclass, field
MAGIC = b"AGPC"
HEADER_SIZE = 48
CURRENT_VERSION = 1
SECTION_META = 0x01
SECTION_TILE = 0x02
SECTION_OBJECT = 0x03
SECTION_FLAGS = 0x04
SECTION_REVISION_META = 0x05
CATEGORY_FIELDS = (
"violence", "sexual", "language", "substance",
"gambling", "horror", "hate", "custom",
)
class ChunkCodecError(Exception):
"""Malformed bytes -- the payload isn't shaped like a chunk at all."""
class ChunkValidationError(Exception):
"""A structurally-valid chunk failed a content/business-rule check.
``reason`` is the machine-readable code the API surface (world_persistence_impl.md
§8.1) puts in the 422 response body's ``reason`` field, e.g.
``"checksum_mismatch"``, ``"layer_dims"``, ``"object_out_of_bounds"``.
"""
def __init__(self, reason: str, message: str | None = None) -> 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:
"""Stored here as min-corner + size (matching the Godot client's AABB
type) for convenience, but the §3.6 BINARY wire format is min + max
(world.md §3.1, and the client's actual ChunkDataDecoder) -- fixed
2026-09-14 after a real client/server interop test caught this class's
original binary codec writing min+size on the wire, which the client
decoder (unaware of the "size" convention) read as a second min/max
pair, corrupting every chunk's bounds. The two representations are
fully recoverable from each other; `max` below is computed for exactly
this reason."""
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("<H", data, off)[0]
def _read_u32(data: bytes, off: int) -> int:
return struct.unpack_from("<I", data, off)[0]
def _read_i32(data: bytes, off: int) -> int:
return struct.unpack_from("<i", data, off)[0]
def _read_i64(data: bytes, off: int) -> int:
return struct.unpack_from("<q", data, off)[0]
def _read_f32(data: bytes, off: int) -> float:
return struct.unpack_from("<f", data, off)[0]
def _w_u16(v: int) -> bytes:
return struct.pack("<H", v & 0xFFFF)
def _w_u32(v: int) -> bytes:
return struct.pack("<I", v & 0xFFFFFFFF)
def _w_i32(v: int) -> bytes:
return struct.pack("<i", v)
def _w_i64(v: int) -> bytes:
return struct.pack("<q", v)
def _w_f32(v: float) -> bytes:
return struct.pack("<f", v)
def _read_str(data: bytes, off: int) -> 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.max[0])
out += _w_f32(payload.bounds.max[1])
out += _w_f32(payload.bounds.max[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))
max_pos = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20))
size = tuple(a - b for a, b in zip(max_pos, min_pos))
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.
+239
View File
@@ -0,0 +1,239 @@
"""Game-server orchestration.
On ``POST /api/worlds/{id}/join`` the backend ensures a headless Godot
dedicated-server process is running for that world and returns its address.
One process per world, keyed by ``world_id``. A background reaper kills a
world's server once its status file reports zero players for
``GAME_SERVER_IDLE_TIMEOUT_SECONDS``.
This lives in-process (a module-level singleton) rather than as a separate
service, so the backend MUST run single-worker -- which it does
(``uvicorn app.main:app`` with no ``--workers``). The Godot binary and
project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``);
the backend spawns them as subprocesses.
Design notes / limits (PC3):
- 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
import time
from dataclasses import dataclass, field
from pathlib import Path
from app.config import settings
@dataclass
class _WorldServer:
world_id: str
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)
def status(self) -> dict:
try:
return json.loads(self.status_path.read_text())
except (OSError, ValueError):
return {}
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
def stop(self) -> None:
if self.alive():
self.proc.terminate()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
self.status_path.unlink(missing_ok=True)
class GameServerSupervisor:
def __init__(self) -> None:
self._servers: dict[str, _WorldServer] = {}
self._lock = asyncio.Lock()
self._reaper: asyncio.Task | None = None
self._runtime_dir = Path(tempfile.gettempdir()) / "agp-game-servers"
self._runtime_dir.mkdir(parents=True, exist_ok=True)
# ── lifecycle ────────────────────────────────────────────────────────
def start(self) -> None:
if not settings.GAME_SERVERS_ENABLED:
return
if self._reaper is None or self._reaper.done():
self._reaper = asyncio.create_task(self._reap_loop())
async def shutdown(self) -> None:
if self._reaper:
self._reaper.cancel()
async with self._lock:
for srv in list(self._servers.values()):
srv.stop()
self._servers.clear()
# ── join ─────────────────────────────────────────────────────────────
async def ensure_server(self, world_id: str) -> dict:
"""Return ``{"host", "port", "world_id"}`` for the world's dedicated
server, spawning it if needed. Raises RuntimeError if orchestration
is disabled or no port/engine is available."""
if not settings.GAME_SERVERS_ENABLED:
raise RuntimeError("game servers are not enabled on this backend")
async with self._lock:
srv = self._servers.get(world_id)
if srv is not None and not srv.alive():
srv.stop()
srv = None
del self._servers[world_id]
if srv is None:
srv = self._spawn(world_id)
self._servers[world_id] = srv
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 = [
settings.GODOT_BIN, "--headless",
"--path", settings.GAME_PROJECT_DIR,
"res://scenes/server.tscn", "--",
"--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, token=token
)
def _free_port(self) -> int:
used = {s.port for s in self._servers.values()}
for p in range(settings.GAME_SERVER_PORT_MIN, settings.GAME_SERVER_PORT_MAX + 1):
if p in used:
continue
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
try:
s.bind(("0.0.0.0", p))
return p
except OSError:
continue
raise RuntimeError("no free game-server port in the configured range")
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:
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:
while True:
await asyncio.sleep(30)
async with self._lock:
now = time.time()
for wid, srv in list(self._servers.items()):
if not srv.alive():
srv.stop()
del self._servers[wid]
continue
idle_for = now - srv.last_join
if idle_for > settings.GAME_SERVER_IDLE_TIMEOUT_SECONDS and srv.player_count() == 0:
srv.stop()
del self._servers[wid]
except asyncio.CancelledError:
pass
def debug_state(self) -> list[dict]:
return [
{
"world_id": s.world_id, "port": s.port, "alive": s.alive(),
"players": s.player_count(), "uptime_s": round(time.time() - s.started_at),
}
for s in self._servers.values()
]
supervisor = GameServerSupervisor()
+197
View File
@@ -0,0 +1,197 @@
"""Lightweight glTF / GLB metadata extraction.
This module intentionally avoids heavyweight dependencies such as trimesh or
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
vertex counts, animation presence, texture references, and the bounding box)
a small amount of glTF-specific parsing is sufficient and keeps the backend
dependency-free.
"""
from __future__ import annotations
import json
import struct
from typing import Any, Optional
# GLB binary header magic bytes ("glTF").
_GLB_MAGIC = b"glTF"
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
# but be lenient with null bytes and other whitespace that real exporters emit.
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
if content.startswith(_GLB_MAGIC):
return True
if filename:
lowered = filename.lower()
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
return True
# A bare JSON buffer (e.g. uploaded without an extension) that has the
# glTF asset marker is also a glTF file.
stripped = content.lstrip()
if stripped.startswith(b"{"):
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return False
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
return False
def _parse_glb(content: bytes) -> dict[str, Any]:
"""Parse the JSON chunk of a GLB binary container."""
if len(content) < 12:
raise ValueError("GLB file is too short")
magic, _version, _length = struct.unpack_from("<III", content, 0)
if magic != _GLB_MAGIC_INT:
raise ValueError("invalid GLB magic")
offset = 12
json_chunk: Optional[dict[str, Any]] = None
while offset + 8 <= len(content):
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
offset += 8
chunk_end = offset + chunk_length
if chunk_end > len(content):
raise ValueError("GLB chunk overruns file boundary")
chunk_data = content[offset:chunk_end]
if chunk_type == _GLB_JSON_CHUNK_TYPE:
try:
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
offset = chunk_end
if json_chunk is None:
raise ValueError("GLB file does not contain a JSON chunk")
return json_chunk
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
"""Parse a plain .gltf JSON file."""
stripped = content.lstrip()
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid glTF JSON: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError("glTF JSON root must be an object")
return payload
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
"""Return POSITION accessor counts for every mesh primitive."""
counts: list[int] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
count = accessor.get("count")
if isinstance(count, int):
counts.append(count)
return counts
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
"""Return min/max bounds for every POSITION accessor that declares them."""
bounds: list[list[float]] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
minimum = accessor.get("min")
maximum = accessor.get("max")
if isinstance(minimum, list) and isinstance(maximum, list):
bounds.append([float(x) for x in minimum])
bounds.append([float(x) for x in maximum])
return bounds
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
"""Collect external texture source references from the image array."""
refs: list[str] = []
for index, image in enumerate(gltf.get("images", [])):
uri = image.get("uri")
if isinstance(uri, str) and not uri.startswith("data:"):
refs.append(uri)
continue
name = image.get("name")
if isinstance(name, str) and name:
refs.append(name)
continue
if isinstance(uri, str):
# Embedded data URI; keep a stable, non-huge reference.
refs.append(f"data:{index}")
continue
buffer_view = image.get("bufferView")
if buffer_view is not None:
refs.append(f"bufferView:{buffer_view}")
continue
refs.append(f"image:{index}")
return refs
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
"""Extract upload-relevant metadata from glTF/GLB bytes.
Raises ValueError for malformed glTF content. The returned dict contains:
mesh_count, vertex_count, has_animation, texture_refs,
bounding_box_min, bounding_box_max.
"""
if content.startswith(_GLB_MAGIC):
gltf = _parse_glb(content)
else:
gltf = _parse_gltf_json(content)
position_counts = _position_accessor_counts(gltf)
bounds = _position_bounds(gltf)
if bounds:
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
else:
bounding_box_min = [0.0, 0.0, 0.0]
bounding_box_max = [0.0, 0.0, 0.0]
return {
"mesh_count": len(gltf.get("meshes", [])),
"vertex_count": sum(position_counts),
"has_animation": bool(gltf.get("animations")),
"texture_refs": _texture_refs(gltf),
"bounding_box_min": bounding_box_min,
"bounding_box_max": bounding_box_max,
}
+431
View File
@@ -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)
)
+2 -2
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "5432:5432"
- "${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -20,7 +20,7 @@ services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
- "${REDIS_HOST_PORT:-6379}:6379"
volumes:
- redis_data:/data
healthcheck:
+8
View File
@@ -9,6 +9,14 @@ pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
# backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed --
# hashing/verifying a password then raises "password cannot be longer than
# 72 bytes" (a misleading downstream symptom of the real AttributeError:
# module 'bcrypt' has no attribute '__about__') on every call. Without this
# pin, `pip install -r requirements.txt` silently grabs whatever the latest
# bcrypt is and breaks register/login outright. Pin below 4.1 explicitly.
bcrypt==4.0.1
python-multipart==0.0.20
email-validator==2.2.0
httpx==0.28.1
+183
View File
@@ -0,0 +1,183 @@
"""Shared pytest fixtures + hermetic-DB bootstrap.
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
with no manual setup, ``pytest_configure`` below brings up an ephemeral
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
not already pointing at a reachable database. When it is (dev loop, or the
checkpoint runner which stands the DB up itself), that path is skipped and
the existing database is reused.
If Docker isn't available and no DB is reachable, the whole DB-backed suite
is skipped with a clear message rather than erroring out 60+ times.
"""
import asyncio
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import urlsplit
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.database import get_db
from app.main import app
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"
_EPHEMERAL_PG_PORT = 25432
# Set by _ensure_database() when it starts a container it owns; torn down in
# pytest_unconfigure.
_started_container = False
_db_unavailable_reason: str | None = None
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _db_reachable(url: str) -> bool:
parts = urlsplit(url)
return _port_open(parts.hostname or "localhost", parts.port or 5432)
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw)
def pytest_configure(config):
global _started_container, _db_unavailable_reason
if _db_reachable(settings.DATABASE_URL):
return # dev loop / checkpoint runner already provided one
if not _run(["docker", "--version"]).returncode == 0:
_db_unavailable_reason = (
"no reachable DATABASE_URL and docker is unavailable -- run the "
"checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres "
"manually"
)
return
env = os.environ.copy()
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
up = _run(["docker", "compose", "up", "-d", "postgres"], env=env)
if up.returncode != 0:
_db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}"
return
_started_container = True
for _ in range(30):
r = _run(
["docker", "compose", "exec", "-T", "postgres",
"pg_isready", "-U", "agp_user", "-d", "agp_db"],
env=env,
)
if r.returncode == 0:
break
time.sleep(1)
else:
_db_unavailable_reason = "ephemeral Postgres never became ready"
return
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
os.environ["DATABASE_URL"] = url
settings.DATABASE_URL = url
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
env={**os.environ, "DATABASE_URL": url})
if mig.returncode != 0:
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
def pytest_unconfigure(config):
if not _started_container:
return
env = os.environ.copy()
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
_run(["docker", "compose", "down", "-v"], env=env)
@pytest.fixture(scope="session", autouse=True)
def _reset_database():
"""Truncate all application tables once per test session."""
if _db_unavailable_reason:
pytest.skip(_db_unavailable_reason, allow_module_level=True)
async def _truncate() -> None:
engine = create_async_engine(settings.DATABASE_URL)
async with engine.begin() as conn:
await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE"))
await engine.dispose()
asyncio.run(_truncate())
yield
@pytest.fixture(autouse=True)
def _reset_rate_limiter():
"""Every TestClient request looks like the same client IP, so the
per-IP auth limiter would trip mid-suite. Clear it between tests; the
dedicated rate-limit test disables this to exercise the real behaviour.
"""
from app import rate_limit
rate_limit.reset_all()
yield
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test.
Deliberately does NOT reuse app.database's shared engine: FastAPI's
(sync) TestClient runs each request through anyio's blocking-portal
machinery, which can hand different requests different event loops.
asyncpg connections are bound to the loop they were created on, so
reusing one from the shared pool across requests raises "attached to a
different loop" the moment a test makes more than one request. NullPool
sidesteps it: every checkout is a brand-new connection.
"""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
Binary file not shown.
Binary file not shown.
+105
View File
@@ -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()
Binary file not shown.
+74 -64
View File
@@ -3,92 +3,102 @@
from datetime import date, timedelta
import pytest
from fastapi.testclient import TestClient
from app.routers.auth import calculate_age
from app.main import app
from app.routers.auth import calculate_age, MIN_AGE, RegisterRequest
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 6, 15)
today = date(2026, 6, 15)
dob = today.replace(year=today.year - 18)
assert calculate_age(dob, today=today) == 18
def test_just_turned_18(self):
"""A person who turned 18 yesterday is 18."""
today = date(2025, 6, 15)
today = date(2026, 6, 15)
dob = today.replace(year=today.year - 18) - timedelta(days=1)
assert calculate_age(dob, today=today) == 18
def test_one_day_short_of_18(self):
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 6, 15)
today = date(2026, 6, 15)
dob = today.replace(year=today.year - 18) + timedelta(days=1)
assert calculate_age(dob, today=today) == 17
def test_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_leap_day_birthday_before(self):
assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
def test_leap_day_birthday_after(self):
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
class TestRegisterRequestDob:
def test_dob_parsed_from_iso_string(self):
req = RegisterRequest(username="u", email="u@example.com", password="p",
date_of_birth="1990-01-01")
assert req.date_of_birth == date(1990, 1, 1)
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
from app.routers.auth import RegisterRequest
def test_dob_required(self):
with pytest.raises(Exception):
RegisterRequest(username="u", email="u@example.com", password="p")
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
def test_bad_dob_string_rejected(self):
with pytest.raises(Exception):
RegisterRequest(username="u", email="u@example.com", password="p",
date_of_birth="not-a-date")
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"2000/01/01",
"2000-13-01",
"2000-00-10",
"not-a-date",
"",
],
)
def test_invalid_dates_rejected(self, dob):
"""Invalid dates are rejected by the validator."""
from app.routers.auth import RegisterRequest
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
class TestRegisterAgeGate:
"""End-to-end: the /register endpoint computes age from the DOB."""
def _dob_for_age(self, years: int) -> str:
return (date.today().replace(year=date.today().year - years)).isoformat()
def test_adult_accepted(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_adult", "email": "gate_adult@e.com",
"password": "secret123", "date_of_birth": self._dob_for_age(25),
})
assert r.status_code == 201, r.text
def test_minor_rejected(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_minor", "email": "gate_minor@e.com",
"password": "secret123", "date_of_birth": self._dob_for_age(15),
})
assert r.status_code == 403
assert str(MIN_AGE) in r.json()["detail"]
def test_exactly_18_accepted(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_18", "email": "gate_18@e.com",
"password": "secret123",
"date_of_birth": (date.today().replace(year=date.today().year - 18) - timedelta(days=1)).isoformat(),
})
assert r.status_code == 201, r.text
def test_future_dob_rejected(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_future", "email": "gate_future@e.com",
"password": "secret123", "date_of_birth": "2999-01-01",
})
assert r.status_code == 400
def test_dob_is_stored(self):
client = TestClient(app)
dob = self._dob_for_age(30)
reg = client.post("/api/auth/register", json={
"username": "gate_stored", "email": "gate_stored@e.com",
"password": "secret123", "date_of_birth": dob,
})
assert reg.status_code == 201
me = client.get("/api/auth/me", headers={
"Authorization": f"Bearer {reg.json()['access_token']}"
})
assert me.json()["date_of_birth"] == dob
+251
View File
@@ -0,0 +1,251 @@
"""Integration tests for the auth endpoints (register + login).
These tests hit the real FastAPI app against the real configured database
(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated
in-memory DB), so they exercise the full stack: Pydantic validation → DB →
JWT → response. Requires migrations already applied (alembic upgrade head)
before running.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.database import get_db
from app.routers.auth import pwd_context
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test.
Deliberately does NOT reuse app.database's shared engine: FastAPI's
(sync) TestClient runs each request through anyio's blocking-portal
machinery, which can hand different requests different event loops.
asyncpg connections are bound to the loop they were created on, so
reusing one from the shared engine's connection pool across requests
raises "cannot perform operation: another operation is in progress" the
moment a test makes more than one request (register-then-login,
register-twice, etc.) -- confirmed this is the actual cause, not a bug
in the endpoints themselves. NullPool sidesteps it: every checkout is a
brand-new connection, never handed to a different loop than the one
that opened it.
"""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Register endpoint
# ---------------------------------------------------------------------------
class TestRegister:
"""Tests for POST /api/auth/register."""
def test_register_success(self):
"""A valid registration returns 201 with a token."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "newuser",
"email": "new@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_register_rejects_under_18(self):
"""A DOB that computes to under 18 gets a 403 (see test_auth_age_gate
for the age-math coverage)."""
from datetime import date
client = TestClient(app)
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
resp = client.post(
"/api/auth/register",
json={
"username": "kid",
"email": "kid@example.com",
"password": "secret123",
"date_of_birth": minor_dob,
},
)
assert resp.status_code == 403
assert "at least 18" in resp.json()["detail"]
def test_register_rejects_duplicate_username(self):
"""Registering the same username twice returns 409."""
client = TestClient(app)
# First registration
client.post(
"/api/auth/register",
json={
"username": "duplicate",
"email": "dup1@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
# Second registration with same username, different email
resp = client.post(
"/api/auth/register",
json={
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 409
assert "Username already registered" in resp.json()["detail"]
def test_register_rejects_duplicate_email(self):
"""Registering the same email twice returns 409."""
client = TestClient(app)
client.post(
"/api/auth/register",
json={
"username": "user1",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
resp = client.post(
"/api/auth/register",
json={
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
def test_register_missing_dob(self):
"""Missing the required date_of_birth field returns 422."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "user",
"email": "user@example.com",
"password": "secret123",
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Login endpoint
# ---------------------------------------------------------------------------
class TestLogin:
"""Tests for POST /api/auth/login."""
def _create_user(self, username: str, password: str, email: str = None):
"""Helper to create a user via register."""
client = TestClient(app)
if email is None:
email = f"{username}@example.com"
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": email,
"password": password,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
return resp.json()["access_token"]
def test_login_success(self):
"""Correct credentials return a token."""
self._create_user("loginuser", "correctpass")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "loginuser", "password": "correctpass"},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_login_wrong_password(self):
"""Wrong password returns 401."""
# Distinct username from test_login_success -- there's no per-test
# DB rollback/isolation here (real Postgres via the app's own
# dependency-overridden session), so reusing "loginuser" collided
# with the user that test already created (409 on register instead
# of the intended 401-on-login assertion this test is actually for).
self._create_user("loginuser_wrongpass", "correctpass")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "loginuser_wrongpass", "password": "wrongpass"},
)
assert resp.status_code == 401
assert "Incorrect username or password" in resp.json()["detail"]
def test_login_nonexistent_user(self):
"""Non-existent user returns 401 (not 404)."""
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "nobody", "password": "any"},
)
assert resp.status_code == 401
assert "Incorrect username or password" in resp.json()["detail"]
def test_login_accepts_form_encoded(self):
"""Login accepts application/x-www-form-urlencoded (OAuth2)."""
self._create_user("formuser", "formpass")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "formuser", "password": "formpass"},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
def test_login_token_is_valid_jwt(self):
"""The returned token is a valid JWT that can be decoded."""
self._create_user("jwttoken", "jwt123")
client = TestClient(app)
resp = client.post(
"/api/auth/login",
data={"username": "jwttoken", "password": "jwt123"},
)
token = resp.json()["access_token"]
# Verify it's a 3-part JWT
parts = token.split(".")
assert len(parts) == 3
+171
View File
@@ -0,0 +1,171 @@
"""Tests for expired-token rejection across auth endpoints.
Covers two distinct expiry paths:
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
signature is still valid.
Requires migrations already applied (alembic upgrade head) before running.
"""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from jose import jwt
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.database import get_db
from app.models.session import RefreshToken
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test."""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mint_access_token(exp: datetime) -> str:
"""Mint an RS256 access token with an explicit ``exp`` claim."""
return jwt.encode(
{"sub": "1", "exp": exp},
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def _expire_refresh_token_in_db(token: str) -> None:
"""Set the persisted refresh token's ``expires_at`` into the past."""
async def _do():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
from sqlalchemy import update
await session.execute(
update(RefreshToken)
.where(RefreshToken.token == token)
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
)
await session.commit()
await engine.dispose()
asyncio.run(_do())
def _register_user(client: TestClient, username: str) -> str:
"""Register a user and return its refresh token."""
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
return resp.json()["refresh_token"]
# ---------------------------------------------------------------------------
# Expired access token (GET /api/auth/me)
# ---------------------------------------------------------------------------
class TestExpiredAccessToken:
def test_expired_access_token_is_rejected_by_me(self):
"""An access JWT with a past exp claim returns 401 on /me."""
client = TestClient(app)
expired_token = _mint_access_token(
datetime.now(timezone.utc) - timedelta(minutes=5)
)
resp = client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {expired_token}"},
)
assert resp.status_code == 401
assert "Could not validate credentials" in resp.json()["detail"]
def test_garbage_access_token_is_rejected_by_me(self):
"""A non-JWT bearer token returns 401 on /me."""
client = TestClient(app)
resp = client.get(
"/api/auth/me",
headers={"Authorization": "Bearer not-a-real-jwt"},
)
assert resp.status_code == 401
def test_missing_bearer_token_returns_401(self):
"""No Authorization header at all returns 401 on /me."""
client = TestClient(app)
resp = client.get("/api/auth/me")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Expired refresh token (POST /api/auth/refresh)
# ---------------------------------------------------------------------------
class TestExpiredRefreshToken:
def test_expired_refresh_token_is_rejected(self):
"""A refresh token whose DB expires_at is in the past returns 401."""
client = TestClient(app)
refresh_token = _register_user(client, "expired_rt_user")
# Force the persisted record to be expired while its JWT signature
# is still valid.
_expire_refresh_token_in_db(refresh_token)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
def test_expired_refresh_token_jwt_is_rejected(self):
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
client = TestClient(app)
expired_jwt = jwt.encode(
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
settings.private_pem,
algorithm=settings.ALGORITHM,
)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": expired_jwt},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
+91
View File
@@ -0,0 +1,91 @@
"""Integration tests for GET/PUT /api/avatar/loadout.
Hits the real app against the configured Postgres (same contract as the
other integration tests -- migrations applied first).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.models.avatar_loadout import DEFAULT_LOADOUT
def _auth_headers(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
class TestGetLoadout:
def test_new_user_gets_the_default(self):
client = TestClient(app)
headers = _auth_headers(client, "av_default")
resp = client.get("/api/avatar/loadout", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert body["data"] == DEFAULT_LOADOUT
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/avatar/loadout").status_code == 401
class TestPutLoadout:
def test_save_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "av_save")
loadout = {
"base_body": "base_human_male_01",
"slots": {"hair": "hair_mohawk_01", "accessory": ["accessory_belt_01"]},
"skin_tone": {"r": 0.8, "g": 0.6, "b": 0.5, "a": 1.0},
}
put = client.put("/api/avatar/loadout", headers=headers, json={"data": loadout})
assert put.status_code == 200, put.text
assert put.json()["is_default"] is False
got = client.get("/api/avatar/loadout", headers=headers)
assert got.status_code == 200
assert got.json()["is_default"] is False
assert got.json()["data"] == loadout
def test_put_is_idempotent_replace(self):
client = TestClient(app)
headers = _auth_headers(client, "av_replace")
client.put(
"/api/avatar/loadout", headers=headers,
json={"data": {"base_body": "base_human_male_01", "slots": {"hair": "hair_mohawk_01"}}},
)
second = {"base_body": "base_human_male_01", "slots": {"chest": "chest_bandit_01"}}
client.put("/api/avatar/loadout", headers=headers, json={"data": second})
got = client.get("/api/avatar/loadout", headers=headers)
assert got.json()["data"] == second
def test_rejects_missing_base_body(self):
client = TestClient(app)
headers = _auth_headers(client, "av_nobase")
resp = client.put("/api/avatar/loadout", headers=headers, json={"data": {"slots": {}}})
assert resp.status_code == 422
def test_rejects_non_object_slots(self):
client = TestClient(app)
headers = _auth_headers(client, "av_badslots")
resp = client.put(
"/api/avatar/loadout", headers=headers,
json={"data": {"base_body": "base_human_male_01", "slots": "nope"}},
)
assert resp.status_code == 422
def test_requires_auth(self):
client = TestClient(app)
resp = client.put("/api/avatar/loadout", json={"data": {"base_body": "x", "slots": {}}})
assert resp.status_code == 401
+219
View File
@@ -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"
+153
View File
@@ -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
+220
View File
@@ -0,0 +1,220 @@
"""Tests for the lightweight glTF metadata extraction service."""
import json
import struct
import pytest
from app.services.gltf import (
extract_gltf_metadata,
looks_like_gltf,
_parse_glb,
_parse_gltf_json,
)
# ---------------------------------------------------------------------------
# Test data helpers
# ---------------------------------------------------------------------------
def _make_gltf_json(
meshes: int = 1,
vertices_per_mesh: int = 24,
has_animations: bool = False,
images: list[dict] | None = None,
) -> dict:
"""Build a minimal glTF JSON structure."""
accessors = []
meshes_data = []
for i in range(meshes):
acc_index = len(accessors)
accessors.append({
"bufferView": 0,
"componentType": 5126, # FLOAT
"count": vertices_per_mesh,
"type": "VEC3",
"max": [1.0, 1.0, 1.0],
"min": [-1.0, -1.0, -1.0],
})
meshes_data.append({
"primitives": [{
"attributes": {"POSITION": acc_index},
}],
})
gltf = {
"asset": {"version": "2.0"},
"accessors": accessors,
"meshes": meshes_data,
}
if has_animations:
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
if images:
gltf["images"] = images
return gltf
def _make_glb(gltf_json: dict) -> bytes:
"""Wrap *gltf_json* in a minimal GLB binary container."""
json_bytes = json.dumps(gltf_json).encode("utf-8")
json_length = len(json_bytes)
# Pad JSON chunk to 4-byte boundary.
json_padding = (4 - json_length % 4) % 4
json_chunk = json_bytes + b"\x00" * json_padding
json_chunk_type = 0x4E4F534A # "JSON"
# No BIN chunk.
bin_length = 0
bin_chunk_type = 0x004E4942
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
if bin_length > 0:
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
else:
bin_header = b""
return header + json_header + json_chunk + bin_header
# ---------------------------------------------------------------------------
# looks_like_gltf
# ---------------------------------------------------------------------------
class TestLooksLikeGltf:
def test_glb_magic(self):
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
def test_gltf_extension(self):
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
def test_glb_extension(self):
assert looks_like_gltf(b"some binary", "model.glb") is True
def test_json_with_asset_version(self):
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
assert looks_like_gltf(payload) is True
def test_plain_json_no_asset(self):
payload = json.dumps({"foo": "bar"}).encode()
assert looks_like_gltf(payload) is False
def test_non_json_bytes(self):
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
# ---------------------------------------------------------------------------
# _parse_glb
# ---------------------------------------------------------------------------
class TestParseGlb:
def test_parses_valid_glb(self):
gltf_json = _make_gltf_json()
glb = _make_glb(gltf_json)
result = _parse_glb(glb)
assert result["asset"]["version"] == "2.0"
assert len(result["meshes"]) == 1
def test_rejects_too_short(self):
with pytest.raises(ValueError, match="too short"):
_parse_glb(b"glTF\x00\x00")
def test_rejects_invalid_magic(self):
with pytest.raises(ValueError, match="invalid GLB magic"):
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
# ---------------------------------------------------------------------------
# _parse_gltf_json
# ---------------------------------------------------------------------------
class TestParseGltfJson:
def test_parses_valid_gltf(self):
gltf_json = _make_gltf_json()
result = _parse_gltf_json(json.dumps(gltf_json).encode())
assert result["asset"]["version"] == "2.0"
def test_rejects_non_object(self):
with pytest.raises(ValueError, match="root must be an object"):
_parse_gltf_json(b"[1, 2, 3]")
# ---------------------------------------------------------------------------
# extract_gltf_metadata (GLB path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGlb:
def test_basic_glb(self):
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 1
assert meta["vertex_count"] == 24
assert meta["has_animation"] is False
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
assert meta["texture_refs"] == []
def test_multiple_meshes(self):
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 3
assert meta["vertex_count"] == 36
def test_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["has_animation"] is True
def test_with_external_texture(self):
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["textures/rock.png"]
def test_with_named_texture(self):
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["my_texture"]
def test_empty_meshes(self):
gltf_json = _make_gltf_json(meshes=0)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 0
assert meta["vertex_count"] == 0
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
# ---------------------------------------------------------------------------
# extract_gltf_metadata (plain .gltf JSON path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGltfJson:
def test_plain_gltf(self):
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["mesh_count"] == 2
assert meta["vertex_count"] == 16
assert meta["has_animation"] is False
def test_plain_gltf_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["has_animation"] is True
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataErrors:
def test_invalid_glb(self):
with pytest.raises(ValueError):
extract_gltf_metadata(b"not a glb")
def test_invalid_gltf_json(self):
with pytest.raises(ValueError):
_parse_gltf_json(b"[1, 2, 3]")
+56
View File
@@ -0,0 +1,56 @@
"""The auth endpoints are rate-limited per client IP."""
import pytest
from fastapi.testclient import TestClient
from app import rate_limit
from app.config import settings
from app.main import app
@pytest.fixture(autouse=True)
def _clean_limiter():
# This module tests the limiter itself: force it on (the checkpoint
# runner disables it suite-wide via RATE_LIMIT_ENABLED) and start each
# test from an empty window.
prev = settings.RATE_LIMIT_ENABLED
settings.RATE_LIMIT_ENABLED = True
rate_limit.reset_all()
yield
rate_limit.reset_all()
settings.RATE_LIMIT_ENABLED = prev
def test_login_is_limited_after_the_threshold():
client = TestClient(app)
codes = []
for i in range(15):
r = client.post("/api/auth/login", data={"username": f"nobody{i}", "password": "x"})
codes.append(r.status_code)
# First 10 get through to the (failing) auth check; the rest are 429.
assert codes[:10] == [401] * 10
assert 429 in codes[10:]
assert codes[-1] == 429
def test_register_shares_the_same_bucket():
client = TestClient(app)
# Burn the budget on login...
for i in range(10):
client.post("/api/auth/login", data={"username": f"x{i}", "password": "y"})
# ...register from the same IP is now blocked too.
r = client.post("/api/auth/register", json={
"username": "rl_user", "email": "rl@e.com",
"password": "secret123", "date_of_birth": "1990-01-01",
})
assert r.status_code == 429
assert "Retry-After" in r.headers
def test_limiter_window_is_per_key():
limiter = rate_limit.SlidingWindowLimiter(max_hits=2, window_seconds=60)
limiter.check("a")
limiter.check("a")
with pytest.raises(Exception):
limiter.check("a")
limiter.check("b") # different key, unaffected
+201
View File
@@ -0,0 +1,201 @@
"""Tests for the POST /api/auth/refresh endpoint.
These tests exercise the full stack: register → get refresh token →
refresh → new access token. Requires migrations already applied
(alembic upgrade head) before running.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.database import get_db
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test."""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Refresh endpoint tests
# ---------------------------------------------------------------------------
class TestRefresh:
"""Tests for POST /api/auth/refresh."""
def _register_user(self, username: str, password: str, email: str = None):
"""Helper to register a user and return the refresh token."""
client = TestClient(app)
if email is None:
email = f"{username}@example.com"
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": email,
"password": password,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
return resp.json()["refresh_token"]
def test_refresh_success(self):
"""A valid refresh token returns a new access token."""
refresh_token = self._register_user("refep_refreshuser", "secret123")
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
# The refresh token in the response is the same one we sent
assert data["refresh_token"] == refresh_token
def test_refresh_returns_different_access_token(self):
"""Refreshing produces a new access token (different from any previous)."""
refresh_token = self._register_user("refep_refreshtoken", "secret123")
client = TestClient(app)
# First refresh
resp1 = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp1.status_code == 200
access_token_1 = resp1.json()["access_token"]
# Second refresh
resp2 = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp2.status_code == 200
access_token_2 = resp2.json()["access_token"]
# Each refresh should produce a new access token
assert access_token_1 != access_token_2
def test_refresh_invalid_token(self):
"""An invalid (garbage) refresh token returns 401."""
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": "not.a.jwt.token"},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
def test_refresh_empty_body(self):
"""Missing refresh_token field returns 422 validation error."""
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={},
)
assert resp.status_code == 422
def test_refresh_missing_field(self):
"""No body at all returns 422."""
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
content="{}",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 422
def test_refresh_with_login_token(self):
"""A refresh token obtained from login also works for /refresh."""
client = TestClient(app)
# Register first
client.post(
"/api/auth/register",
json={
"username": "refep_loginrefuser",
"email": "loginref@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
# Login to get a refresh token
login_resp = client.post(
"/api/auth/login",
data={"username": "refep_loginrefuser", "password": "secret123"},
)
assert login_resp.status_code == 200
refresh_token = login_resp.json()["refresh_token"]
# Use it to refresh
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
def test_refresh_access_token_is_valid_jwt(self):
"""The access token returned by /refresh is a valid JWT."""
from jose import jwt
refresh_token = self._register_user("refep_jwttoken", "secret123")
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
access_token = resp.json()["access_token"]
# Verify it's a 3-part JWT
parts = access_token.split(".")
assert len(parts) == 3
# Verify it decodes with the public key
payload = jwt.decode(
access_token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert "sub" in payload
assert "exp" in payload
def test_refresh_expires_minutes_in_response(self):
"""The response includes access_token_expires_minutes."""
refresh_token = self._register_user("refep_expiretest", "secret123")
client = TestClient(app)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
assert "access_token_expires_minutes" in resp.json()
assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES
+79
View File
@@ -0,0 +1,79 @@
"""Tests for refresh token uniqueness (jti claim) and model column length."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from jose import jwt
from app.config import settings
from app.routers.auth import _create_refresh_token
from app.models.session import RefreshToken
class TestRefreshTokenJtiUniqueness:
"""Verify that _create_refresh_token produces unique tokens even when
called with identical data in rapid succession (issue #1 from review)."""
def test_two_calls_produce_different_tokens(self):
"""Two calls with the same sub produce different JWTs because of jti."""
token_a = _create_refresh_token({"sub": "42"})
token_b = _create_refresh_token({"sub": "42"})
assert token_a != token_b
def test_jti_is_valid_uuid(self):
"""The jti claim inside the refresh token is a valid UUID."""
token = _create_refresh_token({"sub": "1"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert "jti" in payload
# Verify it parses as a UUID (raises ValueError if not)
uuid.UUID(payload["jti"])
def test_each_token_has_unique_jti(self):
"""Generate 100 tokens for the same user — all jtis must differ."""
jtis = set()
for _ in range(100):
token = _create_refresh_token({"sub": "999"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
jtis.add(payload["jti"])
assert len(jtis) == 100
def test_jti_survives_decode(self):
"""The jti claim is present and correct after decode."""
token = _create_refresh_token({"sub": "7"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert payload["sub"] == "7"
assert "jti" in payload
assert "exp" in payload
class TestRefreshTokenModelColumnLength:
"""Verify the model's token column length matches the migration (issue #2)."""
def test_token_column_has_length_512(self):
"""RefreshToken.token column should be String(512), not unbounded."""
from sqlalchemy import inspect
# Get the column definition from the model's table
column = RefreshToken.__table__.columns["token"]
# The type should be String with length 512
assert column.type.length == 512
def test_token_column_type_is_string(self):
"""The token column should be a String type."""
from sqlalchemy import String as SAString
column = RefreshToken.__table__.columns["token"]
assert isinstance(column.type, SAString)
+490
View File
@@ -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)
+54
View File
@@ -0,0 +1,54 @@
"""Tests for POST /api/worlds/{id}/join.
The happy path spawns a real Godot process, so it's covered by a manual /
checkpoint integration test, not here. These cover the envelope: auth,
visibility, and the 503 when orchestration is disabled (the default).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
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"]
class TestJoinWorld:
def test_requires_auth(self):
client = TestClient(app)
assert client.post("/api/worlds/1/join").status_code == 401
def test_unknown_world_is_404(self):
client = TestClient(app)
headers, _ = _auth(client, "join_404")
assert client.post("/api/worlds/999999/join", headers=headers).status_code == 404
def test_private_world_of_another_user_is_404(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "join_owner")
wid = _make_world(client, owner_headers, "private")
other_headers, _ = _auth(client, "join_other")
assert client.post(f"/api/worlds/{wid}/join", headers=other_headers).status_code == 404
def test_disabled_orchestration_returns_503(self):
# GAME_SERVERS_ENABLED defaults to False in tests.
client = TestClient(app)
headers, _ = _auth(client, "join_503")
wid = _make_world(client, headers, "public")
r = client.post(f"/api/worlds/{wid}/join", headers=headers)
assert r.status_code == 503
assert "not enabled" in r.json()["detail"]
+31
View File
@@ -449,6 +449,37 @@ The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
---
## 9a. Loadout Persistence (implemented, PC3 Phase 1)
**Storage.** One row per user in `avatar_loadouts` (`user_id` PK/FK,
`data` JSONB, timestamps). The entire loadout is stored as a single JSON
document in the exact shape the client's `AvatarLoadout.to_json()` emits
(`{"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}`) rather than
exploded into columns.
**Why one JSON blob, not columns.** The server has no copy of the asset
catalog (`assets/avatars/catalog.json` ships with the client). It therefore
cannot meaningfully validate *which* asset belongs in *which* slot, whether
an asset exists, or slot-exclusivity rules — that validation stays on the
client (`AvatarLoadout.validate()` against the loaded `AvatarCatalog`). The
server enforces only the envelope: a JSON object with a non-empty string
`base_body`, an object `slots`, and a sane size cap. Keeping the schema
opaque to the server also means catalog/loadout-shape changes don't need a
migration.
**Endpoints** (`/api/avatar`, bearer auth):
| Method | Path | Behaviour |
|---|---|---|
| `GET` | `/loadout` | The user's saved loadout, or the server default (`{"base_body": "base_human_male_01", "slots": {}}`, `is_default: true`) if they have never saved one. |
| `PUT` | `/loadout` | Replace the user's loadout (full document, not a patch). |
**Defaults.** A brand-new account has no row; `GET` returns
`models.avatar_loadout.DEFAULT_LOADOUT` with `is_default: true` so the client
can distinguish "never customised" from "customised back to the base look".
---
## 10. Summary
| Concern | Decision |
+18 -3
View File
@@ -227,18 +227,33 @@ An account has an **age verification status**:
### 5.2 Tier 1 — Self-Declaration Flow
> **Implemented, PC3 Phase 3.** `POST /api/auth/register` now takes a real
> `date_of_birth` (ISO `YYYY-MM-DD`), not the throwaway `over_18` boolean it
> had before. The server computes age (`auth.calculate_age`) and rejects
> anyone under `auth.MIN_AGE` (18) with a **403** — this platform is
> adults-only, so there is no "minor, restricted" tier here (the 13+ rows
> below are the general design, not the current gate). A future-in-the-past
> or nonsensical date is a **400**. The DOB is stored on the user row and
> surfaced only back to that user (`/api/auth/me`), never to other players.
>
> **Limitation, stated plainly:** this is still self-attested — a determined
> user can enter a false DOB. It is a concrete, stored, legally-meaningful
> data point (matching how most platforms gate before adding hard checks)
> and the seam for Tier 2. Encryption-at-rest for the DOB column and the
> Tier 2 ID-check hook below are **not yet implemented**.
```
User submits { date_of_birth }
│
▼
Server validates format & plausibility (age 13–120, not in future)
Server validates format & plausibility (real date, not in future)
│
▼
Compute age from DOB
│
▼
age >= 18 ──► status = self_declared (adult)
age >= 13 ──► status = self_declared (minor, restricted)
age >= 18 ──► status = self_declared (adult) ← the current gate
age >= 13 ──► status = self_declared (minor, restricted) ← design only
age < 13 ──► status = rejected (below minimum platform age)
```
+5
View File
@@ -4,6 +4,11 @@
> **Owner:** Platform team
> **Related:** World chunk data format & persistence, server authority + client
> prediction, content moderation & age verification, avatar component system
> **Note:** the recommendation here stands, but edits do **not** ride the
> `request_chunk_edit` ENet RPC (§4.2, §7.4). Per
> [`world_persistence_impl.md`](world_persistence_impl.md) the editor saves
> chunks over HTTP to the FastAPI backend, which is the sole writer; the
> validation/moderation boundary moves with it.
> **Godot version:** 4.6.2 stable
## 1. Overview
+56
View File
@@ -237,6 +237,62 @@ snapshot_state:
---
## 7a. Server Orchestration (implemented, PC3 Phase 2)
**One dedicated server process per world**, spawned on demand.
```
client backend (single worker) host
│ POST /api/worlds/{id}/join (bearer JWT) │
│ ───────────────────────────▶ GameServerSupervisor │
│ ensure_server(world_id): │
│ if none running for world ─── spawn:
│ godot --headless
│ res://scenes/server.tscn --
│ --port <p> --world <id>
│ --status-file <path>
│ ◀─────────────────────────── { host, port, world_id } │
│ │
│ ENet connect host:port ───────────────────────────────────▶ dedicated server
```
- **Supervisor** (`backend/app/services/game_servers.py`): in-process singleton,
so the backend MUST run single-worker. Keys servers by `world_id`. Allocates
ports from `GAME_SERVER_PORT_MIN..MAX`. A background reaper kills a world's
server once its **status file** (`{players, tick, updated}`, rewritten by the
server ~1×/s) reports `players == 0` for `GAME_SERVER_IDLE_TIMEOUT_SECONDS`.
No crash-restart — the next join respawns.
- **Enable flag:** `GAME_SERVERS_ENABLED` (default off). The checkpoint/playtest
launcher (`pm/checkpoint_ops.py`) turns it on, passes `GODOT_BIN`,
`GAME_PROJECT_DIR`, and `GAME_SERVER_HOST` (the LAN IP baked into the `.exe`).
A backend with it off returns **503** from `/join`, and the client falls back
to local single-player.
- **Per-world content:** all worlds currently load the same test level (M2/M7
work). `world_id` still scopes a distinct process + session.
### 7b. Client play path
`world_list.gd` → `POST /join` → `Session.begin(world_id, host, port)` →
load `game.tscn`. `game.gd` sees `Session.active` and connects a
`NetworkClient`:
- **Local player** keeps predicting with its own collision-aware
`CharacterBody3D` (`player.gd`) — that *is* the prediction. Its input frame
(`player.net_input`) is forwarded to the server every physics tick.
- **Correction backstop:** if the local position drifts from the server's by
more than `CORRECTION_THRESHOLD` (1.5 m — generous; catches teleport / a wall
walked through / real desync, not prediction noise) it snaps.
- **Remote players:** rendered from snapshots, position lerped. *Appearance sync
is a follow-up* — remotes currently show as labelled capsules; the server
does not yet replicate avatar loadouts.
- **Disconnect** → status message → back to the world list.
The `NetworkClient`'s own flat-plane prediction + input-replay reconciliation
(§5) is exercised by `smoke_test_network_client.gd`; the collision-aware game
path above uses the lighter `send_input()` + drift-snap instead.
---
## 8. Security & Anti-Cheat
- **No client-authoritative gameplay values.** Clients only send *inputs* and
+194
View File
@@ -0,0 +1,194 @@
# PC2 Test Level & Player Spawn — Design Note
> **Status:** Decided (PC2)
> **Owner:** Architecture
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
> (2) build the player & spawn it. Both must be able to follow this note
> without further design decisions.
## 1. Decision: static scene, not chunk streaming
**The PC2 playable test level is a single, hand-authored, static Godot 3D
scene.** It is **not** built through the chunk streaming system
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
`chunk_data_decoder.gd`).
This is a deliberate decision, not a default:
- **The streaming system has never been exercised with real content.** It is a
faithful port of the `docs/architecture/world.md` format spec, but nothing in
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
chunk authoring/streaming endpoint to integrate against.
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
streaming is server-authoritative streaming around a moving player across a
network boundary. None of those preconditions exist in PC2, so exercising the
pipeline here would mean building a chunk *authoring + serving* path first —
clearly out of checkpoint scope.
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
level and walk around" working**, which is the actual PC2 outcome being tested.
Chunk streaming is **not removed and not broken by this decision** — it stays as
an optional, later milestone. The static level is the PC2 proof of the *player*
and *camera/physics* half of the problem; chunk streaming remains the later proof
of the *world data* half.
## 2. Where things live
| Artifact | Path | Owned by |
|---|---|---|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
| Player scene | `scenes/player.tscn` | player task |
| Player controller script | `scripts/player.gd` | player task |
| Game composition scene | `scenes/game.tscn` | player task |
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
reference to `test_level`; it discovers the spawn point at runtime (see §3).
## 3. Player spawn convention
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
That node's world transform is the spawn point.
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
carries no gameplay behavior — it is the established Godot idiom for "position
hint." (Confirmed present in Godot 4.6.2.)
- The marker's **position** is authoritative; its rotation is ignored by the
player (spawn yaw is the player's concern, default forward is fine).
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
the ground top is `y = 0`) so the player capsule does not spawn intersecting
the floor and get popped out by the physics solver.
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
multiple spawns, that is a later convention — not PC2.)
**Lookup rule (implemented by `player.gd`, not by the level):**
```
# in player.gd _ready()
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
if spawn is Node3D:
global_position = spawn.global_position
else:
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
```
The lookup runs from the current scene root so it finds the marker whether it is
a direct child of the level instance or nested under a `Level` wrapper. The
fallback keeps the player runnable in a scene that forgot its marker, but a
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
on it (see §6).
## 4. Composition (who puts level + player together)
`scenes/game.tscn` is the PC2 playable entry point:
```
Game (Node3D)
├── Level (instance of res://scenes/worlds/test_level.tscn)
└── Player (instance of res://scenes/player.tscn)
```
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
instantiation, not code, so there is nothing to keep in sync.
- The **level scene is self-contained and has zero dependency on the player** —
it loads and is smoke-testable on its own. The level task does not instance or
reference `player.tscn`.
- The **player scene is self-contained and has zero dependency on any specific
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
- Ownership of `game.tscn` goes to the **player task**, which depends on the
level task having landed `test_level.tscn` first (chain with `depends_on`).
## 5. Level content requirements (level task)
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
children:
| Node | Type | Notes |
|---|---|---|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
Notes:
- **Do not** add any `script` to the level. It is pure content. Any per-level
behavior belongs to the player (or a future game-mode script), not the level.
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
coordinates, no thin walls the capsule can tunnel through.
## 6. Player contract (player task)
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
Minimum for PC2:
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
the lowest-risk choice; third-person is fine too but is more work.
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
gracefully.
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
may be split into its own task if the controller grows.
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
The player must work when instanced under `Game` alongside a level; it must not
assume it is the only node in the scene.
## 7. Entry point / how a human plays it
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
`application/run/main_scene` to it.
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
for this note** and out of scope for the level/player tasks. The current menu
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
the test level, that navigation wiring is its own small task — do not fold it
into level or player.
## 8. Validation (both tasks must run this before "done")
```sh
# one-time, first checkout only — builds the class/import cache
godot --headless --path . --import
# then, to catch parse/load errors:
godot --headless --path . --quit
```
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
`scripts/smoke_test_*.gd` pattern is the right vehicle.
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
and asserts the player's global position equals the `PlayerSpawn` position
(within a small epsilon). This is the check that proves the spawn convention
actually wired through — not just "the scene loads."
## 9. Explicitly out of scope for PC2
- Networking / multiplayer / prediction (single-player, local-only).
- Chunk streaming integration and server-side chunk authoring/serving.
- Avatar customization UI and glTF avatar loading.
- Join-a-world navigation from the world list.
- Any content moderation / age-gating logic in the level.
## 10. Summary
| Concern | Decision |
|---|---|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
| Level location | `scenes/worlds/test_level.tscn` |
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
+5 -1
View File
@@ -1,6 +1,10 @@
# World Chunk Data Format & Persistence Architecture
> **Status:** Proposed
> **Status:** Proposed. The chunk format (§2, §3) is normative and implemented
> on the client. **§4 (SQLite schema) and §5 (write ownership / write-behind)
> are superseded by [`world_persistence_impl.md`](world_persistence_impl.md)**,
> which stores chunks in the existing Postgres backend and makes the FastAPI
> process — not the per-world Godot server — the sole writer.
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
+655
View File
@@ -0,0 +1,655 @@
# World Persistence: Server-Side Implementation Architecture
> **Status:** Accepted (design). Implementation is split across two follow-on
> tasks (backend endpoints; editor wiring).
> **Owner:** Platform team
> **Supersedes:** the *storage engine* and *write ownership* parts of
> [`world.md`](world.md) §4 and §5. Everything else in `world.md` — the chunk
> logical model, the JSON and binary encodings, checksums, revisions, content
> flags — stands unchanged and is normative.
> **Related:** [`world.md`](world.md) (chunk format), [`editor.md`](editor.md)
> (authoring surface), [`networking.md`](networking.md) (ENet sim transport),
> [`content_moderation.md`](content_moderation.md) (flags & gating).
## 1. Why this document exists
`world.md` was written during the client-side chunk-streaming work (M2). It
assumes **one** server that owns **one** embedded SQLite file and is both the
authoritative simulation and the authoritative store. That is not the system we
built. What actually exists today is:
| Process | Lifetime | Owns | Code |
|---|---|---|---|
| FastAPI backend | always on, single worker | Postgres (users, worlds, assets, avatar loadouts), game-server orchestration | `backend/app/` |
| Godot dedicated server, one per world | **spawned on join, reaped ~5 min after the last join when empty** | nothing durable; a 30 Hz in-memory player sim | `scripts/server.gd`, spawned by `backend/app/services/game_servers.py` |
| Godot client | player session | local chunk streaming, the in-game editor (planned) | `scripts/chunk_stream_manager.gd`, `scripts/chunk_data_decoder.gd` |
Nothing server-side has ever stored or served a real chunk. `server.gd`
`preload()`s `res://scenes/worlds/test_level.tscn` for **every** world and uses
`world_id` only for logging and the status file. `ChunkStreamManager` has a
`set_server_rpc()` seam that no code calls. The backend's `World` model is
metadata only (`id`, `name`, `description`, `owner_id`, `visibility`).
This document decides where chunk content lives, who may write it, how the
dedicated server gets it, and the exact API surface — so the two implementation
tasks that follow have no architectural decisions left to make.
---
## 2. Decisions at a glance
| # | Question | Decision |
|---|---|---|
| D1 | Where does chunk data live at rest? | **Postgres**, in the existing backend database. Not a per-world SQLite file. |
| D2 | Who writes it? | **The FastAPI backend, exclusively.** The editor writes through it over HTTP with optimistic concurrency. The dedicated Godot server never writes world content. |
| D3 | How does the dedicated server get content? | **HTTP pull from the backend at startup**, over a loopback-only `/api/internal` route authenticated by a per-spawn token the supervisor mints. Lazy fetch as players roam. |
| D4 | Edit while a live session exists? | **Out of scope this milestone, with a defined seam.** The editor is a non-joined client mode; writes always succeed and the response flags `live_session` so the UI can say "players see this when the session restarts". |
| D5 | Edit granularity on the wire | **Full-chunk PUT with `base_revision`**, not RFC-6902 deltas. |
| D6 | Client chunk transport | **HTTP from the backend**, not ENet from the dedicated server. The `set_server_rpc()` seam is satisfied by an HTTP-backed adapter. |
---
## 3. D1 — Chunk data lives in Postgres
### 3.1 Decision
World content (rooms, chunk payloads, chunk revisions) is stored in the
**existing Postgres database**, alongside `worlds`, in new tables created by an
Alembic migration. `world.md` §4's `world.db` SQLite file is **not** built.
The chunk **payload stays exactly what `world.md` §3.6 specifies**: the 48-byte
header + sectioned binary body, checksummed with SHA-256. It moves from a
SQLite `BLOB` to a Postgres `BYTEA`. Nothing about the format changes, so
`scripts/chunk_data_decoder.gd` remains correct and the format spec stays the
single contract.
### 3.2 Reasoning
1. **SQLite's premise does not hold here.** `world.md` §4 justifies SQLite as
"ideal for a server that owns the world store". No process in this system
owns a world store: the only process that could plausibly hold the file (the
dedicated Godot server) is *ephemeral* — reaped when idle, `SIGTERM`'d on
backend shutdown, `SIGKILL`'d 10 s later if it doesn't exit, and orphaned
entirely if the backend dies. Meanwhile the editor must write when no such
process exists. So the file would have to be shared by two processes with
independent lifetimes, which is precisely the workload SQLite's
single-writer/file-lock model handles worst.
2. **Postgres is already a hard dependency; SQLite would be a second one.** The
backend cannot start without Postgres. Adding a per-world SQLite file adds a
second storage engine, a second migration system (`world.md` §4.8 specifies a
hand-rolled `meta.schema_version` runner *next to* the Alembic we already
have), a second backup story, and a second consistency story.
3. **Referential integrity with metadata we already have.** `worlds` rows carry
`owner_id` and `visibility`, and every authorization rule for content derives
from them. In one database this is a join and a foreign key with
`ON DELETE CASCADE`; across two engines, deleting a world silently orphans a
file on disk and every ownership check becomes a cross-store read.
4. **Concurrency is the normal case, not the edge case.** Two creators editing
one world, or an editor writing while the backend serves chunks to a running
dedicated server, are ordinary. Postgres gives row-level MVCC and real
transactions for the read-modify-write in §5.4; SQLite gives a database-level
write lock and `SQLITE_BUSY`.
5. **The repo already has the precedent and the plumbing.** `avatar_loadouts`
(migration `0004`) stores a client-shaped document in Postgres JSONB rather
than inventing a side store, and `backend/app/database.py` already provides
the async session factory this code will use.
### 3.3 What we give up, and why it's acceptable
- **Zero-config embedding.** Irrelevant — the backend already requires a
configured Postgres.
- **`world.md` §5.5 write-behind + the `dirty` column.** Deliberately dropped
(§4.4). It existed to amortize disk I/O for a process that owned the file. Our
writes are human-rate editor saves, not sim-rate; each one is a single
transaction and is durable when the HTTP response returns. Dropping it also
deletes the entire "unclean shutdown recovery" scan in §5.5 — there is no
window in which committed content can be lost.
- **Sharding by `world_id` (`world.md` §8).** Postgres partitioning by
`world_id` remains available on the same table shape if a single table ever
becomes a bottleneck. At ~2–8 KB per chunk, 10 000 chunks is under 100 MB.
---
## 4. Schema (Alembic migration `0005_add_world_content_tables`)
New model module: `backend/app/models/world_content.py`, registered in
`backend/app/models/__init__.py`. `backend/app/models/world.py` is left alone
except for one added relationship, so nothing PC1/PC2 depends on moves.
Conventions follow the existing models: `Column(...)` declarative style,
`Integer` surrogate PKs, `DateTime(timezone=True)` with `server_default=func.now()`,
JSONB for document-shaped fields (as `avatar_loadout.py` does).
### 4.1 `world_rooms`
```python
class WorldRoom(Base):
__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)
# Chunk grid config. world.md §2.2 puts this on the world; it lives on the
# room so a world can later hold rooms with different grids, and so this
# migration is purely additive (the existing `worlds` table is untouched).
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` gains
`rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")`.
Every world gets a `main` room: created in `create_world`, and lazily created on
first room read/write for worlds that predate the migration. `grid` defaults
match `Chunk.CHUNK_SIZE_*` in `scripts/chunk.gd`, which is the client's source
of truth for the same constants.
### 4.2 `world_chunks`
```python
class WorldChunk(Base):
__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
room_id = Column(Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=False, index=True)
chunk_key = Column(String(160), nullable=False, unique=True) # see §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, BYTEA
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")),
)
```
Deltas from `world.md` §4.4:
- **Surrogate `id` PK + unique coordinate constraint**, with `chunk_key` kept as
a unique denormalized column. The key string is the client-facing contract
(`Chunk.build_key`) and is embedded in the payload, so storing it gives a
single-index lookup by key; the coordinate constraint is what actually
enforces one chunk per cell.
- **`dirty` removed** — see §3.3.
- **Content flags folded into the chunk row as JSONB + an indexed `is_mature`
boolean**, instead of `world.md` §4.6's separate 11-column `content_flags`
table. The flags are a document the client already produces in exactly this
shape (`ChunkDataDecoder.ContentFlags.to_dict()`), the only query we anticipate
is "is this mature", and folding them in removes a second write per save and
the `object_id = ''` sentinel hack that §4.6 needed to work around SQLite's
NULL-in-primary-key behaviour (Postgres has the same NULL semantics, so the
hack would have carried over).
- **`world.md` §4.5 `chunk_objects` is deferred, not dropped.** It is a derived
index that exists to answer queries ("which chunks contain a door?") that no
code asks yet, and maintaining it means a delete-and-reinsert of every object
row on every save. It is fully rebuildable from `payload`, so it can be added
in its own migration the day a query needs it, with a backfill. Do **not**
build it in `0005`.
### 4.3 `world_chunk_revisions`
```python
class WorldChunkRevision(Base):
__tablename__ = "world_chunk_revisions"
id = Column(Integer, primary_key=True, index=True)
chunk_key = Column(String(160), nullable=False, index=True) # deliberately NOT a FK
world_id = Column(Integer, nullable=False, index=True) # also not a FK, same reason
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
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
```
Kept from `world.md` §4.7 including its two design points: the full payload is
stored (rollback needs it) and `chunk_key`/`world_id` are **not** foreign keys,
so deleting a chunk or a world preserves the audit trail.
**Retention (new, required):** this table grows without bound — a creator
dragging a brush can produce hundreds of revisions per minute. The
implementation must ship a retention policy from day one: keep the newest
`WORLD_CHUNK_REVISION_KEEP` (default 20) payloads per `chunk_key` and null out
`payload` on older rows (keeping the metadata row for the audit trail), pruned
in the same transaction as the write. A `created_at` index supports a later
time-based prune.
### 4.4 Config additions (`backend/app/config.py`)
```
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB; a dense 32×32 uint16 layer is 2 KiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
```
### 4.5 Chunk key format
`world.md` §2.3 specifies `"{world_id}:{room_id}:{cx}:{cy}:{cz}"` and illustrates
it with `w_0001:r_0001`. Those ids were illustrative. The real key uses the
backend's identifiers:
```
"{world.id}:{room.slug}:{cx}:{cy}:{cz}" e.g. "12:main:3:-1:0"
```
The room **slug** rather than its numeric id, so a key is human-readable in logs
and stable if rooms are ever re-imported. `Chunk.build_key()` in
`scripts/chunk.gd` already produces this shape unmodified.
---
## 5. D2 — The FastAPI backend is the only writer
### 5.1 Decision
The in-game editor (`editor.md` §7.2) submits edits over **HTTP to the FastAPI
backend**, which validates, versions, and persists them. The dedicated Godot
server process **never writes world content** and does not sit in the edit path.
This preserves `world.md`'s and `editor.md`'s actual invariant — *clients never
touch storage; they submit edits to a server-authoritative tier that validates
them* — while identifying that tier as the always-on backend rather than the
ephemeral sim process. `editor.md` §4.2's claim that edits ride the same
`request_chunk_edit` RPC as gameplay is the one part of that doc this design
changes; the moderation and validation properties it derives from that claim are
unaffected, because the validation boundary moves with the writer.
### 5.2 Reasoning
1. **Nothing that can vanish in 300 seconds may own the write path.** The reaper
in `game_servers.py` `stop()`s an idle server with `SIGTERM` and escalates to
`SIGKILL` after 10 s; `supervisor.shutdown()` does the same to every server on
backend shutdown; and if the backend dies uncleanly the servers are orphaned
with no one to flush them. Because the sim process holds no unsaved world
content in this design, **no reap, crash, or kill can ever lose world data** —
that is the single strongest practical reason for this split.
2. **Editing must work with no dedicated server running.** A creator opening
their world to build should not have to spin up a 30 Hz sim process, burn a
port from the 100-port range, and hold it for 5 minutes past their last save.
3. **`server.gd` has no database access, no credentials, and no HTTP client.**
Routing writes through it means either shipping Postgres credentials into a
process spawned per join, or building an ENet→HTTP proxy inside the sim loop.
Both are strictly worse than the editor calling the backend directly.
4. **Authorization already lives in the backend.** `CurrentUser`, the
`owner_id` check, and `WorldVisibility` are all there. The dedicated server
has no user identities at all — it knows peer ids, not accounts.
### 5.3 Authorization
- **Read** a chunk: the same rule as `GET /api/worlds/{id}` — the world is
`PUBLIC`, or the caller owns it. Otherwise 404 (not 403), matching the
existing routers' deliberate non-disclosure.
- **Write** a chunk: caller must be `world.owner_id`, matching `update_world`;
403 otherwise. Collaborator/role-based editing is future work and slots in at
this one check.
- **Mature gating hook:** `is_mature` chunks are where `content_moderation.md`'s
age gate attaches on the read path. Specify the column and the index now; the
gate itself lands with that doc's work.
### 5.4 Concurrency
Optimistic concurrency exactly as `world.md` §5.4, expressed over HTTP:
1. Reader gets `revision` (header `X-Chunk-Revision`, or the JSON field).
2. Writer sends `base_revision`.
3. The service does `SELECT ... FOR UPDATE` on the chunk row, compares
`base_revision` to `revision`, and either commits at `revision + 1` or returns
**409** with `current_revision` and `current_checksum` so the editor can
re-fetch and re-apply.
4. `base_revision: 0` means "create; fail with 409 if it already exists".
### 5.5 Validation (the backend must not trust the client)
The write path decodes the submitted payload server-side before storing it. This
requires a **Python port of the `world.md` §3.6 codec** —
`backend/app/services/chunk_codec.py`, mirroring `scripts/chunk_data_decoder.gd`.
It checks: magic `AGPC`, `format_version <= 1`, declared vs. actual
`payload_len`, **recomputed** SHA-256 (the header's checksum is advisory), tile
layer dims equal the room's `grid.size`, tile indices in palette range, object
count ≤ `WORLD_CHUNK_MAX_OBJECTS`, object positions inside the chunk's bounds,
`chunk_key` in the payload matching the URL, and string-field length limits
(`world.md` §7). `content_flags` and `is_mature` are **recomputed** from the
payload's flag sections, never taken from the request body.
**Drift control (required):** the two codecs must be pinned by golden files —
fixture chunks under `tests/fixtures/chunks/` encoded by one implementation and
decoded byte-identically by the other, asserted in both the pytest suite and the
Godot headless suite. Without this the format has two implementations and no
contract test, which is exactly the failure `world.md` §5.2 was trying to avoid
by having one shared implementation.
---
## 6. D4 — Editing versus a live session (scope boundary)
**In scope for this milestone:**
- The editor is entered as **"Edit world" from the world browser**: a non-joined
client mode. It does **not** call `POST /api/worlds/{id}/join`, does not open
an ENet connection, and does not spawn a dedicated server. It loads chunks over
HTTP, edits them locally, and saves them over HTTP.
- Writes always succeed regardless of whether a session is live. The write
response carries `live_session: bool` (from `supervisor.debug_state()`), so the
editor can surface *"this world has players in it right now — they'll see your
changes when the session restarts."*
- A dedicated server started **after** a write sees the new content, because it
loads from the store at startup (§7).
**Explicitly out of scope, deferred:**
- Build-in-place while joined to a live session.
- `chunk_invalidated` push to connected clients (`world.md` §5.3) and hot-reload
of the dedicated server's collision world.
- Live collaborative editing / cursors (`editor.md` §9).
**The seam that makes the deferred work cheap:** the manifest endpoint (§7.2)
returns a monotonic `content_revision` for the world, bumped on every chunk
write. Hot reload is then: the dedicated server polls the manifest on a timer
(or the backend pushes to it over the same internal channel), re-fetches the
chunks whose revision changed, and rebuilds those collision bodies. Nothing in
this design has to change to add it.
**Why the editor must not join:** if it joined, the sim process would enforce
collision against content it loaded at spawn while the creator edits a diverging
local copy — players would walk through new walls and bounce off deleted ones,
with no error anywhere. Making build mode non-joined turns a silent correctness
bug into an explicit product boundary.
---
## 7. D3 — What the dedicated server does at startup
### 7.1 Decision
`server.gd` pulls its world's content from the backend over **HTTP** at startup,
authenticated by a **per-spawn token** the supervisor mints, and **does not bind
the ENet port until content is loaded**.
Rejected alternative — *supervisor writes a whole-world snapshot file at spawn
and passes `--world-file`*: attractive (no new auth surface, no HTTP client in
the sim process), but it makes spawn latency proportional to world size, gives
the server no way to fetch a chunk lazily as players roam beyond the preloaded
set, and still needs a separate channel for the hot-reload work above. Rejected —
*give the Godot process a Postgres connection*: there is no PG driver in
GDScript, and it would put database credentials in a process spawned per join.
### 7.2 Startup sequence
1. `_parse_args()` gains `--api-url` and `--server-token` (§7.3).
2. `GET /api/internal/worlds/{world_id}/manifest` → rooms, per-room `grid`,
`bounds`, `spawn`, `chunk_count`, and the world's `content_revision`.
3. `GET /api/internal/worlds/{world_id}/rooms/{slug}/chunks?` bounds query for
the spawn neighbourhood (radius from the spawn point, default 3 chunks), then
fetch each payload; decode with the existing `ChunkDataDecoder` and build
static collision from the tile layers and objects.
4. **Then** `network_peer.create_server(port)`, then write the status file.
5. **Fallback:** if the backend is unreachable, the world has no rooms, or the
room has zero chunks, load `test_level.tscn` as today and record
`"content": "fallback"` in the status file. A content outage must never make
a world un-joinable, and this keeps every PC3 flow working before any world
has authored content.
During the session the server may lazily fetch chunks as players approach
unloaded coordinates, through the same internal route. It **never writes**.
Dynamic entity runtime state (positions, health) stays in memory and out of
chunks, exactly as `world.md` §3.3 already specifies. Shutdown flushes nothing.
### 7.3 Internal authentication
`GameServerSupervisor._spawn()` generates `token = secrets.token_urlsafe(32)`,
stores it on the `_WorldServer` dataclass, and passes it on the command line
alongside the existing `--port/--world/--status-file`. A FastAPI dependency
`require_game_server` resolves `Authorization: Bearer <token>` against
`supervisor.validate_token(world_id, token)` — a dict lookup, since the
supervisor is an in-process singleton. Properties this gives us:
- A token grants access to **one** world and **dies with its process** (the
reaper's `del self._servers[wid]` invalidates it).
- No long-lived shared secret in config or on disk.
- It reuses the **already documented** single-worker constraint in
`game_servers.py`; it does not add a new one.
Rejected alternative — *let the dedicated server use the public routes with a
service user account*: the public routes enforce per-user visibility and
ownership, which a service identity would have to be exempted from, and it would
pollute `updated_by_id`/audit rows with a fake user. A separate `/api/internal`
prefix keeps the two auth models apart and can be blocked at the reverse proxy.
### 7.4 Lifecycle changes this requires in `game_servers.py`
These are real, load-bearing findings from the current implementation:
- **`_await_port()` is the wrong readiness signal now.** It returns as soon as
the UDP port is bound, and the port is bound before content exists today. With
content loading it must await a `"ready": true` field that `server.gd` writes
to the status file *after* content load + `create_server`. Otherwise the
backend hands a client an address for a server that is still fetching chunks.
- **`_await_port()` fails open.** After its 8 s deadline it returns normally,
with no error — `ensure_server` then reports success for a server that may
never have started. Content loading makes a slow start more likely, so the
readiness wait needs a real timeout error (503 to the joining client) and a
longer, configurable deadline.
- **The status file is the only server→backend channel**, and `_spawn` currently
passes no credentials at all — hence §7.3.
- **`player_count()` returns 0 when the status file is missing or unparseable**,
so a live server whose status write fails can be reaped after the idle timeout.
Harmless for data (the sim owns nothing durable — the point of D2) but worth a
comment where the fallback lives.
- **No crash restart** (documented in the module docstring): if a server dies,
the next join respawns it and it reloads content from the store. With D2 this
is now genuinely safe rather than merely tolerated.
---
## 8. API surface
All paths are under the existing `worlds` router prefix (`/api/worlds`) unless
noted. New router module: `backend/app/routers/world_content.py`, mounted at
`/api/worlds`; internal routes in `backend/app/routers/internal.py` at
`/api/internal`.
### 8.1 Public (editor & player client; JWT `CurrentUser`)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/worlds/{world_id}/rooms` | List rooms + grid config |
| `POST` | `/api/worlds/{world_id}/rooms` | Create a room (owner) |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Chunk **index** over a bounds query |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Fetch one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Write one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Write up to `WORLD_CHUNK_MAX_BATCH` chunks atomically |
| `DELETE` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Delete one chunk |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions` | Revision history (owner) |
| `POST` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback` | Restore a revision (owner) |
**Room list** — `GET .../rooms` → `200`
```json
[{ "id": 7, "slug": "main", "name": "Main",
"grid": { "size": [32, 32, 1], "origin": [0, 0, 0] },
"bounds": null,
"spawn": { "pos": [0, 1.5, 0], "yaw": 0.0 },
"chunk_count": 41 }]
```
**Chunk index** — `GET .../chunks?cx_min=&cx_max=&cy_min=&cy_max=&cz=0` → `200`.
Payloads are *not* included; this is `world.md` §5.1's `list_chunks(bounds)` and
lets a streamer decide what to fetch and skip anything already cached by
checksum.
```json
{ "room": "main", "content_revision": 118,
"chunks": [{ "key": "12:main:0:0:0", "cx": 0, "cy": 0, "cz": 0,
"revision": 4, "checksum": "9f86…", "payload_len": 2344,
"is_mature": false, "updated_at": "2026-09-14T10:02:11Z" }] }
```
Bounds are capped (default 32×32 chunks per query) and return `422` beyond that.
**Chunk fetch** — `GET .../chunks/{cx}/{cy}/{cz}`, content-negotiated:
- `Accept: application/octet-stream` (default for the game client) → `200` with
the raw `world.md` §3.6 bytes, `ETag: "<checksum>"`,
`X-Chunk-Revision: <n>`, `X-Chunk-Key: <key>`.
- `Accept: application/json` (tooling, tests, the editor's debug view) → the
§3.5 JSON encoding, decoded server-side from the payload. JSON is derived, never
stored, so there is no dual-write drift (`world.md` §4.4).
- `If-None-Match: "<checksum>"` → `304`. This is what makes the client's
`user://chunk_cache/` cache cheap and is why the checksum is the ETag.
- `404` if the chunk does not exist (an unwritten coordinate is not an error to
the streamer — it renders empty).
**Chunk write** — `PUT .../chunks/{cx}/{cy}/{cz}`
```
Content-Type: application/octet-stream
X-Base-Revision: 4 # 0 = create
<binary payload>
```
or, for clients that prefer JSON:
`{"base_revision": 4, "payload_b64": "…"}`.
Responses:
| Code | Body / meaning |
|---|---|
| `200` | `{"key", "revision", "checksum", "payload_len", "updated_at", "is_mature", "live_session"}` |
| `409` | `{"detail": "revision conflict", "current_revision": 6, "current_checksum": "…"}` |
| `403` | not the world owner |
| `404` | world or room not found / not visible |
| `413` | payload over `WORLD_CHUNK_MAX_PAYLOAD_BYTES` |
| `422` | decode/validation failure (`{"detail", "reason"}`, e.g. `checksum_mismatch`, `layer_dims`, `object_out_of_bounds`) |
**Batch write** — `PUT .../chunks` with
`{"chunks": [{"cx","cy","cz","base_revision","payload_b64"}, …]}`. Applied in
**one transaction, all-or-nothing**: if any entry conflicts, nothing is written
and the response is `409` naming the offending coordinate. Justification: an
editor brush stroke or an object drag routinely straddles a chunk boundary, and
without a batch the editor either issues N round trips or leaves half of a user
action persisted. Capped at `WORLD_CHUNK_MAX_BATCH` chunks and 8 MB.
### 8.2 D5 — Full-chunk PUT, not JSON-Patch deltas
`world.md` §5.4 specifies RFC-6902 deltas against `base_revision`. This design
sends **whole chunks** instead.
- A 32×32×1 chunk is ~2 KB of dense tile indices plus its objects — a few KB.
The bandwidth a patch saves is negligible at editor rates.
- The stored form is an opaque binary blob. Patching it means the backend must
decode → apply a JSON-Patch → re-encode → re-checksum, i.e. implement and test
a *second* mutation format on top of the codec, with its own failure modes, and
keep it in sync with the client's patch generator. That is the same dual-path
drift risk `world.md` §5.2 exists to prevent.
- Lost-update protection comes from `base_revision`, not from the patch, so
nothing is given up on correctness.
Delta encoding stays available as a pure bandwidth optimization later —
`world.md` §8 already files it as future work.
### 8.3 Internal (dedicated server; `require_game_server` token)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/internal/worlds/{world_id}/manifest` | Rooms, grids, spawn, `content_revision`, chunk counts |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks` | Bounds index (same shape as §8.1) |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Binary payload |
Read-only by construction — there is no internal write route, and adding one
would violate D2. The token is world-scoped, so a path whose `world_id` differs
from the token's world is `403`. These routes skip the per-user visibility check
(the sim process has no user) and apply no mature gating (gating is a client/user
concern; the server still needs collision).
### 8.4 D6 — The client streams chunks over HTTP, not over ENet
`world.md` §5.3 routes `request_chunk` / `chunk_data` over the game's ENet
connection. This design has the playing client fetch chunks from the **backend
over HTTP**, the same routes the editor uses.
- The dedicated server has no content the backend doesn't have; proxying bytes
through it adds a hop and puts multi-megabyte reliable transfers in contention
with the 30 Hz snapshot broadcast on the same ENet peer.
- HTTP gives caching for free — `ETag`/`If-None-Match` on the checksum, and a
local `user://chunk_cache/<checksum>` that survives sessions.
- `ApiClient` (`scripts/api_client.gd`) already carries the JWT and the result
contract; no new transport code.
**This does not require rewriting `ChunkStreamManager`.** It already calls
through a `set_server_rpc()` seam that only needs an object with
`request_chunk(key)`. The editor-wiring task supplies an `HttpChunkSource` that
implements that method against `ApiClient` and feeds
`_on_chunk_data_received(key, bytes)` — which also finally gives that dormant
seam a caller. Note the existing `_load_chunk()` builds its key with empty
world/room ids (`Chunk.build_key("", "", coord)`); the adapter must supply the
real world id and room slug.
---
## 9. What each follow-on task owns
**Backend task**
- `backend/app/models/world_content.py` + `0005_add_world_content_tables`
- `backend/app/services/chunk_codec.py` (Python port of `world.md` §3.6) and the
golden-file fixtures shared with the Godot suite
- `backend/app/services/world_store.py` (the `world.md` §5.1 service interface,
minus `flush_dirty`/`rebuild_index`, over Postgres)
- `backend/app/routers/world_content.py`, `backend/app/routers/internal.py`
- `game_servers.py`: per-spawn token, `--api-url`/`--server-token` args,
readiness-from-status-file instead of `_await_port` (§7.4)
- `create_world` creates the `main` room
**Client/editor task**
- `HttpChunkSource` adapter → `ChunkStreamManager.set_server_rpc()`
- Editor save path (single + batch PUT), 409 conflict re-fetch-and-reapply,
`live_session` warning UI
- "Edit world" entry from the world browser that does **not** join
- `server.gd`: arg parsing, manifest + chunk fetch before `create_server`,
collision build from decoded chunks, `test_level.tscn` fallback,
`"ready"`/`"content"` fields in the status file
---
## 10. Open questions (deliberately not decided here)
- **Hot reload / build-in-place** while a session is live (§6) — the
`content_revision` seam is in place for it.
- **Collaborator permissions** beyond `owner_id` — one check in §5.3.
- **Mature-content gating** on the read path — column and index exist; the
policy belongs to `content_moderation.md`.
- **`world_chunk_objects`** derived index — spec'd in `world.md` §4.5, build it
when a query needs it (§4.2).
- **Payload compression** — the §3.6 header reserves a flags bitmask; Postgres
TOAST already compresses `BYTEA`, so measure before adding a second layer.
- **Chunk streaming for very large worlds** — the bounds-query cap in §8.1 is a
guardrail, not a scaling plan.
+72
View File
@@ -0,0 +1,72 @@
# Package Checkpoint 1 (PC1)
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
clone (not a reused agent workspace).
## Automated validation
Run with `scripts/run_checkpoint_tests.py` — see
`docs/testing/test_inventory.md` for exactly what each suite covers.
| Check | Result |
|---|---|
| Alembic migrations, fresh DB | PASS |
| Backend pytest | 68/68 PASS |
| Godot smoke tests (4 files) | ALL PASS |
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
## Problems found during this checkpoint (fixed, tracked as tasks)
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
done — a historical data gap (a review-feedback follow-up task's
supersession link was never recorded, predating when that field started
being tracked). Backfilled in the PM database; confirmed green.
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
actually 68; said no integration tests existed, 4 real ones had landed).
Fixed via task `398bf1a22a2d` / PR #40.
## What's actually testable right now
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
the real scope of PC1; don't expect more when playtesting.
- Register a new account (18+ only, rejected otherwise)
- Log out, log back in with the same credentials
- Play → Browse Worlds — loads a (currently empty) world list with no errors
## Manual playtest: backend on cloudbison, client on pinkbison
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
env vars, no editing files. Foolproof enough to hand to someone else.
### 1. On cloudbison — the AGP dashboard
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
the API server, all in one step. The page shows the LAN address it's
reachable at once it's up (under a minute).
When you're done playtesting, click **Stop & Clean Up** on the same page —
it tears down everything that step started (API server, Postgres
container, its volume and network). Nothing is left running or lying
around afterward, even if the dashboard itself gets restarted in between.
### 2. On pinkbison — download and run the .exe
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
pick up newer changes) — this pulls the latest `main` and exports a Windows
binary with cloudbison's backend address already baked in. Click
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
environment variable, no config file, no build step on pinkbison at all.
### 3. Tear down when done
**Stop & Clean Up** on the dashboard page. That's it.
## Status
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
restricts agent claiming to the 5 PC1-scope features. Agents will not move
on to post-PC1 work until that's widened.
+97
View File
@@ -0,0 +1,97 @@
# Package Checkpoint 3 (PC3)
> **Status: partial.** PC3's planned scope was M4-avatars + M5-multiplayer +
> Foundation Hardening + M6–M10 (see `docs/build-plan-v1.md`). As of
> 2026-09-05, **M4, M5, and most of Foundation Hardening are done and
> checkpoint-verified**; M6–M10 have not been started. This doc records what
> is real. Whether to call this "PC3" (redefined scope, M6–M10 → PC4) or keep
> PC3 open is a user decision.
Built by Claude directly (fleet offline), branch `pc3` → `main`.
## What's real and verified (`scripts/run_checkpoint_tests.py`, all green)
| Check | Result |
|---|---|
| Alembic migrations, fresh DB | PASS |
| Backend `pytest` | 77/77 PASS (hermetic — auto-starts an ephemeral Postgres) |
| Godot smoke tests (~16 files) | ALL PASS |
| `smoke_test_network_client` (real dedicated server) | 4/4 PASS |
| 2-player multiplayer (`run_multiplayer_2p_test.sh`) | PASS |
| Fresh-clone boot (`verify_fresh_clone.sh`) | PASS |
| Linux + Windows exports | Build as real ELF64 / PE32+ binaries |
### M4 — Avatars in the game
- Placeholder avatar pack (14 GLBs, rigged 24-bone base body + attachments)
generated by `scripts/tools/gen_placeholder_avatars.gd`. Real art is a
content swap — catalog paths are stable.
- The avatar **loader was fixed** — it had never run against a real skeleton
(`Skeleton3D.has_bone()` isn't a Godot 4 method; the bone-copy dropped the
skin binding; attachments were parsed then discarded).
- `avatar_visualizer.gd` **rewritten** — the old one referenced a loader API
that never existed and didn't compile.
- Persistence: `avatar_loadouts` table + `GET/PUT /api/avatar/loadout`
(loadout stored as one JSON doc; server validates the envelope only).
- Wired into gameplay: the in-world player renders as the assembled avatar
(not a capsule); the loadout is fetched at login; "Customize Avatar" from
the main menu saves through to the backend.
- **Follow-up:** camera is still first-person, so the local player doesn't
see their own avatar much (third-person is a polish item); remote players'
avatar appearance is not replicated yet (they show as labelled capsules).
### M5 — Real multiplayer
- `server.gd` **actually runs a sim now** — loads the world level, spawns a
server player per peer, processes each peer's input queue in seq order at a
fixed 30 Hz, broadcasts full snapshots with a per-peer `last_seq`. (The
input queue was a no-op before.)
- `network_client.gd` reconciliation **replays in-flight inputs** from the
authoritative state (drops acked ones), snaps only on real drift.
- **Server orchestration** (`backend/app/services/game_servers.py`): one
headless Godot dedicated-server process per world, spawned on demand by
`POST /api/worlds/{id}/join`, port-allocated, with a status-file idle
reaper. Off by default; the playtest launcher (`checkpoint_ops.py`)
enables it.
- **Client play path**: world browser → `/join` → `Session` → `game.tscn`
connects → local player predicts with collision, input forwarded, server
position is a drift backstop, remote players render from snapshots,
disconnect returns to the world list. Falls back to local single-player
when the backend has no orchestration.
- **Verified**: two real client processes each `register → /join → play`,
hosted on an orchestrated server, see and move relative to each other
(`run_game_multiplayer_test.sh`).
### Foundation Hardening
- **Hermetic backend tests** — `cd backend && pytest` works from a clean
checkout (auto ephemeral Postgres + migrations, torn down after); skips
cleanly with a message if there's no Docker.
- **Fresh-clone boot check** — `verify_fresh_clone.sh` exports a pristine
tree, imports from scratch, boots headless, fails on any script error.
Also fixed the root cause: a stale `.godot/global_script_class_cache.cfg`
was committed to the repo.
- **Real age verification** — `/register` takes a date of birth (not a
checkbox), server computes age, 403 under 18. Still self-attested; the
limitation and the ID-check hook are documented
(`content_moderation.md` 5.2).
- **Auth rate limiting** — 10 attempts / min / IP on `/register` + `/login`.
- **CI workflow** — `.forgejo/workflows/checkpoint.yml` written, **not
running yet**: needs the operator to enable Forgejo Actions + register a
`host` runner (one-time; see `docs/ci.md`). Interim gate is
`run_checkpoint_tests.py` by hand / the dashboard.
## Not in PC3 (planned but not started)
- **M2** server-side world persistence
- **M6** scripting sandbox
- **M7** in-game editor
- **M8** social / chat
- **M9** economy
- **M10** discovery
## Playtest
Same as PC2 — grab the exe from the dashboard's `/checkpoints`, backend
pointed at it. New: with multiple people in the same world you now actually
see each other move.
+50
View File
@@ -0,0 +1,50 @@
# CI
`.gitea/workflows/checkpoint.yml` runs the full checkpoint suite
(`scripts/run_checkpoint_tests.py`: migrations on a fresh DB, backend
`pytest`, all Godot smoke tests, the 2-player multiplayer test, and the
fresh-clone boot check) on **every push to `main` and every PR**.
## Status: live
Set up 2026-09-05:
- Gitea Actions enabled (`[actions] ENABLED = true` in
`/DATA/AppData/forgejo/gitea/conf/app.ini`, container restarted; a
timestamped `.bak` is next to it).
- `act_runner` v0.2.13 registered as **`agp-host`** with label **`host`**
(host execution mode — jobs run directly on the AGP box, which already
has Docker, `~/godot/godot`, and Python). Lives in `~/act-runner/`.
- Green on the first successful run (`d76bf4d`).
The workflow uses **no JavaScript actions** (no `actions/checkout`), so the
host needs no Node: checkout is a shallow `git fetch` from the local Gitea
using the injected `${{ github.token }}`, and the suite runs in a venv the
job builds (host Python is PEP-668 externally-managed).
## Make the runner permanent (one command, needs sudo)
The runner currently runs as a plain background process — it won't survive a
reboot. A unit file is ready at `~/act-runner/act-runner.service`:
```bash
sudo cp ~/act-runner/act-runner.service /etc/systemd/system/
sudo systemctl daemon-reload
pkill -f 'act_runner daemon' # stop the temporary background one
sudo systemctl enable --now act-runner
```
## Interim / local
```bash
python scripts/run_checkpoint_tests.py
```
— also what `/checkpoint/<PC>` on the dashboard runs before a playtest build.
## Security note
A host-mode runner executes whatever a workflow says, directly on the box,
as `daddy` (in the `docker` group). Fine for a single-maintainer private
repo; revisit if the repo gets outside contributors (switch to a
docker-mode runner or an isolated VM).
+41
View File
@@ -0,0 +1,41 @@
# MVP Vertical Slice: Client-Server Wiring — Plan
## Goal
Prove the real Godot client and the real backend can talk: **a user can register/log in
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
explicitly out of scope.
## Verified backend contract (as of this writing)
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
Rejects under-18 with `400`.
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
public-worlds path, so the worlds list screen can only be shown after login/register.
- `GET /api/health` — unauthenticated, `{status, app}`.
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
## Task breakdown
| # | Type | Task | Depends on |
|---|------|------|-----------|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
## Design decisions captured (resolved in task 1)
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
open but not required yet (no re-login on restart in this slice).
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
- Login is form-encoded; register is JSON — the client helper must support both content types.
## Notes
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
- The world-list screen also needs to be **built** (a new scene), not just wired.
+50
View File
@@ -0,0 +1,50 @@
# Testing at Package Checkpoints
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
real milestones, the client and server builds get packaged together and
independently reviewed before anyone calls anything actually done. See
`project_agp` in Claude's memory, or ask, for the full definition; this
doc is just the mechanical "how do I run the tests" part of that review.
## Running the suite
```
python scripts/run_checkpoint_tests.py
```
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
backend pytest suite, starts the backend server, and runs every Godot
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
Postgres container down afterward (`--keep-postgres` to skip that if you
want to poke at the DB after a failure).
If port 5432 is already taken on the machine, pass `--postgres-port` with
something else.
Exit code is 0 only if everything that actually ran passed — suites with
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
reported SKIPPED, not treated as a failure.
## What this does and doesn't prove
Passing this script means: migrations apply cleanly to a genuinely empty
database, the backend's unit-level logic is correct, and whatever Godot
smoke tests exist actually round-trip against a live backend. It does
**not** mean full endpoint/integration coverage exists — see
`test_inventory.md` for the current, honest list of what's tested and what
isn't. A checkpoint review should still involve reading the actual diffs
for anything this script's current coverage doesn't reach, not just running
green and calling it done.
## Growing this over time
As real integration tests get added (endpoint tests hitting the FastAPI app,
more Godot smoke coverage beyond `health_check`), they should land under
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
picks up new files automatically, nothing to register by hand. Update
`test_inventory.md`'s tables when you do, so the inventory stays a source
of truth instead of quietly going stale.
Eventual goal (not implemented yet): gate merges to `main` on this script
passing, the same way a real CI pipeline would. For now it's a manual step
run at checkpoint review time.
+117
View File
@@ -0,0 +1,117 @@
# Test Inventory
What automated coverage actually exists, what it actually verifies, and what
doesn't have coverage yet. Keep this current as tests are added — it's the
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
to know what "run the tests" actually checked.
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
unreachable, 1px world cards, Play → Back losing the session) are covered
by the smoke tests below so they can't silently regress. PC2 (basic
playable world + character + movement) landed 2026-08-20/21 — see
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
`smoke_test_enter_world.gd`) for what's actually verified. The avatar
loader/customization system (`smoke_test_avatar_system.gd`) also landed
2026-08-21 — real glTF-based loading with graceful failure on missing
assets, verified since `assets/avatars/*.glb` model files don't exist yet.
## Backend — `backend/tests/` (pytest)
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
reachable Postgres — see the runner script).
**Current total: 61 tests, all passing on a clean `main` checkout.**
| File | Tests | What it covers |
|---|---|---|
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
that hit the FastAPI app against a real Postgres database via `TestClient`.
The integration tests exercise the full stack: Pydantic validation → DB →
JWT → response. The suite is also re-runnable without manual cleanup —
`backend/tests/conftest.py` has a session-scoped autouse fixture that
truncates all app tables once before the suite runs.
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
these yet):
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
world/asset CRUD routes — only auth endpoints have integration coverage
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
- No tests for the moderation schema/migration beyond "the migration runs."
- No tests for the `GET /api/auth/me` endpoint's response body (only its
auth-header validation is covered via expired-token tests).
- No tests for session management (create/validate/revoke sessions).
## Godot client — `scripts/smoke_test_*.gd`
**12 smoke tests merged into `main`.** Ten need a live backend, one
(`smoke_test_network_client.gd`) needs a live dedicated server instead,
and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises
local catalog/loadout/loader logic. Backend-dependent tests read
`AGP_API_BASE_URL` env var, falling back to the
`application/agp/api_base_url` project setting (default
`http://127.0.0.1:8000`) if unset.
| File | What it covers |
|---|---|
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
their awaited HTTP calls finish.
**Known gaps:**
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
- No smoke tests for dynamic/static object nodes.
- All smoke tests require a live backend; no offline/unit smoke tests exist.
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
ones that exercise a real `.tscn` scene instantiation — others only test
the `ApiClient` autoload directly.
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
has no smoke coverage yet.
- The Login path (as opposed to Register) through `main_menu.gd` isn't
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
`_on_auth_complete()` code path already exercised for Register, but that's
inference, not a direct check.
## Client export
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
exports complete clean from a fresh checkout and produce valid binaries
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
export is exercised by an automated GUI test (headless Godot can't drive a
window) — actually launching and clicking through the exported binary is a
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
## What "full coverage" should eventually mean here
Not defined precisely yet — the honest target as of 2026-08-20 is
"integration tests for every endpoint + smoke tests for every major scene."
As real integration/endpoint tests get added, update the tables above
rather than letting this doc drift stale; a wrong inventory is worse than
no inventory.
+74
View File
@@ -0,0 +1,74 @@
[preset.0]
name="Linux"
platform="Linux/X11"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="*.json"
exclude_filter=""
export_path="export/linux/agp-game.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false
[preset.1]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="*.json"
exclude_filter=""
export_path="export/windows/agp-game.exe"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
codesign/enable=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
d3d12/agility_sdk_multizip=0
+44
View File
@@ -9,15 +9,59 @@ config_version=5
config/name="AGP-Game"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6.2")
agp/api_base_url="http://127.0.0.1:8000"
[autoload]
ApiClient="*res://scripts/api_client.gd"
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
Session="*res://scripts/session.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
[input]
move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
move_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
toggle_build_mode={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194333,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics]
; Fixed 30 Hz sim -- the multiplayer server, client prediction, and snapshot
; rate all key off this. See docs/architecture/networking.md 3.1.
common/physics_ticks_per_second=30
[rendering]
renderer/rendering_method="gl_compatibility"
-1
View File
@@ -1 +0,0 @@
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://avatar_customization"]
[ext_resource type="Script" path="res://scripts/avatar_customization_screen.gd" id="1"]
[node name="AvatarCustomization" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

Some files were not shown because too many files have changed in this diff Show More