Commit Graph

358 Commits

Author SHA1 Message Date
daddy df75e87885 Merge branch 'agent/264d84d07f28-godot-social-client' into main
checkpoint / checkpoint (push) Successful in 5m9s
2026-09-15 19:25:47 +00:00
daddy 246b7ac996 Implement Godot API client with REST social helpers and WebSocket gateway client (task 264d84d07f28) 2026-09-15 19:25:27 +00:00
daddy 21397b710a Merge branch 'agent/062e441c1419-ws-gateway' into main
checkpoint / checkpoint (push) Successful in 5m6s
2026-09-15 18:58:33 +00:00
daddy e5bcd61ee4 Implement real-time WebSocket gateway for presence, DM, and world chat (US-4, US-2) 2026-09-15 18:58:19 +00:00
daddy 4fc297be43 Merge branch 'agent/39df5555656f-world-chat' into main
checkpoint / checkpoint (push) Successful in 4m59s
2026-09-15 17:55:56 +00:00
daddy 39a971f00e Implement in-experience (world/room) chat endpoints and pub/sub hook (US-4) 2026-09-15 17:55:47 +00:00
daddy d3389a9f48 Merge branch 'agent/8aaf7c48d376-reports' into main
checkpoint / checkpoint (push) Successful in 4m49s
2026-09-15 17:31:44 +00:00
daddy 429cf6f46c Implement message reporting endpoint and moderation review queue (US-4)
- POST /api/chat/messages/{message_id}/report for reporting messages with category/reason
- Idempotent duplicate report rejection (409)
- GET /api/moderation/reports for queue listing (restricted to moderators/admins)
- POST /api/moderation/reports/{id}/resolve supporting dismiss, remove, warn, ban actions
- Audit fields (moderator_id, resolved_at, action) recorded on resolution
- Added 9 integration tests in backend/tests/test_chat_reports.py (all passing)
2026-09-15 17:31:43 +00:00
daddy 01a5520c20 Merge branch 'agent/5645c26acd5c-public-profile' into main
checkpoint / checkpoint (push) Successful in 4m36s
2026-09-15 17:18:29 +00:00
daddy 0b525d6996 Add public profile view endpoint: favorites, friends, privacy enforcement (US-3)
- Implemented GET /api/profile/{user_id} in backend/app/routers/profiles.py
- Server-side privacy enforcement for profile_visibility, favorites_visibility, and friends_list_visibility
- Block relationships enforce restricted view (no bio, empty favorites, empty friends)
- Non-friends restricted when profile_visibility or sections set to friends-only
- Asset public check for avatar in restricted view
- No private user fields leaked (email, password excluded)
- Added 7 integration tests in backend/tests/test_profile.py covering all privacy and block permutations

Independently verified: 26/26 profile tests passing.
2026-09-15 17:18:28 +00:00
daddy eacdeb5b46 Merge branch 'agent/d6b3f928e9ad-dm-chat' into main
checkpoint / checkpoint (push) Successful in 4m30s
2026-09-15 16:56:42 +00:00
daddy 8b17c17137 Add DM chat send/list endpoints: filter, rate limit, conversations (US-4)
Implements direct-message (DM) chat send and history endpoints:
- Migration 0011_add_dm_last_read.py (last_read_at on conversation_participants)
- Models updated in backend/app/models/chat.py
- Router in backend/app/routers/chat_dm.py:
  - POST /api/chat/dm/{user_id}/messages (send DM, filtered via TextFilter, rate-limited via Redis)
  - GET  /api/chat/dm/{user_id}/messages (paginated history, updates last_read_at)
  - GET  /api/chat/dm/conversations (conversations list, last message, unread count)
- Blocks enforced in both directions (cannot send to/from blocked users)
- Self-messaging prevented, peer exist check, auth required
- Integration tests in backend/tests/test_chat_dm.py

Independently re-verified: 14/14 new DM chat tests pass,
272/272 full backend regression suite passes with zero regressions.
2026-09-15 16:56:13 +00:00
daddy 24024a3463 Merge branch 'agent/86f6ef19b352-profile-model' into main
checkpoint / checkpoint (push) Successful in 4m8s
2026-09-15 16:44:16 +00:00
daddy 0a0dde3d50 Add Profile model + migration + own-profile endpoints (US-3)
Implements user profile data model and own-profile management endpoints:
- Profile and ProfileFavorites models in backend/app/models/profile.py
- Migration 0010_add_profile_tables.py (profiles, profile_favorites)
- Endpoints in backend/app/routers/profiles.py:
  - GET  /api/profile/me (own profile with default fallback)
  - PUT  /api/profile/me (upsert bio, avatar_asset_id, privacy settings)
  - GET  /api/profile/me/favorites (list favorite world IDs)
  - PUT  /api/profile/me/favorites (replace favorite world IDs)
- Fixes values_callable on Asset.asset_type ENUM in app/models/asset.py
- Added test_profile.py covering auth, validation, favorites, privacy

Independently re-verified: 19/19 new profile tests pass,
258/258 full backend regression suite passes with zero regressions.
2026-09-15 16:44:00 +00:00
daddy abd05b266d Merge branch 'agent/8bc166bd983b-friends-router' into main
checkpoint / checkpoint (push) Successful in 3m57s
2026-09-15 16:28:31 +00:00
daddy 11c4810231 Add Friends & follow API router: requests, accept/decline, follow, block
Implements docs/planning/social.md US-1's API layer on top of the
merged Friendship/Follow/Block models (app/models/social.py):

- POST/GET /api/friends/requests, POST .../accept, POST .../decline
- DELETE /api/friends/{user_id} (unfriend)
- POST/DELETE /api/friends/{user_id}/follow, GET /api/friends/following
- POST/DELETE /api/friends/{user_id}/block, GET /api/friends/blocked
- GET /api/friends (mutual friends list)

All endpoints require JWT auth. Requests are scoped to caller (no
cross-account enumeration); duplicate/self requests are rejected
idempotently; block clears mutual friendship and follow in both directions.

Independently re-verified: 19/19 new unit/integration tests pass,
239/239 full backend test suite passes with zero regressions.
2026-09-15 16:28:09 +00:00
daddy e2c8671c57 Groups API (CRUD, join/leave, roles, group page)
checkpoint / checkpoint (push) Successful in 3m29s
Full CRUD + membership lifecycle on top of the merged Group/GroupMember
models: create/get/update/delete, join (idempotent)/leave (owner must
transfer first), role-set (owner-only, rejects setting role=owner --
must use /transfer), ownership transfer (exactly-one-owner invariant:
promotes new owner, demotes old to admin). Group page member list uses
an explicit allowlisted schema, no private user fields leak.

Independently re-verified: 220/220 backend tests, transfer/leave/
role-set authorization logic read directly and confirmed sound.
2026-09-15 12:11:42 +00:00
daddy f6c55d888f Add Groups API router: create/join/leave, roles, transfer, group page
Implements docs/planning/social.md US-5's API layer on top of the
already-merged Group/GroupMember models (app/models/groups.py):

- POST/GET /api/groups, GET/PATCH/DELETE /api/groups/{id}
- POST .../join (idempotent), .../leave (owner must transfer first)
- POST .../members/{user_id}/role (owner-only, admin/member),
  .../transfer (owner-only, demotes prior owner to admin)

Group page member list is a dedicated schema (user_id/username/role/
joined_at) so no private User fields leak. 27 new tests cover
authorization boundaries, idempotency, and exactly-one-owner invariants;
full backend suite (220 tests) passes with zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFSPt6rX1gwZmcr2HEfvzg
2026-09-15 01:09:29 +00:00
daddy eac6cf81ef Text filtering service + profanity config
checkpoint / checkpoint (push) Successful in 2m56s
TextFilter protocol + ListBasedTextFilter: normalized (case-fold +
leetspeak/lookalike substitution, non-alnum stripped) substring matching
against a configurable JSON term list, with an index map so matched spans
in normalized text translate back to the original text for redaction.
Supports both redact and reject modes. No external service dependency;
the protocol itself is the extension point for a future third-party
provider. Standalone service module, not yet wired into any endpoint
(sibling tasks own that).

Independently re-verified: 193/193 backend tests, no regressions.
2026-09-15 01:02:00 +00:00
daddy 10a431e7a1 Merge branch 'main' into verify-4daac3f1cfba 2026-09-15 00:37:58 +00:00
daddy 0b3622d6b3 Group + membership models + migration
checkpoint / checkpoint (push) Successful in 2m55s
groups (owner SET NULL -- a group is a durable community, not
single-owner content, survives owner deletion) and group_members
(owner/admin/member role, unique group_id+user_id, CASCADE both FKs).
Models/migration only, no API endpoints (sibling task).

Independently re-verified: migration round-trip, schema inspected
directly (FKs/constraints/indexes match spec), 170/170 backend tests.
2026-09-15 00:37:47 +00:00
daddy eb2328b427 Merge branch 'main' into verify-ff065522ba55 2026-09-15 00:35:40 +00:00
daddy 55f849f221 Reusable named rate-limiting dependency (extend existing in-process limiter)
checkpoint / checkpoint (push) Successful in 2m48s
Generalizes app/rate_limit.py's SlidingWindowLimiter pattern (deliberately
in-process, no Redis -- this backend runs single-worker per that file's
own documented reasoning) into a named-limit factory: rate_limit(name,
max_hits=, window_seconds=) returns a FastAPI dependency, reusing the
existing 429+Retry-After behavior and RATE_LIMIT_ENABLED flag. Used by
chat-send/report endpoints (sibling tasks). auth_rate_limit itself
untouched; reset_all() generalized to cover every registered limiter.

Independently re-verified: 164/164 backend tests, no regressions.
2026-09-15 00:35:28 +00:00
daddy 600261dba2 Merge branch 'main' into verify-9eaf37ab424c 2026-09-15 00:33:52 +00:00
daddy c256fb2334 tests/conftest.py: derive the hermetic-bootstrap compose project/ports per checkout
checkpoint / checkpoint (push) Successful in 2m47s
Hardcoded _COMPOSE_PROJECT="agp-pytest" + fixed ports meant every
concurrent checkout running pytest (e.g. multiple Agent-tool subagents,
each in their own workspace clone) fought over the same Docker Compose
project and host ports -- one run's pytest_unconfigure teardown could
yank the ephemeral Postgres out from under a completely unrelated
concurrent run. Confirmed live running 3 parallel subagents: one hit
"connection was closed in the middle of operation", another independently
hit a transient DeadlockDetectedError from concurrent table truncation on
the same shared container. Same root cause and fix pattern as the
agent_core workspace/Docker-Compose collision documented in project_agp
memory -- derive both the project name and the ports from a hash of the
checkout directory so concurrent checkouts land on different
projects/ports while a single checkout stays stable across repeated runs.
2026-09-15 00:33:46 +00:00
daddy 9334caf533 Add pluggable server-side TextFilter service (social.md §4 US-4)
Deterministic default (ListBasedTextFilter) backed by a configurable JSON
term list, with normalization (case-fold, leetspeak substitutions,
stripped punctuation/spacing between letters) so trivial evasion doesn't
slip through. Supports both reject (blocking) and redact (asterisk
replacement) modes. TextFilter is a Protocol, leaving an explicit
extension point for a future third-party moderation provider.

Standalone module, not wired into any router/endpoint -- sibling chat
tasks depend on this one. No DB/migration work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:32:17 +00:00
daddy 712d21e746 Add groups + group_members models and migration
docs/planning/social.md US-5 (task 13). Models + Alembic migration only,
per the sibling-dependency boundary with the Groups API task.

- groups: id, name, description, owner_id (FK users, ON DELETE SET NULL --
  a group is a durable multi-member community, so it and its full roster
  survive the owner deleting their account; the API task handles surfacing
  an ownerless group for reassignment).
- group_members: group_id/user_id (both FK, ON DELETE CASCADE -- pure
  join-table rows with no meaning without either side), role
  (owner/admin/member native enum, default member), joined_at, unique
  constraint on (group_id, user_id).
- User gets owned_groups / group_memberships relationships.
- tests/conftest.py TEST_TABLES updated for truncation between tests.
- New tests/test_group_models.py: role assignment, unique-membership
  rejection, group-delete cascade, member-delete cascade, and owner-delete
  SET NULL survival -- all verified against real Postgres.

Verified: upgrade/downgrade/upgrade round-trip, \d-inspected schema matches
spec (FKs, cascade behavior, unique constraint, indexes), full backend
suite 166 passed / 0 regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:28:46 +00:00
daddy de2d431571 Generalize rate_limit.py into a reusable named-limit dependency
Adds rate_limit(name, max_hits=, window_seconds=) as a factory for FastAPI
dependencies, generalizing the auth_rate_limit pattern for future endpoints
(chat send, message report) without touching auth_rate_limit's own behavior.
Reuses SlidingWindowLimiter for counting and the existing 429 + Retry-After
response, and respects RATE_LIMIT_ENABLED the same way auth_rate_limit does.
reset_all() now clears every registered limiter (auth's plus any named ones),
so the conftest autouse fixture keeps working for sibling tasks automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:26:48 +00:00
daddy f40940a491 Chat data models + migration (conversations, messages, reports)
checkpoint / checkpoint (push) Successful in 2m47s
conversations (denormalized ordered user_a/user_b pair + check constraint
for cheap uniqueness, plus a real conversation_participants join table
for future >2-party support), messages (nullable conversation_id/world_id/
room_id, sender SET NULL to survive account deletion, room_id correctly
FKs into world_rooms), message_reports (SET NULL on message/reporter/
moderator -- audit trail survives deletion, matching the existing
WorldChunkRevision precedent). Models/migration only, no API endpoints.

Independently re-verified: migration round-trip, full schema inspected
directly (FKs/cascade/constraints/indexes all match spec), 160/160
backend tests via the hermetic bootstrap.
2026-09-15 00:22:12 +00:00
daddy 546678a30d Add chat data models + migration (conversations, messages, reports)
docs/planning/social.md US-4, task 6: conversations,
conversation_participants, messages (DM + world/room chat), and
message_reports, following app/models/social.py's conventions (native
enums, explicit index/constraint names, CASCADE FKs where appropriate).

Delete behavior, chosen per-relationship:
- conversations.user_a_id/user_b_id -> CASCADE: a DM is meaningless with
  one of its two participants gone, so it cascades away entirely
  (participants + messages with it).
- messages.sender_id -> SET NULL: world/room chat history and reported
  messages must survive the sender's account being deleted, matching the
  existing actor-reference convention (WorldChunk.updated_by_id,
  WorldChunkRevision.actor_id).
- messages.world_id/room_id -> CASCADE: chat scoped to a world/room has no
  meaning once that world/room is gone (mirrors WorldChunk).
- message_reports.message_id -> SET NULL (not CASCADE): a moderation
  report must outlive the message it's about, mirroring
  WorldChunkRevision's audit-trail-survives-deletion precedent.
- message_reports.reporter_id/moderator_id -> SET NULL: audit-actor
  references, same reasoning.

Models + migration only, no endpoints, no text filtering (sibling tasks).

Verified: migration upgrade/downgrade/upgrade round-trip against a real
Postgres with schema inspected via \d against spec; new model tests cover
DM creation, pair uniqueness + ordering check constraint, world/room
messages, report defaults, and cascade behavior for both
conversation-deletion and sender-deletion; full backend suite (160 tests)
passes with no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:19:53 +00:00
daddy d1f4547c5f Friends/follow/block models + Alembic migration
checkpoint / checkpoint (push) Successful in 2m37s
friendships (requester/addressee, pending/accepted status, unique ordered
pair), follows, and blocks tables -- CASCADE FKs, indexed, native Postgres
enum for status matching existing repo convention. Models/migration only,
no API endpoints (sibling task).

Independently re-verified: alembic upgrade/downgrade/upgrade round-trip
clean, schema inspected directly (FKs/cascade/unique constraints all as
specified), 152/152 backend tests via the hermetic bootstrap.
2026-09-15 00:12:30 +00:00
daddy 9661b9a81c Add social graph models: friendships, follows, blocks
Models + Alembic migration (0007, head) only, per docs/planning/social.md
US-1 -- the friends/follow API router is a separate sibling task.

- friendships: requester_id/addressee_id (FK users, CASCADE), status
  (pending/accepted native enum), unique pair constraint, indexed FKs.
- follows: follower_id/followed_id (FK users, CASCADE), unique pair.
- blocks: blocker_id/blocked_id (FK users, CASCADE), unique pair.
- Relationships wired onto User for both directions of each table.
- tests/test_social_models.py exercises request/accept, the unique-pair
  constraint, follow, block, and DB-level CASCADE delete against real
  Postgres. Full backend suite (152 tests) still green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:10:15 +00:00
daddy 4eb4476fc3 Presence & status model + endpoints (Redis heartbeat) -- recovered
checkpoint / checkpoint (push) Successful in 2m25s
Real feature built in August, checkpointed mid-task by the old fleet's
graceful-shutdown mechanism, then orphaned when checkpoint_focus_
feature_ids scoped the fleet away from M8 and it was never resumed --
the PM task had shown done/reviewed for a month with zero actual code on
main. Ported forward from the 225-commits-stale branch onto current
main's conventions (DbDep pattern, migration rebased onto 0005's head),
fixing two real bugs found in the process: the original PresenceStore's
explicit connect() was never called from anywhere (would 500 on every
real request), and a naive fix hits 'event loop is closed' under this
repo's TestClient(app) pattern -- fixed with lazy per-event-loop client
caching, same class of issue already documented for asyncpg in
conftest.py. conftest.py extended to also bring up ephemeral Redis
symmetrically with its existing Postgres bootstrap.

144/144 backend tests independently re-run via the fully automated
hermetic path (zero manual setup, matching what a fresh contributor
gets), plus a manual end-to-end heartbeat/TTL-expiry/batch-query check
against real Postgres+Redis.
2026-09-15 00:01:24 +00:00
daddy f34a4806ef Port presence feature forward from orphaned agent/dd2a126b57a1 branch
Recovers a real August feature that was never merged (3 WIP checkpoints +
fix commit 47ab492, 225 commits behind main): a Redis-backed heartbeat
presence store plus /api/presence/{heartbeat,presence,status} endpoints.
Ported onto current main's conventions rather than rebuilt:

- app/models/user.py: last_seen_at/status_message columns (unchanged from
  the orphaned branch; still compatible with the current model).
- alembic/versions/0006_add_presence_columns.py: same column migration,
  rebased as the new head after 0005_add_world_content_tables (was
  2a9b9873244a off 0001_initial).
- app/routers/presence.py: same three endpoints, updated to the DbDep
  pattern other routers (avatar.py) now use; retains the 47ab492 fix
  (is_online_batch via MGET, correct StatusUpdate Field annotation).
- app/services/presence.py: real adaptation beyond a mechanical port --
  the original PresenceStore required an explicit connect() that nothing
  ever called (main.py's lifespan never wired it up), so every real
  request would have raised "not connected". Made the Redis client connect
  lazily on first use instead, and cache it per running event loop rather
  than globally: this repo's TestClient(app) pattern (no `with` block) can
  dispatch different requests onto different event loops, which broke a
  single cached connection the same way NullPool's comment in conftest.py
  describes for asyncpg. Dropped the unused get_presence()/
  update_status_message() methods and the never-wired init/shutdown
  singleton helpers -- dead code even in the original.
- tests/conftest.py: extended the existing ephemeral-Postgres bootstrap to
  also bring up ephemeral Redis (docker-compose.yml already defined the
  service) when REDIS_URL isn't already reachable, so `pytest` stays
  hermetic.
- tests/test_presence.py: heartbeat sets online, batch presence over
  mixed online/offline/unknown ids (the exact 47ab492 regression), status
  message update/strip/length-limit, and TTL expiry via a short-TTL
  PresenceStore instance (real Redis SET/EXISTS/MGET, no 60s sleep needed).

Verified: full backend pytest suite (144 tests) passes against a fresh
Postgres+Redis. Also drove the real endpoints end-to-end against a running
uvicorn + docker-compose Postgres/Redis: heartbeat -> online, batch query,
forced TTL expiry -> offline with last_seen_at retained, status message
trim + 422 over 140 chars.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 23:59:14 +00:00
daddy 9f04cef447 Editor: save world edits to the server; re-enter and see the changes persist
checkpoint / checkpoint (push) Successful in 2m18s
ChunkSaveService (single PUT + base_revision, 409 -> refetch -> retry,
atomic batch PUT), wired into InGameEditor (Save/Ctrl+S, live_session
surfaced as a warning, not ignored). Owner-gated 'Edit World' entry from
the world browser that sets Session.editing without ever calling
join_world() -- editing is non-joined per world_persistence_impl.md §6.
game.gd fetches the real chunk over HTTP when entering edit mode.

19/19 new save-path smoke assertions (incl. a real two-writer 409
conflict resolved transparently, and reload confirming persistence) and
the full existing suite (18 Godot smoke tests + 132 backend pytest)
independently re-verified with zero regressions.
2026-09-14 23:38:46 +00:00
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