Compare commits

..

261 Commits

Author SHA1 Message Date
daddy 8019a1321c fix(studio): Add solid floor collision, live 3D gizmo dragging, inspector property sync, and nudge shortcuts
checkpoint / checkpoint (push) Successful in 6m23s
2026-09-22 21:07:12 +00:00
daddy 3f0ba543e1 feat(studio): Add Quick Start Guide, Viewport HUD, 3D raycast picking, quick add toolbar, and starter Baseplate
checkpoint / checkpoint (push) Successful in 6m20s
2026-09-22 20:11:02 +00:00
daddy 721dbe5ae7 feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s
2026-09-22 18:30:57 +00:00
daddy 654c1ec6bf Hide top action HUD and creator pill when world editor is active
checkpoint / checkpoint (push) Successful in 6m9s
2026-09-21 18:25:52 +00:00
daddy 509e85b53e Add bison model to default editor palette and object catalog
checkpoint / checkpoint (push) Successful in 6m10s
2026-09-21 18:11:06 +00:00
daddy ddcc0866b8 fix: Articulate pants with leg bones and fix walking animation clipping
checkpoint / checkpoint (push) Successful in 6m11s
2026-09-20 21:05:55 +00:00
daddy a7de464e1f fix: Scale and fit clothing attachments over body limbs to eliminate clipping
checkpoint / checkpoint (push) Successful in 6m11s
2026-09-20 20:51:05 +00:00
daddy 84b98e9b3a style: Double height of Water Buffalo lodge hat
checkpoint / checkpoint (push) Successful in 6m18s
2026-09-20 12:43:25 +00:00
daddy bb65274a49 style: Make water buffalo hat taller with rounded dome top, and shorten horns to white base and brown tip
checkpoint / checkpoint (push) Successful in 6m21s
2026-09-20 12:00:38 +00:00
daddy 62fa0b0067 feat: Add right hand/foot editor options, Flintstones Water Buffalo Lodge hat, hide controls in world editor, and easy main menu sound toggle
checkpoint / checkpoint (push) Successful in 6m19s
2026-09-20 11:40:41 +00:00
daddy 034a6901a9 test: add end-to-end smoke test for world chat send and sound toggle controls
checkpoint / checkpoint (push) Successful in 6m6s
2026-09-19 13:39:13 +00:00
daddy 7abf37ea81 fix(chat): support body and content fields in world and DM chat messages; feat(audio): add sound toggle switch and mute controls
checkpoint / checkpoint (push) Successful in 6m11s
2026-09-19 13:38:23 +00:00
daddy 7b693899a7 feat(mvp): support semantic versioning (MVP Build major.minor.release) in HUD and menus
checkpoint / checkpoint (push) Successful in 6m12s
2026-09-18 21:55:35 +00:00
daddy ee4006d217 feat(mvp): complete M13 clean-up milestone (real usernames, avatar studio redesign, pause menu, audio manager)
checkpoint / checkpoint (push) Successful in 6m22s
2026-09-18 21:54:01 +00:00
daddy 4c8a60fbc8 feat(audio): add built-in sound effects (bell, coin, chime, etc.), inspector sound presets, and preview player
checkpoint / checkpoint (push) Failing after 6m3s
2026-09-18 01:34:46 +00:00
daddy f93e99f042 feat(scripting): connect give_item to wallet rewards, add [E] Interact prompt and toast feedback
checkpoint / checkpoint (push) Failing after 6m2s
2026-09-18 01:04:36 +00:00
daddy 370395f1b6 feat(display): enable borderless fullscreen by default, F11/Alt+Enter toggle, and menu button
checkpoint / checkpoint (push) Failing after 6m3s
2026-09-18 00:56:19 +00:00
daddy 2b355d0ed8 feat(editor): add bundled 3D prop models and sample picker in Creator Studio
checkpoint / checkpoint (push) Failing after 3h14m9s
2026-09-17 23:35:33 +00:00
daddy 48aefe95ed docs: update AGENTS.md to PC5
checkpoint / checkpoint (push) Failing after 3h14m9s
2026-09-17 23:15:08 +00:00
daddy 7ecf0d53bd Complete M11 Creator Studio and M12 Trust & Safety for PC5
checkpoint / checkpoint (push) Failing after 3h14m9s
- Creator Studio: Visual behavior & property inspector, GLB model importer, binary persistence
- Trust & Safety: 18+ TOS confirmation (DOB removed), creator guidelines & briefing card on join, in-game reporting HUD
- Test Harness: Add --filter, --skip-backend, --skip-godot to run_checkpoint_tests.py
2026-09-17 23:13:57 +00:00
daddy 920354705a docs: expand README with comprehensive architecture, features, and controls
checkpoint / checkpoint (push) Successful in 5m59s
2026-09-17 19:53:45 +00:00
daddy 145af47068 chore: rename repo references and build presets to modbison
checkpoint / checkpoint (push) Successful in 5m59s
2026-09-17 18:41:48 +00:00
daddy aa5f945287 feat: rename game to ModBison and currency to Buffalo Chips
checkpoint / checkpoint (push) Successful in 5m56s
2026-09-17 18:29:48 +00:00
daddy e05e18c384 feat(ui): wire M8 social, M9 economy, and M10 discovery into main menu and in-game HUD
checkpoint / checkpoint (push) Successful in 5m54s
2026-09-17 18:04:47 +00:00
daddy ea6948a9ba Fix avatar base body polygon winding order to prevent backface culling transparency
checkpoint / checkpoint (push) Successful in 5m56s
2026-09-17 17:22:40 +00:00
daddy 50598a0dcb fix(multiplayer): replicate per-player avatar loadout and preserve original materials
checkpoint / checkpoint (push) Successful in 5m57s
2026-09-17 16:42:15 +00:00
daddy 7c77c0b447 fix: center ground under chunks and align player spawn with chunk grid
checkpoint / checkpoint (push) Successful in 5m56s
2026-09-17 13:36:59 +00:00
daddy bbd9ffc46f fix(world): correct 3D chunk coordinate mapping and streaming proximity
checkpoint / checkpoint (push) Successful in 5m55s
2026-09-17 12:06:06 +00:00
daddy d2b62c5d25 feat: stream chunks in-game, render 3D catalog props, and ground chunk coordinates
checkpoint / checkpoint (push) Successful in 5m54s
2026-09-16 21:31:33 +00:00
daddy e54ff6eeb2 fix: ensure server world always includes solid ground geometry to prevent falling through floor
checkpoint / checkpoint (push) Successful in 5m57s
2026-09-16 19:21:11 +00:00
daddy 41d1f7438c Fix build stamp visibility, add create world UI, and add Save as New World to editor
checkpoint / checkpoint (push) Successful in 5m54s
2026-09-16 14:21:48 +00:00
daddy 73172b1e28 fix(editor): move build stamp to bottom, expand palette list, add hover feedback and instructions
checkpoint / checkpoint (push) Successful in 6m5s
2026-09-16 12:21:39 +00:00
daddy 42520b33d5 fix(join): increase join_world timeout, log dedicated server stdout/stderr, null check multiplayer
checkpoint / checkpoint (push) Successful in 5m49s
2026-09-15 21:23:45 +00:00
daddy e6e22ac5f2 Merge branch 'agent/5a68d35804c7-pc4-packaging' into main
checkpoint / checkpoint (push) Successful in 5m51s
2026-09-15 21:05:42 +00:00
daddy 790d32845a docs(testing): update test inventory with social, economy, and discovery smoke tests for PC4 2026-09-15 21:05:35 +00:00
daddy e7ddeaa410 Merge branch 'agent/2b4212c24690-pc4-checkpoint' into main
checkpoint / checkpoint (push) Successful in 5m49s
2026-09-15 21:04:10 +00:00
daddy f891a958a9 docs(checkpoints): PC4 scope, test plan, and capabilities documentation 2026-09-15 21:04:03 +00:00
daddy 69b9cd5d8f Merge branch 'agent/b77bddf31ba5-godot-discover-home-ui' into main
checkpoint / checkpoint (push) Successful in 5m52s
2026-09-15 21:03:04 +00:00
daddy a9144a1160 feat(godot): discovery & home UI with search, categories, sorting, pagination, and world entry 2026-09-15 21:02:58 +00:00
daddy 6afee66b83 Merge branch 'agent/56c49440d0a6-godot-discovery-client' into main
checkpoint / checkpoint (push) Successful in 5m49s
2026-09-15 21:01:11 +00:00
daddy 434f776f9a feat(godot): discovery API client with typed WorldSummary and DiscoveryResult parsing 2026-09-15 21:01:05 +00:00
daddy f95edb6713 Merge branch 'agent/33706f401ad9-discovery-trending-sort' into main
checkpoint / checkpoint (push) Successful in 5m54s
2026-09-15 20:59:53 +00:00
daddy 6684578701 feat(discovery): trending sort heuristic over world_visits with decay weighting 2026-09-15 20:59:46 +00:00
daddy 77d4ea5669 Merge branch 'agent/300da1dcddf3-discovery-browse-search' into main
checkpoint / checkpoint (push) Successful in 5m50s
2026-09-15 20:58:29 +00:00
daddy dd4edecf11 feat(discovery): browse and search endpoint with name/tag/category filters, sorting, and categories listing 2026-09-15 20:58:25 +00:00
daddy bddee1b96d Merge branch 'agent/42726e60fe10-world-visit-tracking' into main
checkpoint / checkpoint (push) Successful in 5m43s
2026-09-15 20:57:31 +00:00
daddy 38dc0cab21 feat(discovery): world visit tracking endpoint with atomic play_count increment, last_played_at, and deduplication 2026-09-15 20:57:25 +00:00
daddy 892e330456 Merge branch 'agent/b377353b8167-world-discovery-metadata' into main
checkpoint / checkpoint (push) Successful in 5m38s
2026-09-15 20:56:07 +00:00
daddy e3188b6123 feat(discovery): world discovery metadata and world_visits model with migration 0015 2026-09-15 20:56:01 +00:00
daddy f8b1fc4ea7 Merge branch 'agent/96e5f11d7896-godot-selling-payout-ui' into main
checkpoint / checkpoint (push) Successful in 5m45s
2026-09-15 20:52:16 +00:00
daddy 041b5f907e feat(godot): selling, listing management, and creator payout UI 2026-09-15 20:51:45 +00:00
daddy 603ecdf5e2 Merge branch 'agent/9797a0cfd9e6-godot-marketplace-ui' into main
checkpoint / checkpoint (push) Successful in 5m55s
2026-09-15 20:50:50 +00:00
daddy ffa9a9e6da feat(godot): marketplace browse, search/filter and buy UI 2026-09-15 20:50:42 +00:00
daddy a4aa50edb9 Merge branch 'agent/34c725431645-godot-wallet-ui' into main
checkpoint / checkpoint (push) Successful in 5m45s
2026-09-15 20:49:52 +00:00
daddy 30ff63f3fd feat(godot): wallet balance, credit pack purchase and ledger history UI 2026-09-15 20:49:43 +00:00
daddy 5c4a45f3c9 Merge branch 'agent/cc09c1428892-godot-economy-api-client' into main
checkpoint / checkpoint (push) Successful in 5m43s
2026-09-15 20:48:20 +00:00
daddy 15ac91c42f feat(godot): add EconomyApiClient with wallet, marketplace, and payout helpers 2026-09-15 20:48:10 +00:00
daddy 70564ff799 Merge branch 'agent/4ba24a178c1b-marketplace-moderation-agegate' into main
checkpoint / checkpoint (push) Successful in 5m55s
2026-09-15 20:47:00 +00:00
daddy 397200818d feat(marketplace): moderation endpoints and age gate enforcement 2026-09-15 20:46:50 +00:00
daddy 33621a8e60 Merge branch 'agent/b52008923e93-payout-review-audit' into main
checkpoint / checkpoint (push) Successful in 5m47s
2026-09-15 20:44:34 +00:00
daddy 6ed409f727 feat(payouts): admin review, PayoutProvider interface, and ledger audit trail 2026-09-15 20:44:27 +00:00
daddy 997a217db5 Merge branch 'agent/f5b4684f2e65-payout-requests-api' into main
checkpoint / checkpoint (push) Successful in 5m43s
2026-09-15 20:43:35 +00:00
daddy cf17f29799 feat(economy): creator payout request models, migration, and API 2026-09-15 20:43:22 +00:00
daddy 71e741b5f9 Merge branch 'agent/bc90d3f481b4-purchase-escrow-service' into main
checkpoint / checkpoint (push) Successful in 5m41s
2026-09-15 20:41:15 +00:00
daddy 5e1a3bb16e feat(marketplace): purchase service with atomic escrow, 30/70 fee splitting and refunds 2026-09-15 20:41:07 +00:00
daddy 46cd5d6c95 Merge branch 'agent/f51c7d95e237-marketplace-listings-api' into main
checkpoint / checkpoint (push) Successful in 5m31s
2026-09-15 20:36:33 +00:00
daddy 90cc736d37 feat(marketplace): add marketplace listings API and catalog items CRUD 2026-09-15 20:09:07 +00:00
daddy 18306c72e0 Merge branch 'agent/166836d55365-marketplace-models' into main
checkpoint / checkpoint (push) Successful in 5m21s
2026-09-15 19:58:37 +00:00
daddy 0b7f407ad2 Implement CatalogItem, MarketplaceListing, Purchase, CreatorBalance models and Alembic migration 0013 (task 166836d55365) 2026-09-15 19:58:32 +00:00
daddy 30698a77d6 Merge branch 'agent/575fd14b5360-payment-provider-endpoints' into main
checkpoint / checkpoint (push) Successful in 5m19s
2026-09-15 19:56:31 +00:00
daddy 2b78729e6b Implement PaymentProvider interface, NoopPaymentProvider, and purchase/confirm endpoints (task 575fd14b5360) 2026-09-15 19:56:24 +00:00
daddy c9a8c4dfb2 Merge branch 'agent/909e3e9a59af-wallet-service-api' into main
checkpoint / checkpoint (push) Successful in 5m16s
2026-09-15 19:54:12 +00:00
daddy d2bf0e9118 Implement WalletService with atomic credit/debit, row locking, and wallet/transactions API (task 909e3e9a59af) 2026-09-15 19:54:03 +00:00
daddy 80cbcd334b Merge branch 'agent/8343bc0a730f-wallet-models' into main
checkpoint / checkpoint (push) Successful in 5m16s
2026-09-15 19:50:57 +00:00
daddy 64dd8b5f09 Implement wallet and transaction ledger models and Alembic migration (task 8343bc0a730f) 2026-09-15 19:50:53 +00:00
daddy 430042c585 Merge branch 'agent/909744ff1eee-godot-groups-ui' into main
checkpoint / checkpoint (push) Successful in 5m13s
2026-09-15 19:40:53 +00:00
daddy 8e54fee03d Implement Godot groups UI with browse, create, group page, and role management (task 909744ff1eee) 2026-09-15 19:40:41 +00:00
daddy 5b993aa358 Merge branch 'agent/121de83d3be8-godot-profile-ui' into main
checkpoint / checkpoint (push) Successful in 5m10s
2026-09-15 19:38:05 +00:00
daddy 9b381a2b50 Implement Godot profile page UI with bio editing, privacy controls, favorites, and friends (task 121de83d3be8) 2026-09-15 19:37:50 +00:00
daddy a3f895aa19 Merge branch 'agent/9b05df0c6869-godot-chat-ui' into main
checkpoint / checkpoint (push) Successful in 5m9s
2026-09-15 19:35:25 +00:00
daddy 484b455e44 Implement Godot chat UI with DMs, world chat, real-time WebSocket receipt, and reporting (task 9b05df0c6869) 2026-09-15 19:35:13 +00:00
daddy 056e5c9f86 Merge branch 'agent/af249a67f693-godot-friends-presence-ui' into main
checkpoint / checkpoint (push) Successful in 5m9s
2026-09-15 19:32:46 +00:00
daddy e112467b53 Implement Godot friends & presence UI (task af249a67f693) 2026-09-15 19:32:35 +00:00
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
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
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 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
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
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
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
368 changed files with 50730 additions and 356 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
+5
View File
@@ -11,3 +11,8 @@ Thumbs.db
# Godot UID files (regenerated from path) # Godot UID files (regenerated from path)
*.uid *.uid
assets/build_info.json
# Dynamic uploads
uploads/
backend/uploads/
+40
View File
@@ -0,0 +1,40 @@
# ModBison (Project & Agent Guidelines)
> **CRITICAL REBRANDING NOTICE**
> - **Game Name**: **ModBison** (formerly `agp-game`).
> - "ModBison" represents a play on game modification and the 1960s British "mod" subculture, adhering to the user's "bison" project naming convention (e.g. host machine `cloudbison`, Windows client machine `pinkbison`, social platform `WiseBison`).
> - **In-Game Currency**: **Buffalo Chips** (formerly `Credits` / `CR`).
> - Named playfully after bison poop. Displayed throughout all UI menus, wallet, marketplace, and creator payout screens as "Buffalo Chips".
> - **Git Repository**:
> - Local clone path: `/home/daddy/modbison` (symlinked as `/home/daddy/agp-game-dev` for backwards compatibility).
> - Gitea repository name: `modbison`
> - Clone URL: `http://localhost:3000/daddy/modbison.git`
> - Web UI: `https://git.cloudbison.duckdns.org/daddy/modbison`
> - **Binaries & Exports**:
> - Windows: `modbison.exe` (legacy alias `agp-game.exe` maintained for backwards compatibility)
> - Linux: `modbison.x86_64`
---
## Architecture Overview
1. **Client**: Godot 4.6.2 (GDScript).
- Headless engine binary located at `~/godot/godot`.
- Export presets defined in `export_presets.cfg`.
- Main scenes in `res://scenes/` (`main_menu.tscn`, `world_3d.tscn`, `wallet_ui.tscn`, `marketplace_ui.tscn`, `selling_payout_ui.tscn`, etc.).
- Scripts in `res://scripts/`.
2. **Backend**: FastAPI + SQLAlchemy (asyncpg) + Alembic in `backend/`.
- Python environment: `/home/daddy/agp/venv` (or project `.venv`).
- Runtime game server supervisor allocates headless Godot instances per active world session under `/tmp/modbison-game-servers`.
3. **Orchestration & Playtesting (`/home/daddy/agp`)**:
- PM Dashboard: `http://localhost:7070` (`agp-dashboard.service`).
- Checkpoint system: `pm/checkpoint_ops.py` controls playtest instances (`PC1` through `PC5`).
- Current active checkpoint: **PC5** (backend on `http://192.168.58.138:28005`, postgres on `25545`).
- Building Windows package: `PYTHONPATH=. /home/daddy/agp/venv/bin/python3 -c "import pm.checkpoint_ops as ops; ops.build_exe('PC5')"`
4. **Testing Protocols**:
- Headless smoke tests: `AGP_API_BASE_URL=http://127.0.0.1:28005 ~/godot/godot --headless --path . --script res://scripts/smoke_test_<name>.gd`
- Fast checkpoint tests: `PYTHONPATH=. /home/daddy/agp/venv/bin/python3 scripts/run_checkpoint_tests.py --skip-backend --filter <keyword>`
- Backend pytest: `PYTHONPATH=. /home/daddy/agp/venv/bin/pytest backend/tests`
+18
View File
@@ -0,0 +1,18 @@
# ModBison Guidelines for Claude Code
See [AGENTS.md](AGENTS.md) for full project conventions.
### Quick Reference:
- **Game Name**: ModBison (formerly agp-game)
- **Currency**: Buffalo Chips (formerly Credits / CR)
- **Repo URL**: `http://localhost:3000/daddy/modbison.git` (`https://git.cloudbison.duckdns.org/daddy/modbison`)
- **Working Tree**: `/home/daddy/modbison` (or `/home/daddy/agp-game-dev`)
- **Windows Executable**: `modbison.exe`
- **Linux Executable**: `modbison.x86_64`
### Commands:
- **Run tests**:
- Backend: `PYTHONPATH=. /home/daddy/agp/venv/bin/pytest backend/tests`
- Godot UI smoke tests: `AGP_API_BASE_URL=http://127.0.0.1:28004 ~/godot/godot --headless --path . --script res://scripts/smoke_test_<name>.gd`
- **Rebuild PC4 Windows package**:
- `PYTHONPATH=. /home/daddy/agp/venv/bin/python3 -c "import pm.checkpoint_ops as ops; ops.build_exe('PC4')"`
+128 -2
View File
@@ -1,3 +1,129 @@
# agp-game # ModBison
Agentic Game Platform - Godot game built by autonomous agents > **Godot 4 Multiplayer Sandbox Platform**
> Customizable 3D avatars, real-time multiplayer worlds, in-game building tools, social community hubs, and a creator economy powered by **Buffalo Chips**.
---
## 🦬 About ModBison
**ModBison** (formerly `agp-game`) is a collaborative sandbox game and platform built in Godot 4 and Python FastAPI.
* **The Name**: "ModBison" is a double play on game modification and the 1960s British "mod" youth subculture, aligning with the host and infrastructure naming convention (`cloudbison`, `pinkbison`, `WiseBison`).
* **The Currency**: **Buffalo Chips** — the in-game virtual currency used for marketplace trading, creator selling, and cash payouts (a playful reference to bison poop).
---
## 🏛️ Architecture Overview
The platform consists of two integrated environments:
1. **Client & Game Server (`modbison`)**:
- Built with **Godot 4.6.2** (GDScript).
- Headless Godot dedicated server (`scenes/server.tscn`) spawned per active world session via high ENet ports.
- Dual-platform export configuration: Windows Desktop (`modbison.exe`) and Linux (`modbison.x86_64`).
- Binary chunk streaming and client-side interpolation.
2. **Backend & Orchestration Platform (`agp`)**:
- **FastAPI** (Python 3.12) REST API with **PostgreSQL** (asyncpg) and **Alembic** migrations.
- **Redis** for real-time presence heartbeats and pub/sub messaging.
- **Game Server Supervisor**: Dynamic headless Godot instance orchestrator allocating runtime servers in `/tmp/modbison-game-servers`.
- **PM Dashboard & Checkpoints**: Live orchestration dashboard running at `http://localhost:7070` managing Package Checkpoints (**PC1** through **PC4**).
---
## ✨ What Has Been Built (Feature Summary)
### 1. 👤 3D Avatar & Customization Engine
* **Canonical Rig**: 24-bone skeleton supporting attachment sockets for Head, Hair, Face, Chest, Pants, Left/Right Hand, Left/Right Foot, and Accessories.
* **Customization Studio** (`scenes/avatar_customization.tscn`): Full 3D turntable preview, orbit camera controls, slot-based equipment, RGB tint pickers, and backend persistence (`/api/avatar/loadout`).
* **Multiplayer Replication**: Remote players receive complete avatar loadout dictionaries via RPC upon joining, dynamically assembling meshes and materials in real time.
* **Solid Double-Sided Rendering**: Fixed polygon winding order and material setups to ensure characters render fully solid from all camera angles.
### 2. 🌍 Multiplayer World & Chunk Streaming
* **Dedicated Server Architecture**: Server-authoritative player movement, client prediction, reconciliation, and collision boundaries.
* **Chunk Streaming**: Binary stream encoding/decoding (`chunk_codec.py` / `chunk_data_decoder.gd`) enabling real-time streaming of world geometry, collision boxes, and dynamic props as players explore.
* **Stable Spawn System**: Server-authoritative solid ground anchoring ensuring players never fall into the void upon connection.
### 3. 🛠️ In-Game World Editor & Sandboxed Scripting
* **Runtime World Editor** (`scripts/editor/`): In-game tool registry, block/prop palette, selection manager, raycast placement/deletion, and undo/redo action stacks.
* **Chunk Persistence** (`chunk_save_service.gd`): Saves player modifications and created structures back to the world backend.
* **Rule-Based Scripting** (`scripts/scripting/`): Sandboxed behavior interpreter (`script_interpreter.gd`, `script_rule.gd`) allowing props to trigger gameplay actions, inventory changes, and spawn events.
* **In-Game HUD Bar**: Hotkey-driven overlay bar giving players instant access to build tools and menus while inside a live world.
### 4. 👥 Social, Chat & Clans
* **Friends & Presence** (`scenes/friends_presence_ui.tscn`): Friend requests, accept/decline flows, and live online/in-world presence heartbeats via Redis.
* **Multi-Channel Chat** (`scenes/chat_ui.tscn`): Direct messaging (DM), group chat, and spatial in-world chat with automated text filtering (`text_filter.py`).
* **Groups & Clans** (`scenes/groups_ui.tscn`): Group creation, member roles, descriptions, and membership rosters.
### 5. 💰 Virtual Economy & Creator Payouts
* **In-Game Currency**: **Buffalo Chips** displayed across all menus, wallets, listings, and payout screens.
* **Wallet UI** (`scenes/wallet_ui.tscn`): Live balance display, quick-purchase packs (+100, +500, +1,000, +5,000 Buffalo Chips), and transaction history ledger.
* **Marketplace UI** (`scenes/marketplace_ui.tscn`): Browse items for sale, filter by price in Buffalo Chips, search catalog, and instant purchase with inventory delivery.
* **Creator Selling & Payouts** (`scenes/selling_payout_ui.tscn`): Catalog item listing, price configuration, escrow tracking (Available, In Escrow, Lifetime Earned), and cash payout requests (minimum 100 Buffalo Chips).
### 6. 🌐 Discovery & Catalog
* **World Discovery UI** (`scenes/discovery_ui.tscn`): Public world browser with search by title, tags, or category.
* **Trending Algorithm**: Decay-weighted play counts ranking popular worlds on the discovery front page.
---
## 🎮 In-Game Controls & Hotkeys
| Key | Action |
| :--- | :--- |
| **`W` `A` `S` `D`** | Move character |
| **`Space`** | Jump |
| **`Mouse`** | Look / Camera rotation |
| **`T`** or **`Enter`** | Open Chat |
| **`Tab`** or **`F1`** | Open Friends & Social Panel |
| **`F2`** | Toggle In-Game World Editor |
| **`ESC`** | Close current modal / Toggle In-Game HUD Menu |
---
## 🚀 Development, Testing & Builds
### Automated Smoke Tests (Headless Godot)
All UI panels and client subsystems have headless GDScript smoke tests:
```bash
# Set target backend (e.g. PC4 playtest instance)
export AGP_API_BASE_URL=http://127.0.0.1:28004
# Run individual UI & subsystem smoke tests
~/godot/godot --headless --path . --script res://scripts/smoke_test_main_menu_full_flow.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_marketplace_ui.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_wallet_ui.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_selling_payout_ui.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_avatar_in_world.gd
```
### Backend Test Suite
```bash
# Run 335+ FastAPI & SQLAlchemy test suite
PYTHONPATH=. /home/daddy/agp/venv/bin/pytest backend/tests
```
### Building Release Binaries
```bash
# Export Linux and Windows binaries
scripts/build.sh
# Or build pre-configured Windows package for current checkpoint (PC4):
PYTHONPATH=. /home/daddy/agp/venv/bin/python3 -c "import pm.checkpoint_ops as ops; ops.build_exe('PC4')"
```
Output artifacts:
- Windows: `export/windows/modbison.exe` (or `agp/playtest/PC4/build/windows/modbison.exe`)
- Linux: `export/linux/modbison.x86_64`
---
## 📦 Project Links & Configuration
* **Gitea Repository**: `http://localhost:3000/daddy/modbison.git`
* **Web Interface**: [https://git.cloudbison.duckdns.org/daddy/modbison](https://git.cloudbison.duckdns.org/daddy/modbison)
* **Active Playtest Instance**: **PC4**
- Backend URL: `http://192.168.58.138:28004`
- Database: PostgreSQL on port `25544`
* **PM Dashboard**: `http://localhost:7070`
* **Working Directory**: `/home/daddy/modbison` (symlinked as `/home/daddy/agp-game-dev`)
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dvln67rbfmcnt"
path="res://.godot/imported/bell.wav-c9e8b15c72938ff0cc4247d900f73a2f.sample"
[deps]
source_file="res://assets/audio/bell.wav"
dest_files=["res://.godot/imported/bell.wav-c9e8b15c72938ff0cc4247d900f73a2f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://uvtdcjsrs4oc"
path="res://.godot/imported/chime.wav-cbf50c3ed007f7bda834f64e66ec1ffa.sample"
[deps]
source_file="res://assets/audio/chime.wav"
dest_files=["res://.godot/imported/chime.wav-cbf50c3ed007f7bda834f64e66ec1ffa.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cxiiaesukt1ci"
path="res://.godot/imported/click.wav-726094e36c7052fadb6392aaf7409595.sample"
[deps]
source_file="res://assets/audio/click.wav"
dest_files=["res://.godot/imported/click.wav-726094e36c7052fadb6392aaf7409595.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cssl2kgkc53is"
path="res://.godot/imported/coin.wav-e1bec9e9502a5df388e6fd33d4bb5689.sample"
[deps]
source_file="res://assets/audio/coin.wav"
dest_files=["res://.godot/imported/coin.wav-e1bec9e9502a5df388e6fd33d4bb5689.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c10qifwxnhin6"
path="res://.godot/imported/door_open.wav-d530c3547ac0143568ab23d68285c71c.sample"
[deps]
source_file="res://assets/audio/door_open.wav"
dest_files=["res://.godot/imported/door_open.wav-d530c3547ac0143568ab23d68285c71c.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://bl6jimdmb1d3u"
path="res://.godot/imported/fanfare.wav-e0524ad6f859b26eee9ccfabfec4f302.sample"
[deps]
source_file="res://assets/audio/fanfare.wav"
dest_files=["res://.godot/imported/fanfare.wav-e0524ad6f859b26eee9ccfabfec4f302.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
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
+155
View File
@@ -0,0 +1,155 @@
{
"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"
},
"hair_waterbuffalo_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_waterbuffalo_01.glb",
"version": "1.0.0",
"display_name": "Water Buffalo Lodge Hat",
"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"
},
"left_hand_wristband_01": {
"type": "attachment",
"slot": "left_hand",
"path": "res://assets/avatars/left_hand_wristband_01.glb",
"version": "1.0.0",
"display_name": "Left Gold Band",
"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"
},
"right_hand_wristband_01": {
"type": "attachment",
"slot": "right_hand",
"path": "res://assets/avatars/right_hand_wristband_01.glb",
"version": "1.0.0",
"display_name": "Right Gold Band",
"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"
},
"left_foot_sneaker_01": {
"type": "attachment",
"slot": "left_foot",
"path": "res://assets/avatars/left_foot_sneaker_01.glb",
"version": "1.0.0",
"display_name": "Left Sneaker",
"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"
},
"right_foot_sneaker_01": {
"type": "attachment",
"slot": "right_foot",
"path": "res://assets/avatars/right_foot_sneaker_01.glb",
"version": "1.0.0",
"display_name": "Right Sneaker",
"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.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dbpg1j858253k"
path="res://.godot/imported/hair_waterbuffalo_01.glb-c029a4cc5c7f4da664b2a6ff40a4d5a5.scn"
[deps]
source_file="res://assets/avatars/hair_waterbuffalo_01.glb"
dest_files=["res://.godot/imported/hair_waterbuffalo_01.glb-c029a4cc5c7f4da664b2a6ff40a4d5a5.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://cdu5ncc6txi2a"
path="res://.godot/imported/left_foot_sneaker_01.glb-58178eceb7610b00fcdd19ec562d32ee.scn"
[deps]
source_file="res://assets/avatars/left_foot_sneaker_01.glb"
dest_files=["res://.godot/imported/left_foot_sneaker_01.glb-58178eceb7610b00fcdd19ec562d32ee.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.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bd2pj86s3jgw6"
path="res://.godot/imported/left_hand_wristband_01.glb-bbb8beaefb98cfa41b275a9c621f1f25.scn"
[deps]
source_file="res://assets/avatars/left_hand_wristband_01.glb"
dest_files=["res://.godot/imported/left_hand_wristband_01.glb-bbb8beaefb98cfa41b275a9c621f1f25.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://da2jib466th8w"
path="res://.godot/imported/right_foot_sneaker_01.glb-54204fa9ac3cba3033c3591ef48c54c7.scn"
[deps]
source_file="res://assets/avatars/right_foot_sneaker_01.glb"
dest_files=["res://.godot/imported/right_foot_sneaker_01.glb-54204fa9ac3cba3033c3591ef48c54c7.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
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b64ejiiluvql4"
path="res://.godot/imported/right_hand_wristband_01.glb-65f50dbbe9aaf5f2de1555fec5ca3385.scn"
[deps]
source_file="res://assets/avatars/right_hand_wristband_01.glb"
dest_files=["res://.godot/imported/right_hand_wristband_01.glb-65f50dbbe9aaf5f2de1555fec5ca3385.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
+52
View File
@@ -0,0 +1,52 @@
{
"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"
},
"buffalo_chip_coin": {
"asset_id": "props/buffalo_chip_coin.glb",
"type": "prop",
"display_name": "Buffalo Chip Coin",
"category": "props"
},
"bison": {
"asset_id": "props/bison.glb",
"type": "prop",
"display_name": "Bison",
"category": "nature"
},
"spawn_point": {
"asset_id": "",
"type": "spawn_point",
"display_name": "Spawn Point",
"category": "gameplay"
}
}
}
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://wa7kf5fvrdh1"
path="res://.godot/imported/bison.glb-caef5252d8e08046747c7628b6609f07.scn"
[deps]
source_file="res://assets/props/bison.glb"
dest_files=["res://.godot/imported/bison.glb-caef5252d8e08046747c7628b6609f07.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.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://oemjaiyc1jor"
path="res://.godot/imported/bison_04bizontexture.jpg-ee62bcbf71190dd9c3260c488d0f5226.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "62ac14285e2f49777af903582f16d021"
}
[deps]
source_file="res://assets/props/bison_04bizontexture.jpg"
dest_files=["res://.godot/imported/bison_04bizontexture.jpg-ee62bcbf71190dd9c3260c488d0f5226.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dxjew7eex2jh4"
path="res://.godot/imported/buffalo_chip_coin.glb-ccee212ee6fb7ddcc2555bc23ea60f4b.scn"
[deps]
source_file="res://assets/props/buffalo_chip_coin.glb"
dest_files=["res://.godot/imported/buffalo_chip_coin.glb-ccee212ee6fb7ddcc2555bc23ea60f4b.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://kik8lcd3jp7x"
path="res://.godot/imported/chest.glb-22b4964b33afcd001921fbaa02f82b8a.scn"
[deps]
source_file="res://assets/props/chest.glb"
dest_files=["res://.godot/imported/chest.glb-22b4964b33afcd001921fbaa02f82b8a.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://n18xbm0q3jai"
path="res://.godot/imported/crate_small.glb-e6fdaf315193cd20d4514609adaa68f2.scn"
[deps]
source_file="res://assets/props/crate_small.glb"
dest_files=["res://.godot/imported/crate_small.glb-e6fdaf315193cd20d4514609adaa68f2.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://b3nfmxbm87pl3"
path="res://.godot/imported/lamp_post_01.glb-eea7873c5c7bafcb4d19a09b9d97e53c.scn"
[deps]
source_file="res://assets/props/lamp_post_01.glb"
dest_files=["res://.godot/imported/lamp_post_01.glb-eea7873c5c7bafcb4d19a09b9d97e53c.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://boi3fsj4ehqvb"
path="res://.godot/imported/rock_01.glb-b0b73bccccfa933f3863bdc2afcb3246.scn"
[deps]
source_file="res://assets/props/rock_01.glb"
dest_files=["res://.godot/imported/rock_01.glb-b0b73bccccfa933f3863bdc2afcb3246.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://b84vjc1c3d650"
path="res://.godot/imported/tree_pine_01.glb-519f726dc86fdb9eefd0928153fa0181.scn"
[deps]
source_file="res://assets/props/tree_pine_01.glb"
dest_files=["res://.godot/imported/tree_pine_01.glb-519f726dc86fdb9eefd0928153fa0181.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
@@ -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"
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
from app.models import user, session, world, asset # noqa: F401 from app.models import user, session, world, asset, avatar_loadout, world_content # noqa: F401
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
@@ -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")
@@ -0,0 +1,33 @@
"""add presence columns to users
Revision ID: 0006_add_presence_columns
Revises: 0005_add_world_content_tables
Create Date: 2026-09-14 00:00:00.000000
Ported forward from the orphaned agent/dd2a126b57a1 branch (originally
2a9b9873244a_add_presence_columns.py, down_revision 0001_initial) and
rebased onto the current migration head. See app/services/presence.py /
app/routers/presence.py.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0006_add_presence_columns"
down_revision: Union[str, None] = "0005_add_world_content_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add last_seen_at and status_message columns to users table."""
op.add_column("users", sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("status_message", sa.Text(), nullable=True))
def downgrade() -> None:
"""Remove presence columns from users table."""
op.drop_column("users", "status_message")
op.drop_column("users", "last_seen_at")
@@ -0,0 +1,118 @@
"""add social graph tables (friendships, follows, blocks)
Revision ID: 0007_add_social_graph_tables
Revises: 0006_add_presence_columns
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-1 / app/models/social.py. Models + migration
only -- the request/accept/block business logic lives in the sibling
"Friends & follow API router" task.
"""
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 = "0007_add_social_graph_tables"
down_revision: Union[str, None] = "0006_add_presence_columns"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
friendship_status = postgresql.ENUM(
"pending", "accepted", name="friendship_status"
)
friendship_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"friendships",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"requester_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"addressee_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
postgresql.ENUM(
"pending", "accepted", name="friendship_status", create_type=False
),
nullable=False,
server_default="pending",
),
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("requester_id", "addressee_id", name="uq_friendships_pair"),
)
op.create_index("ix_friendships_requester_id", "friendships", ["requester_id"])
op.create_index("ix_friendships_addressee_id", "friendships", ["addressee_id"])
op.create_table(
"follows",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"follower_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"followed_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
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),
sa.UniqueConstraint("follower_id", "followed_id", name="uq_follows_pair"),
)
op.create_index("ix_follows_follower_id", "follows", ["follower_id"])
op.create_index("ix_follows_followed_id", "follows", ["followed_id"])
op.create_table(
"blocks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"blocker_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"blocked_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
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),
sa.UniqueConstraint("blocker_id", "blocked_id", name="uq_blocks_pair"),
)
op.create_index("ix_blocks_blocker_id", "blocks", ["blocker_id"])
op.create_index("ix_blocks_blocked_id", "blocks", ["blocked_id"])
def downgrade() -> None:
op.drop_index("ix_blocks_blocked_id", table_name="blocks")
op.drop_index("ix_blocks_blocker_id", table_name="blocks")
op.drop_table("blocks")
op.drop_index("ix_follows_followed_id", table_name="follows")
op.drop_index("ix_follows_follower_id", table_name="follows")
op.drop_table("follows")
op.drop_index("ix_friendships_addressee_id", table_name="friendships")
op.drop_index("ix_friendships_requester_id", table_name="friendships")
op.drop_table("friendships")
postgresql.ENUM(name="friendship_status").drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,178 @@
"""add chat tables (conversations, messages, message_reports)
Revision ID: 0008_add_chat_tables
Revises: 0007_add_social_graph_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-4 / app/models/chat.py. Models + migration only
-- text filtering, rate limiting, and send/report/moderation endpoints live
in sibling tasks that depend on this one.
"""
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 = "0008_add_chat_tables"
down_revision: Union[str, None] = "0007_add_social_graph_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
message_report_status = postgresql.ENUM(
"open", "resolved", "dismissed", name="message_report_status"
)
message_report_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"conversations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_a_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_b_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
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),
sa.UniqueConstraint("user_a_id", "user_b_id", name="uq_conversations_pair"),
sa.CheckConstraint("user_a_id < user_b_id", name="ck_conversations_ordered_pair"),
)
op.create_index("ix_conversations_user_a_id", "conversations", ["user_a_id"])
op.create_index("ix_conversations_user_b_id", "conversations", ["user_b_id"])
op.create_table(
"conversation_participants",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"conversation_id",
sa.Integer(),
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint(
"conversation_id", "user_id", name="uq_conversation_participants_pair"
),
)
op.create_index(
"ix_conversation_participants_conversation_id",
"conversation_participants",
["conversation_id"],
)
op.create_index(
"ix_conversation_participants_user_id", "conversation_participants", ["user_id"]
)
op.create_table(
"messages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"sender_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"conversation_id",
sa.Integer(),
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"world_id",
sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"room_id",
sa.Integer(),
sa.ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("filtered", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_messages_sender_id", "messages", ["sender_id"])
op.create_index(
"ix_messages_conversation_created", "messages", ["conversation_id", "created_at"]
)
op.create_index("ix_messages_world_created", "messages", ["world_id", "created_at"])
op.create_table(
"message_reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"message_id",
sa.Integer(),
sa.ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"reporter_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
postgresql.ENUM(
"open", "resolved", "dismissed", name="message_report_status", create_type=False
),
nullable=False,
server_default="open",
),
sa.Column(
"moderator_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_message_reports_message_id", "message_reports", ["message_id"])
op.create_index("ix_message_reports_reporter_id", "message_reports", ["reporter_id"])
op.create_index("ix_message_reports_moderator_id", "message_reports", ["moderator_id"])
def downgrade() -> None:
op.drop_index("ix_message_reports_moderator_id", table_name="message_reports")
op.drop_index("ix_message_reports_reporter_id", table_name="message_reports")
op.drop_index("ix_message_reports_message_id", table_name="message_reports")
op.drop_table("message_reports")
op.drop_index("ix_messages_world_created", table_name="messages")
op.drop_index("ix_messages_conversation_created", table_name="messages")
op.drop_index("ix_messages_sender_id", table_name="messages")
op.drop_table("messages")
op.drop_index("ix_conversation_participants_user_id", table_name="conversation_participants")
op.drop_index(
"ix_conversation_participants_conversation_id", table_name="conversation_participants"
)
op.drop_table("conversation_participants")
op.drop_index("ix_conversations_user_b_id", table_name="conversations")
op.drop_index("ix_conversations_user_a_id", table_name="conversations")
op.drop_table("conversations")
postgresql.ENUM(name="message_report_status").drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,80 @@
"""add group tables (groups, group_members)
Revision ID: 0009_add_group_tables
Revises: 0008_add_chat_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-5 / app/models/groups.py. Models + migration only
-- create/join/leave, role authorization, and the group page endpoint live
in a sibling task ("Groups API") that depends on this one.
"""
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 = "0009_add_group_tables"
down_revision: Union[str, None] = "0008_add_chat_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
group_role = postgresql.ENUM("owner", "admin", "member", name="group_role")
group_role.create(op.get_bind(), checkfirst=True)
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"owner_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),
)
op.create_index("ix_groups_owner_id", "groups", ["owner_id"])
op.create_table(
"group_members",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False),
nullable=False,
server_default="member",
),
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
op.create_index("ix_group_members_user_id", "group_members", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_group_members_user_id", table_name="group_members")
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_owner_id", table_name="groups")
op.drop_table("groups")
postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,65 @@
"""add profile tables (profiles, profile_favorites)
Revision ID: 0010_add_profile_tables
Revises: 0009_add_group_tables
Create Date: 2026-09-15 00:00:00.000000
app/models/profile.py. Models + migration + owner-only /api/profile/me
endpoints only -- the public profile view endpoint (GET /api/profile/{user_id})
belongs to a sibling task that depends on this one.
"""
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 = "0010_add_profile_tables"
down_revision: Union[str, None] = "0009_add_group_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"profiles",
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column(
"avatar_asset_id",
sa.Integer(),
sa.ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("bio", sa.Text(), nullable=True),
sa.Column("privacy_json", postgresql.JSONB(), nullable=False, server_default="{}"),
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),
)
op.create_index("ix_profiles_avatar_asset_id", "profiles", ["avatar_asset_id"])
op.create_table(
"profile_favorites",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"world_id",
sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_profile_favorites_world_id", "profile_favorites", ["world_id"])
def downgrade() -> None:
op.drop_index("ix_profile_favorites_world_id", table_name="profile_favorites")
op.drop_table("profile_favorites")
op.drop_index("ix_profiles_avatar_asset_id", table_name="profiles")
op.drop_table("profiles")
@@ -0,0 +1,36 @@
"""add last_read_at to conversation_participants (DM unread tracking)
Revision ID: 0011_add_dm_last_read
Revises: 0010_add_profile_tables
Create Date: 2026-09-15 00:00:00.000000
app/routers/chat_dm.py (DM send/list/conversations endpoints). Unread-count
tracking is implemented as a last-read timestamp per participant: whenever a
user fetches a conversation's message history, their
``conversation_participants`` row's ``last_read_at`` is bumped to "now", and
the conversation-list endpoint counts messages with
``created_at > last_read_at`` (or all messages if never read) as unread for
that participant. Nullable/no default so existing rows (none yet in
practice, but consistent with "never read") read as "everything unread".
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0011_add_dm_last_read"
down_revision: Union[str, None] = "0010_add_profile_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"conversation_participants",
sa.Column("last_read_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("conversation_participants", "last_read_at")
@@ -0,0 +1,90 @@
"""add wallets and transactions ledger tables
Revision ID: 0012_add_wallet_and_transactions
Revises: 0011_add_dm_last_read
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-1 (task 8343bc0a730f).
"""
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 = "0012_add_wallet_and_transactions"
down_revision: Union[str, None] = "0011_add_dm_last_read"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TRANSACTION_TYPES = (
"credit_purchase",
"marketplace_purchase",
"marketplace_sale",
"escrow_release",
"payout",
"refund",
"admin_adjustment",
)
def upgrade() -> None:
# 1. Create transaction_type enum for Postgres
bind = op.get_bind()
if bind.dialect.name == "postgresql":
tx_type_enum = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
tx_type_enum.create(bind, checkfirst=True)
tx_type_col = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
else:
tx_type_col = sa.Enum(*TRANSACTION_TYPES, name="transaction_type")
# 2. Create wallets table
op.create_table(
"wallets",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("balance", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("balance >= 0", name="check_wallet_balance_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_wallets_user_id"),
)
op.create_index(op.f("ix_wallets_user_id"), "wallets", ["user_id"], unique=True)
# 3. Create transactions table
op.create_table(
"transactions",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("wallet_id", sa.BigInteger(), nullable=False),
sa.Column("type", tx_type_col, nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("balance_after", sa.BigInteger(), nullable=False),
sa.Column("reference_type", sa.String(), nullable=True),
sa.Column("reference_id", sa.String(), nullable=True),
sa.Column("idempotency_key", sa.String(), nullable=True),
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if bind.dialect.name == "postgresql" else sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("idempotency_key", name="uq_transactions_idempotency_key"),
)
op.create_index(op.f("ix_transactions_wallet_id"), "transactions", ["wallet_id"], unique=False)
op.create_index(op.f("ix_transactions_idempotency_key"), "transactions", ["idempotency_key"], unique=True)
op.create_index(op.f("ix_transactions_created_at"), "transactions", ["created_at"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_transactions_created_at"), table_name="transactions")
op.drop_index(op.f("ix_transactions_idempotency_key"), table_name="transactions")
op.drop_index(op.f("ix_transactions_wallet_id"), table_name="transactions")
op.drop_table("transactions")
op.drop_index(op.f("ix_wallets_user_id"), table_name="wallets")
op.drop_table("wallets")
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(*TRANSACTION_TYPES, name="transaction_type").drop(bind, checkfirst=True)
@@ -0,0 +1,146 @@
"""add marketplace tables: catalog_items, marketplace_listings, purchases, creator_balances
Revision ID: 0013_add_marketplace_tables
Revises: 0012_add_wallet_and_transactions
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
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 = "0013_add_marketplace_tables"
down_revision: Union[str, None] = "0012_add_wallet_and_transactions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ITEM_TYPES = ("asset", "avatar_item", "experience_pass")
LISTING_STATUSES = ("active", "sold", "delisted", "removed_by_moderation")
PURCHASE_STATUSES = ("pending_escrow", "completed", "refunded", "reversed")
def upgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
# Enums
if is_pg:
item_type_enum = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
item_type_enum.create(bind, checkfirst=True)
item_type_col = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
listing_status_enum = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
listing_status_enum.create(bind, checkfirst=True)
listing_status_col = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
purchase_status_enum = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
purchase_status_enum.create(bind, checkfirst=True)
purchase_status_col = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
else:
item_type_col = sa.Enum(*ITEM_TYPES, name="catalog_item_type")
listing_status_col = sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status")
purchase_status_col = sa.Enum(*PURCHASE_STATUSES, name="purchase_status")
# 1. catalog_items
op.create_table(
"catalog_items",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("asset_id", sa.Integer(), nullable=True),
sa.Column("item_type", item_type_col, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=2000), nullable=True),
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if is_pg else sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_catalog_items_owner_id"), "catalog_items", ["owner_id"], unique=False)
op.create_index(op.f("ix_catalog_items_asset_id"), "catalog_items", ["asset_id"], unique=False)
# 2. marketplace_listings
op.create_table(
"marketplace_listings",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("catalog_item_id", sa.BigInteger(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("status", listing_status_col, server_default="active", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("price > 0", name="check_listing_price_positive"),
sa.ForeignKeyConstraint(["catalog_item_id"], ["catalog_items.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_marketplace_listings_catalog_item_id"), "marketplace_listings", ["catalog_item_id"], unique=False)
op.create_index(op.f("ix_marketplace_listings_seller_id"), "marketplace_listings", ["seller_id"], unique=False)
# 3. purchases
op.create_table(
"purchases",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("listing_id", sa.BigInteger(), nullable=True),
sa.Column("buyer_id", sa.Integer(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("platform_fee", sa.BigInteger(), nullable=False),
sa.Column("seller_net", sa.BigInteger(), nullable=False),
sa.Column("status", purchase_status_col, server_default="pending_escrow", nullable=False),
sa.Column("escrow_hold_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["listing_id"], ["marketplace_listings.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["buyer_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_purchases_listing_id"), "purchases", ["listing_id"], unique=False)
op.create_index(op.f("ix_purchases_buyer_id"), "purchases", ["buyer_id"], unique=False)
op.create_index(op.f("ix_purchases_seller_id"), "purchases", ["seller_id"], unique=False)
# 4. creator_balances
op.create_table(
"creator_balances",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("available", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("pending", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("lifetime_earned", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
sa.CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
sa.CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_creator_balances_user_id"),
)
op.create_index(op.f("ix_creator_balances_user_id"), "creator_balances", ["user_id"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_creator_balances_user_id"), table_name="creator_balances")
op.drop_table("creator_balances")
op.drop_index(op.f("ix_purchases_seller_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_buyer_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_listing_id"), table_name="purchases")
op.drop_table("purchases")
op.drop_index(op.f("ix_marketplace_listings_seller_id"), table_name="marketplace_listings")
op.drop_index(op.f("ix_marketplace_listings_catalog_item_id"), table_name="marketplace_listings")
op.drop_table("marketplace_listings")
op.drop_index(op.f("ix_catalog_items_asset_id"), table_name="catalog_items")
op.drop_index(op.f("ix_catalog_items_owner_id"), table_name="catalog_items")
op.drop_table("catalog_items")
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(*PURCHASE_STATUSES, name="purchase_status").drop(bind, checkfirst=True)
sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status").drop(bind, checkfirst=True)
sa.Enum(*ITEM_TYPES, name="catalog_item_type").drop(bind, checkfirst=True)
@@ -0,0 +1,68 @@
"""add payout_requests table and payout_status enum
Revision ID: 0014_add_payout_requests
Revises: 0013_add_marketplace_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-5 (task f5b4684f2e65).
"""
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 = "0014_add_payout_requests"
down_revision: Union[str, None] = "0013_add_marketplace_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PAYOUT_STATUSES = ("pending", "approved", "paid", "rejected")
def upgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
if is_pg:
payout_status_enum = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
payout_status_enum.create(bind, checkfirst=True)
payout_status_col = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
else:
payout_status_col = sa.Enum(*PAYOUT_STATUSES, name="payout_status")
op.create_table(
"payout_requests",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("creator_id", sa.Integer(), nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("status", payout_status_col, server_default="pending", nullable=False),
sa.Column("external_ref", sa.String(length=255), nullable=True),
sa.Column("provider_ref", sa.String(length=255), nullable=True),
sa.Column("requested_by", sa.Integer(), nullable=False),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requested_by"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
sa.CheckConstraint("amount > 0", name="check_payout_amount_positive"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_payout_requests_creator_id", "payout_requests", ["creator_id"], unique=False)
op.create_index("ix_payout_requests_requested_by", "payout_requests", ["requested_by"], unique=False)
op.create_index("ix_payout_requests_reviewed_by", "payout_requests", ["reviewed_by"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
op.drop_index("ix_payout_requests_reviewed_by", table_name="payout_requests")
op.drop_index("ix_payout_requests_requested_by", table_name="payout_requests")
op.drop_index("ix_payout_requests_creator_id", table_name="payout_requests")
op.drop_table("payout_requests")
if is_pg:
op.execute("DROP TYPE IF EXISTS payout_status")
@@ -0,0 +1,62 @@
"""add discovery metadata to worlds and create world_visits table
Revision ID: 0015_add_discovery_metadata
Revises: 0014_add_payout_requests
Create Date: 2026-09-15 00:00:00.000000
docs/planning/discovery.md (task b377353b8167).
"""
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 = "0015_add_discovery_metadata"
down_revision: Union[str, None] = "0014_add_payout_requests"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add discovery metadata columns to worlds table
op.add_column("worlds", sa.Column("category", sa.String(length=100), nullable=True))
op.add_column("worlds", sa.Column("tags", postgresql.ARRAY(sa.String()), server_default="{}", nullable=True))
op.add_column("worlds", sa.Column("play_count", sa.Integer(), server_default="0", nullable=False))
op.add_column("worlds", sa.Column("last_played_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_worlds_category", "worlds", ["category"], unique=False)
op.create_index("ix_worlds_tags", "worlds", ["tags"], unique=False, postgresql_using="gin")
# 2. Create world_visits table
op.create_table(
"world_visits",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("world_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("anonymous_token", sa.String(length=255), nullable=True),
sa.Column("visited_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_world_visits_world_id", "world_visits", ["world_id"], unique=False)
op.create_index("ix_world_visits_user_id", "world_visits", ["user_id"], unique=False)
op.create_index("ix_world_visits_visited_at", "world_visits", ["visited_at"], unique=False)
op.create_index("ix_world_visits_world_visited_at", "world_visits", ["world_id", "visited_at"], unique=False)
def downgrade() -> None:
op.drop_index("ix_world_visits_world_visited_at", table_name="world_visits")
op.drop_index("ix_world_visits_visited_at", table_name="world_visits")
op.drop_index("ix_world_visits_user_id", table_name="world_visits")
op.drop_index("ix_world_visits_world_id", table_name="world_visits")
op.drop_table("world_visits")
op.drop_index("ix_worlds_tags", table_name="worlds", postgresql_using="gin")
op.drop_index("ix_worlds_category", table_name="worlds")
op.drop_column("worlds", "last_played_at")
op.drop_column("worlds", "play_count")
op.drop_column("worlds", "tags")
op.drop_column("worlds", "category")
@@ -0,0 +1,30 @@
"""add content_guidelines column to worlds table
Revision ID: 0016_content_guidelines
Revises: 0015_add_discovery_metadata
Create Date: 2026-09-17 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0016_content_guidelines"
down_revision: Union[str, None] = "0015_add_discovery_metadata"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Check if column already exists (in case partial run added column before version update failed)
bind = op.get_bind()
insp = sa.inspect(bind)
cols = [c["name"] for c in insp.get_columns("worlds")]
if "content_guidelines" not in cols:
op.add_column("worlds", sa.Column("content_guidelines", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("worlds", "content_guidelines")
@@ -0,0 +1,53 @@
"""add asset_collections and collection_assets tables
Revision ID: 0017_add_asset_collections
Revises: 0016_content_guidelines
Create Date: 2026-09-22 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0017_add_asset_collections"
down_revision: Union[str, None] = "0016_content_guidelines"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
tables = insp.get_table_names()
if "asset_collections" not in tables:
op.create_table(
"asset_collections",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("title", sa.String(length=255), nullable=False, index=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("theme", sa.String(length=64), nullable=False, server_default="general", index=True),
sa.Column("thumbnail_url", sa.String(length=512), nullable=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
sa.Column("tags", sa.String(length=512), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
if "collection_assets" not in tables:
op.create_table(
"collection_assets",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("collection_id", sa.Integer(), sa.ForeignKey("asset_collections.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("collection_assets")
op.drop_table("asset_collections")
+41
View File
@@ -75,11 +75,52 @@ class Settings(BaseSettings):
# CORS # CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000" 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 # File uploads
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100 MAX_UPLOAD_SIZE_MB: int = 100
GLTF_MAX_FILE_SIZE_MB: int = 200 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
+30
View File
@@ -53,3 +53,33 @@ async def get_current_user(
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_optional_user(
token: Annotated[str | None, Depends(oauth2_scheme_optional)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User | None:
"""Resolve the JWT bearer token to a User if present and valid, otherwise None."""
if not token:
return None
try:
payload = jwt.decode(
token, settings.public_pem, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
return None
user_id_int = int(user_id)
except (JWTError, ValueError, TypeError):
return None
result = await db.execute(select(User).where(User.id == user_id_int))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
+1
View File
@@ -0,0 +1 @@
"""WebSocket real-time gateway package."""
+538
View File
@@ -0,0 +1,538 @@
"""Real-time WebSocket gateway.
Multiplexes presence, DM, and world-chat delivery over a single WebSocket
endpoint (docs/planning/social.md US-2, US-4).
Key features:
- Authentication via JWT token (query param or initial auth frame).
- Scoped subscriptions to DM conversations and world channels.
- Server-derived presence heartbeat (Redis TTL refreshed periodically).
- Broadcast presence changes (online/offline) to mutual friends.
- Fan-out via Redis pub/sub.
- Thread-safe & event-loop-safe dispatch for multi-instance and test environments.
- Graceful reconnect without duplicate delivery or orphaned state.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from jose import JWTError, jwt
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.config import settings
from app.database import async_session_factory, get_db
from app.models.chat import ConversationParticipant
from app.models.social import Friendship, FriendshipStatus
from app.models.user import User
from app.models.world import World, WorldVisibility
from app.services.presence import presence_store
logger = logging.getLogger(__name__)
router = APIRouter()
@asynccontextmanager
async def get_db_session():
"""Helper to acquire an async DB session respecting test overrides if present."""
from app.main import app
override = app.dependency_overrides.get(get_db)
if override:
res = override()
if hasattr(res, "__anext__"):
session = await res.__anext__()
try:
yield session
finally:
try:
await res.__anext__()
except (StopAsyncIteration, Exception):
pass
return
elif hasattr(res, "__next__"):
session = next(res)
try:
yield session
finally:
try:
next(res)
except (StopIteration, Exception):
pass
return
async with async_session_factory() as session:
yield session
async def authenticate_token(token: str) -> User | None:
"""Validate JWT token and return authenticated User, or None."""
try:
payload = jwt.decode(token, settings.public_pem, algorithms=[settings.ALGORITHM])
user_id_raw = payload.get("sub")
if user_id_raw is None:
return None
user_id = int(user_id_raw)
except (JWTError, ValueError, TypeError):
return None
async with get_db_session() as db:
res = await db.execute(select(User).where(User.id == user_id))
user = res.scalar_one_or_none()
if user and user.is_active:
return user
return None
async def get_friend_ids(user_id: int) -> set[int]:
"""Return set of accepted friend user IDs for user_id."""
try:
async with get_db_session() as db:
res = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = res.scalars().all()
return {
f.addressee_id if f.requester_id == user_id else f.requester_id
for f in friendships
}
except Exception as exc:
logger.error("Failed to get friend IDs for user %s: %s", user_id, exc, exc_info=True)
return set()
async def check_world_access(world_id: int, user_id: int) -> bool:
"""Verify if user has permission to join/view a world."""
async with get_db_session() as db:
res = await db.execute(select(World).where(World.id == world_id))
world = res.scalar_one_or_none()
if not world:
return False
if world.visibility == WorldVisibility.PUBLIC:
return True
if world.owner_id == user_id:
return True
if world.visibility == WorldVisibility.FRIENDS_ONLY:
res_fr = await db.execute(
select(Friendship.id).where(
or_(
and_(Friendship.requester_id == world.owner_id, Friendship.addressee_id == user_id),
and_(Friendship.requester_id == user_id, Friendship.addressee_id == world.owner_id),
),
Friendship.status == FriendshipStatus.ACCEPTED,
).limit(1)
)
return res_fr.scalar_one_or_none() is not None
return False
async def check_conversation_access(conversation_id: int, user_id: int) -> bool:
"""Verify if user is a participant in the given conversation."""
async with get_db_session() as db:
res = await db.execute(
select(ConversationParticipant.id).where(
ConversationParticipant.conversation_id == conversation_id,
ConversationParticipant.user_id == user_id,
).limit(1)
)
return res.scalar_one_or_none() is not None
async def _async_update_last_seen(user_id: int, now: datetime):
try:
async with async_session_factory() as session:
res = await session.execute(select(User).where(User.id == user_id))
db_user = res.scalar_one_or_none()
if db_user:
db_user.last_seen_at = now
await session.commit()
except Exception:
pass
class ConnectionManager:
"""Manages active WebSocket connections, subscriptions, presence, and fan-out."""
def __init__(self):
# ws -> User
self._connections: dict[WebSocket, User] = {}
# ws -> event loop id
self._ws_loops: dict[WebSocket, int] = {}
# user_id -> set of active WebSockets
self._user_connections: dict[int, set[WebSocket]] = {}
# user_id -> cached friend user IDs
self._user_friend_ids: dict[int, set[int]] = {}
# world_id -> set of WebSockets
self._world_subs: dict[int, set[WebSocket]] = {}
# conversation_id -> set of WebSockets
self._dm_subs: dict[int, set[WebSocket]] = {}
# ws -> asyncio heartbeat task
self._heartbeat_tasks: dict[WebSocket, asyncio.Task] = {}
# Per-event-loop Redis pub/sub listener tasks and events
self._redis_tasks: dict[int, asyncio.Task] = {}
self._subscribed_events: dict[int, asyncio.Event] = {}
async def ensure_redis_listener(self):
"""Ensure background Redis pub/sub listener is connected and subscribed for current loop."""
loop = asyncio.get_running_loop()
loop_key = id(loop)
task = self._redis_tasks.get(loop_key)
if task is None or task.done():
event = asyncio.Event()
self._subscribed_events[loop_key] = event
task = asyncio.create_task(self._redis_listener(loop_key, event))
self._redis_tasks[loop_key] = task
else:
event = self._subscribed_events.get(loop_key)
if event:
try:
await asyncio.wait_for(event.wait(), timeout=2.0)
except asyncio.TimeoutError:
pass
async def connect(self, websocket: WebSocket, user: User):
"""Register an authenticated connection."""
await self.ensure_redis_listener()
loop_key = id(asyncio.get_running_loop())
self._connections[websocket] = user
self._ws_loops[websocket] = loop_key
self._user_connections.setdefault(user.id, set()).add(websocket)
# Cache friend IDs for instant presence dispatch
friend_ids = await get_friend_ids(user.id)
self._user_friend_ids[user.id] = friend_ids
# Mark user online in Redis and DB
await presence_store.heartbeat(user.id)
now = datetime.now(timezone.utc)
asyncio.create_task(_async_update_last_seen(user.id, now))
# Start periodic heartbeat task (every 20 seconds)
self._heartbeat_tasks[websocket] = asyncio.create_task(
self._run_periodic_heartbeat(user.id)
)
# Broadcast online presence to friends via Redis
await self.broadcast_presence(user.id, user.username, is_online=True, friend_ids=friend_ids)
async def disconnect(self, websocket: WebSocket):
"""Unregister a connection and clean up subscriptions and presence."""
user = self._connections.pop(websocket, None)
self._ws_loops.pop(websocket, None)
task = self._heartbeat_tasks.pop(websocket, None)
if task:
task.cancel()
# Clean subscriptions
for world_id in list(self._world_subs.keys()):
self._world_subs[world_id].discard(websocket)
if not self._world_subs[world_id]:
self._world_subs.pop(world_id, None)
for conv_id in list(self._dm_subs.keys()):
self._dm_subs[conv_id].discard(websocket)
if not self._dm_subs[conv_id]:
self._dm_subs.pop(conv_id, None)
if user is None:
return
user_id = user.id
username = user.username
user_set = self._user_connections.get(user_id)
if user_set:
user_set.discard(websocket)
if not user_set:
self._user_connections.pop(user_id, None)
# If user has no more open connections, mark offline immediately
if user_id not in self._user_connections:
await presence_store.clear_heartbeat(user_id)
now = datetime.now(timezone.utc)
asyncio.create_task(_async_update_last_seen(user_id, now))
friend_ids = self._user_friend_ids.pop(user_id, set())
await self.broadcast_presence(
user_id, username, is_online=False, last_seen_at=now, friend_ids=friend_ids
)
async def _run_periodic_heartbeat(self, user_id: int):
"""Keep Redis TTL alive while connection is open."""
try:
while True:
await asyncio.sleep(20)
await presence_store.heartbeat(user_id)
except asyncio.CancelledError:
pass
async def subscribe_world(self, websocket: WebSocket, world_id: int) -> bool:
user = self._connections.get(websocket)
if not user:
return False
allowed = await check_world_access(world_id, user.id)
if not allowed:
return False
self._world_subs.setdefault(world_id, set()).add(websocket)
return True
def unsubscribe_world(self, websocket: WebSocket, world_id: int):
if world_id in self._world_subs:
self._world_subs[world_id].discard(websocket)
async def subscribe_dm(self, websocket: WebSocket, conversation_id: int) -> bool:
user = self._connections.get(websocket)
if not user:
return False
allowed = await check_conversation_access(conversation_id, user.id)
if not allowed:
return False
self._dm_subs.setdefault(conversation_id, set()).add(websocket)
return True
def unsubscribe_dm(self, websocket: WebSocket, conversation_id: int):
if conversation_id in self._dm_subs:
self._dm_subs[conversation_id].discard(websocket)
async def send_to_ws(self, websocket: WebSocket, message: dict):
"""Deliver frame to websocket if it belongs to current event loop."""
cur_loop = id(asyncio.get_running_loop())
if self._ws_loops.get(websocket) != cur_loop:
return
try:
await websocket.send_text(json.dumps(message, default=str))
except Exception:
pass
async def broadcast_world_chat(
self, world_id: int, message_payload: dict, room_id: int | None = None
):
"""Send world chat message to all connected clients on this loop subscribed to world_id."""
targets = list(self._world_subs.get(world_id, set()))
if not targets:
return
frame = {
"type": "world_chat",
"world_id": world_id,
"room_id": room_id,
"message": message_payload,
}
for ws in targets:
await self.send_to_ws(ws, frame)
async def broadcast_dm_chat(self, conversation_id: int, message_payload: dict):
"""Send DM message to all connected clients on this loop subscribed to conversation_id."""
targets = list(self._dm_subs.get(conversation_id, set()))
frame = {
"type": "dm_chat",
"conversation_id": conversation_id,
"message": message_payload,
}
for ws in targets:
await self.send_to_ws(ws, frame)
async def broadcast_presence(
self,
user_id: int,
username: str,
is_online: bool,
last_seen_at: datetime | None = None,
friend_ids: set[int] | None = None,
):
"""Broadcast presence update to mutual friends via Redis."""
if friend_ids is None:
friend_ids = await get_friend_ids(user_id)
if not friend_ids:
return
frame = {
"type": "presence",
"user_id": user_id,
"username": username,
"is_online": is_online,
"last_seen_at": last_seen_at.isoformat() if last_seen_at else None,
"friend_ids": list(friend_ids),
}
# Redis broadcast for all loops/instances
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async with client:
await client.publish("presence:events", json.dumps(frame, default=str))
except Exception as exc:
logger.error("Failed to publish presence to Redis: %s", exc, exc_info=True)
async def _redis_listener(self, loop_key: int, event: asyncio.Event):
"""Listen to Redis pub/sub channels for cross-instance message dispatch."""
while True:
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
pubsub = client.pubsub()
await pubsub.psubscribe("world:*:chat", "dm:*:chat", "presence:events")
event.set()
logger.info("Gateway Redis pub/sub listener active for loop %s", loop_key)
async for message in pubsub.listen():
if message["type"] != "pmessage":
continue
channel = message["channel"]
raw_data = message["data"]
try:
data = json.loads(raw_data)
except Exception:
continue
if channel.startswith("world:") and channel.endswith(":chat"):
parts = channel.split(":")
world_id = int(parts[1])
await self.broadcast_world_chat(world_id, data, room_id=data.get("room_id"))
elif channel.startswith("dm:") and channel.endswith(":chat"):
parts = channel.split(":")
conv_id = int(parts[1])
await self.broadcast_dm_chat(conv_id, data)
elif channel == "presence:events":
logger.info("Redis presence event received: %s. Active users: %s", data, list(self._user_connections.keys()))
for friend_id in data.get("friend_ids", []):
targets = list(self._user_connections.get(friend_id, set()))
logger.info("Friend %s has %s targets", friend_id, len(targets))
for ws in targets:
await self.send_to_ws(ws, data)
except asyncio.CancelledError:
break
except Exception as exc:
logger.debug("Redis listener encountered error, retrying in 2s: %s", exc)
event.clear()
await asyncio.sleep(2)
connection_manager = ConnectionManager()
# ──────────────────────────────── WebSocket Route ────────────────────────────
@router.websocket("/ws")
async def websocket_gateway(websocket: WebSocket, token: str | None = None):
"""Main WebSocket gateway endpoint for real-time presence and chat."""
user: User | None = None
# 1. Check query parameter token
if token:
user = await authenticate_token(token)
if user is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept()
# 2. If not authenticated via query param, wait for first auth frame
if user is None:
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=3.0)
data = json.loads(raw)
if data.get("type") == "auth" and data.get("token"):
user = await authenticate_token(data["token"])
except (asyncio.TimeoutError, json.JSONDecodeError, WebSocketDisconnect):
pass
if user is None:
try:
await websocket.send_text(json.dumps({"type": "error", "message": "Authentication failed"}))
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
except Exception:
pass
return
# Mark user online in Redis immediately upon successful auth
await presence_store.heartbeat(user.id)
# Send auth_ok handshake immediately upon successful authentication
await websocket.send_text(
json.dumps({
"type": "auth_ok",
"user_id": user.id,
"username": user.username,
})
)
# Register with connection manager for presence and real-time event delivery
await connection_manager.connect(websocket, user)
try:
while True:
text = await websocket.receive_text()
try:
msg = json.loads(text)
except json.JSONDecodeError:
await websocket.send_text(json.dumps({"type": "error", "message": "Invalid JSON"}))
continue
msg_type = msg.get("type")
if msg_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif msg_type == "heartbeat":
await presence_store.heartbeat(user.id)
await websocket.send_text(json.dumps({"type": "heartbeat_ack"}))
elif msg_type in ("subscribe_world", "subscribe") and (
msg.get("world_id") or msg.get("channel") == "world"
):
world_id = msg.get("world_id") or msg.get("id")
if isinstance(world_id, int):
ok = await connection_manager.subscribe_world(websocket, world_id)
if ok:
await websocket.send_text(
json.dumps({"type": "subscribed", "channel": "world", "id": world_id})
)
else:
await websocket.send_text(
json.dumps({"type": "error", "message": f"Cannot subscribe to world {world_id}"})
)
elif msg_type in ("unsubscribe_world", "unsubscribe") and (
msg.get("world_id") or msg.get("channel") == "world"
):
world_id = msg.get("world_id") or msg.get("id")
if isinstance(world_id, int):
connection_manager.unsubscribe_world(websocket, world_id)
await websocket.send_text(
json.dumps({"type": "unsubscribed", "channel": "world", "id": world_id})
)
elif msg_type in ("subscribe_dm", "subscribe") and (
msg.get("conversation_id") or msg.get("channel") == "dm"
):
conv_id = msg.get("conversation_id") or msg.get("id")
if isinstance(conv_id, int):
ok = await connection_manager.subscribe_dm(websocket, conv_id)
if ok:
await websocket.send_text(
json.dumps({"type": "subscribed", "channel": "dm", "id": conv_id})
)
else:
await websocket.send_text(
json.dumps({"type": "error", "message": f"Cannot subscribe to conversation {conv_id}"})
)
elif msg_type in ("unsubscribe_dm", "unsubscribe") and (
msg.get("conversation_id") or msg.get("channel") == "dm"
):
conv_id = msg.get("conversation_id") or msg.get("id")
if isinstance(conv_id, int):
connection_manager.unsubscribe_dm(websocket, conv_id)
await websocket.send_text(
json.dumps({"type": "unsubscribed", "channel": "dm", "id": conv_id})
)
else:
await websocket.send_text(json.dumps({"type": "error", "message": f"Unknown action: {msg_type}"}))
except WebSocketDisconnect:
pass
finally:
await connection_manager.disconnect(websocket)
+33 -3
View File
@@ -6,15 +6,23 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, users, worlds, assets from app.routers import (
auth, users, worlds, assets, asset_collections, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, marketplace, payouts, discovery,
)
from app.gateway import ws
from app.services.game_servers import supervisor
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown events.""" """Application lifespan: startup and shutdown events."""
# Startup: initialize database, connect to Redis, etc. supervisor.start()
yield yield
# Shutdown: close connections, cleanup await supervisor.shutdown()
app = FastAPI( app = FastAPI(
@@ -37,7 +45,29 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"]) 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(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(asset_collections.router, prefix="/api/assets/collections", tags=["asset-collections"])
app.include_router(asset_collections.router, prefix="/assets/collections", tags=["asset-collections"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
app.include_router(groups.router, prefix="/api/groups", tags=["groups"])
app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"])
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"])
app.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"])
app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"])
app.include_router(chat_world.router, prefix="/api/chat", tags=["chat-world"])
app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"])
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"])
app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"])
app.include_router(ws.router, prefix="/api", tags=["websocket"])
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
@app.get("/api/health") @app.get("/api/health")
+27 -1
View File
@@ -2,5 +2,31 @@ from app.models.user import User
from app.models.session import Session from app.models.session import Session
from app.models.world import World from app.models.world import World
from app.models.asset import Asset from app.models.asset import Asset
from app.models.asset_collection import AssetCollection, CollectionAsset
from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
)
from app.models.groups import Group, GroupMember, GroupRole
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
from app.models.wallet import Wallet, Transaction, TransactionType
from app.models.marketplace import (
CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
ItemType, ListingStatus, PurchaseStatus,
)
from app.models.world_visit import WorldVisit
__all__ = ["User", "Session", "World", "Asset"] __all__ = [
"User", "Session", "World", "Asset", "AssetCollection", "CollectionAsset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole",
"Profile", "ProfileFavorite", "ProfileVisibility",
"Wallet", "Transaction", "TransactionType",
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
"ItemType", "ListingStatus", "PurchaseStatus",
"WorldVisit",
]
+6 -1
View File
@@ -23,7 +23,12 @@ class Asset(Base):
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
asset_type = Column( asset_type = Column(
ENUM(AssetType, name="asset_type", create_type=False), ENUM(
AssetType,
name="asset_type",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False, nullable=False,
) )
file_path = Column(String(512), nullable=False) file_path = Column(String(512), nullable=False)
+53
View File
@@ -0,0 +1,53 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class AssetCollection(Base):
"""A curated collection or pack of 3D models and assets."""
__tablename__ = "asset_collections"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
theme = Column(String(64), nullable=False, default="general", index=True)
thumbnail_url = Column(String(512), nullable=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_public = Column(Boolean, default=True, nullable=False, index=True)
tags = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
owner = relationship("User", back_populates="asset_collections")
items = relationship(
"CollectionAsset",
back_populates="collection",
cascade="all, delete-orphan",
order_by="CollectionAsset.sort_order",
)
class CollectionAsset(Base):
"""Join entity linking an Asset to an AssetCollection with ordering."""
__tablename__ = "collection_assets"
id = Column(Integer, primary_key=True, index=True)
collection_id = Column(
Integer,
ForeignKey("asset_collections.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
asset_id = Column(
Integer,
ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
collection = relationship("AssetCollection", back_populates="items")
asset = relationship("Asset")
+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")
+221
View File
@@ -0,0 +1,221 @@
"""Chat: DM conversations and world/room chat, plus message reports.
docs/planning/social.md §4/§5 (US-4). This module is models + schema only --
text filtering, rate limiting, and the send/list/report/moderation endpoints
belong to sibling tasks that depend on this one.
Delete-behavior choices (mirrors ``app/models/world_content.py``'s
established convention of preserving audit/history data over cascading it
away):
- ``conversations.user_a_id`` / ``user_b_id`` -> CASCADE. A DM conversation
is meaningless with one of its two (and only) participants gone, so
deleting either user removes the conversation outright, which in turn
cascades to its ``conversation_participants`` rows and its ``messages``.
- ``conversation_participants.user_id`` -> CASCADE, ``.conversation_id`` ->
CASCADE. Pure join-table rows; they only make sense alongside both sides.
- ``messages.conversation_id`` -> CASCADE. Deleting a conversation deletes
its message history with it.
- ``messages.sender_id`` -> SET NULL (nullable). Unlike a DM conversation
(which dies with its participants above), world/room chat history and any
message under moderation review should survive the sender's account being
deleted -- the row just becomes attributed to no one, matching how
``WorldChunk.updated_by_id`` / ``WorldChunkRevision.actor_id`` treat
actor references as SET NULL rather than cascading away history.
- ``messages.world_id`` / ``room_id`` -> CASCADE. World/room chat has no
meaning once the world or room it was said in is gone; consistent with
``WorldChunk``'s own CASCADE from both.
- ``message_reports.message_id`` -> SET NULL (nullable). Same audit-trail
reasoning as ``WorldChunkRevision`` (world_content.py): a moderation
report is a record that something *was* said and reported, which must
outlive the underlying message being removed (e.g. as the moderation
action taken in response to the report itself).
- ``message_reports.reporter_id`` / ``moderator_id`` -> SET NULL (nullable).
Actor references for an audit record; the report itself must survive
either account being deleted later, matching the actor-reference
convention above.
"""
import enum
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
DateTime,
ForeignKey,
Index,
Integer,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class ReportStatus(str, enum.Enum):
OPEN = "open"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Conversation(Base):
"""A DM conversation between exactly two users.
``conversation_participants`` is the real roster (modeled as a join
table so a future group-DM schema change can widen it beyond two rows
without touching this table's shape), but a 2-party DM also needs a
cheap, DB-enforced "only one conversation per pair" guarantee, which a
plain join table can't express as a simple unique constraint. So the
pair is additionally denormalized here as ``user_a_id``/``user_b_id``,
always stored with the lower user id first (enforced by the check
constraint below); the API layer is responsible for normalizing the
pair before insert/lookup.
"""
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, index=True)
user_a_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
user_b_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=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
)
participants = relationship(
"ConversationParticipant", back_populates="conversation", cascade="all, delete-orphan"
)
messages = relationship(
"Message", back_populates="conversation", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("user_a_id", "user_b_id", name="uq_conversations_pair"),
CheckConstraint("user_a_id < user_b_id", name="ck_conversations_ordered_pair"),
)
class ConversationParticipant(Base):
"""Join table of a conversation's members.
DMs are strictly 2-party today (enforced by ``Conversation``'s pair
columns above), but this is modeled as a real join table -- not folded
into ``Conversation`` -- so a later group-conversation feature can add
rows here without a schema change to this table.
"""
__tablename__ = "conversation_participants"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(
Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# DM unread tracking (app/routers/chat_dm.py, migration 0010): bumped to
# "now" whenever this participant fetches the conversation's history.
# Null means "never read" -- everything in the conversation is unread.
last_read_at = Column(DateTime(timezone=True), nullable=True)
conversation = relationship("Conversation", back_populates="participants")
user = relationship("User")
__table_args__ = (
UniqueConstraint(
"conversation_id", "user_id", name="uq_conversation_participants_pair"
),
)
class Message(Base):
"""A chat message: either a DM (``conversation_id`` set) or a
world/room chat message (``conversation_id`` null, ``world_id`` set and
optionally ``room_id``).
``world_id`` is denormalized onto every world/room message (mirroring
``WorldChunk.world_id``) so world-scoped history queries don't need a
join through rooms; ``room_id`` narrows further to a specific room when
the world subdivides chat by room, and is null for world-wide chat.
"""
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
sender_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
conversation_id = Column(
Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=True
)
world_id = Column(
Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=True
)
room_id = Column(
Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"), nullable=True
)
body = Column(Text, nullable=False)
filtered = Column(Boolean, nullable=False, default=False, server_default="false")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
sender = relationship("User")
conversation = relationship("Conversation", back_populates="messages")
world = relationship("World")
room = relationship("WorldRoom")
reports = relationship(
"MessageReport", back_populates="message", cascade="all, delete-orphan"
)
__table_args__ = (
Index("ix_messages_conversation_created", "conversation_id", "created_at"),
Index("ix_messages_world_created", "world_id", "created_at"),
)
class MessageReport(Base):
"""A moderation report filed against a message.
``message_id`` is nullable with ``ON DELETE SET NULL`` (rather than
CASCADE) so the report -- the audit record that something was said and
flagged -- outlives the message row, including when the message's own
removal is the moderation action taken in response to this report.
"""
__tablename__ = "message_reports"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(
Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True
)
reporter_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
reason = Column(Text, nullable=False)
status = Column(
ENUM(
ReportStatus,
name="message_report_status",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=ReportStatus.OPEN,
server_default=ReportStatus.OPEN.value,
)
moderator_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
message = relationship("Message", back_populates="reports")
reporter = relationship("User", foreign_keys=[reporter_id])
moderator = relationship("User", foreign_keys=[moderator_id])

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