Compare commits

...

86 Commits

Author SHA1 Message Date
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 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
151 changed files with 24712 additions and 86 deletions
+40
View File
@@ -0,0 +1,40 @@
{
"objects": {
"crate_small": {
"asset_id": "props/crate_small.glb",
"type": "prop",
"display_name": "Small Crate",
"category": "props"
},
"rock_01": {
"asset_id": "props/rock_01.glb",
"type": "prop",
"display_name": "Rock",
"category": "nature"
},
"tree_pine_01": {
"asset_id": "props/tree_pine_01.glb",
"type": "prop",
"display_name": "Pine Tree",
"category": "nature"
},
"lamp_post_01": {
"asset_id": "props/lamp_post_01.glb",
"type": "prop",
"display_name": "Lamp Post",
"category": "props"
},
"chest_01": {
"asset_id": "props/chest.glb",
"type": "interactable",
"display_name": "Treasure Chest",
"category": "interactables"
},
"spawn_point": {
"asset_id": "",
"type": "spawn_point",
"display_name": "Spawn Point",
"category": "gameplay"
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset, avatar_loadout # noqa: F401
from app.models import user, session, world, asset, avatar_loadout, world_content # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -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")
+16
View File
@@ -104,6 +104,22 @@ class Settings(BaseSettings):
# Kill a world's server this long after its last join with no active
# players reported.
GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300
# How long ensure_server() waits for a spawned server to report
# "ready": true in its status file before giving up (503 to the
# joining client). See world_persistence_impl.md §7.4 -- content
# loading makes a slow start more likely than the old bare-port-bind
# wait, so this is a separate, longer-by-default budget.
GAME_SERVER_READY_TIMEOUT_SECONDS: float = 20.0
# The base URL the dedicated Godot server should call back into this
# backend on, for the read-only /api/internal content routes
# (world_persistence_impl.md §4.4 / §7).
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
# ── World content persistence (world_persistence_impl.md §4.4) ─────────
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
model_config = SettingsConfigDict(
env_file=".env",
+30
View File
@@ -53,3 +53,33 @@ async def 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)
+25 -1
View File
@@ -6,7 +6,12 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets, avatar
from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal,
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
@@ -40,8 +45,27 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.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")
+25 -1
View File
@@ -3,5 +3,29 @@ from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
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", "AvatarLoadout"]
__all__ = [
"User", "Session", "World", "Asset", "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)
description = Column(Text, nullable=True)
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,
)
file_path = Column(String(512), nullable=False)
+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])
+97
View File
@@ -0,0 +1,97 @@
"""Groups: named groups with owner/admin/member roles.
docs/planning/social.md §4/§5 (US-5). This module is models + schema only --
create/join/leave, role authorization (only the owner may transfer ownership
or promote/demote admins), and the group page aggregate endpoint belong to
the API router task ("Groups API") that depends on this one.
Delete-behavior choices:
- ``groups.owner_id`` -> ``SET NULL`` (nullable). A group is a durable,
multi-member community, not content solely attributable to one person like
a ``World``/``Asset``. Cascading the group away (or hard-blocking the
owner's account deletion) would punish every other member for the owner's
choice to leave the platform. Instead the group and its full membership
list survive with ``owner_id`` nulled out; the sibling API task is
responsible for surfacing an ownerless group (e.g. prompting an admin to
claim ownership) the same way ``Message.sender_id`` / ``WorldChunk``'s
actor columns already treat "the actor is gone" as a survivable state
elsewhere in this schema.
- ``group_members.group_id`` -> ``CASCADE``. A membership row is meaningless
without the group it belongs to; deleting a group deletes its roster,
mirroring ``ConversationParticipant.conversation_id`` in chat.py.
- ``group_members.user_id`` -> ``CASCADE``. Deleting a user removes their own
membership rows wherever they appear, matching the join-table convention
used by ``ConversationParticipant.user_id`` and the social-graph tables
(``Friendship``/``Follow``/``Block``) for plain per-user references.
"""
import enum
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class GroupRole(str, enum.Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
class Group(Base):
"""A named group. ``owner_id`` is nullable -- see module docstring for
why the group survives its owner's account deletion."""
__tablename__ = "groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, 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
)
owner = relationship("User", back_populates="owned_groups")
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
"""A group's roster: one row per (group, user) with a role."""
__tablename__ = "group_members"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
role = Column(
ENUM(
GroupRole,
name="group_role",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=GroupRole.MEMBER,
server_default=GroupRole.MEMBER.value,
)
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
group = relationship("Group", back_populates="members")
user = relationship("User", back_populates="group_memberships")
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
+195
View File
@@ -0,0 +1,195 @@
"""Marketplace models: catalog items, listings, purchases, and creator balances.
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
from datetime import datetime, timezone
import enum
from sqlalchemy import (
BigInteger,
CheckConstraint,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON
from sqlalchemy.orm import relationship
from app.database import Base
class ItemType(str, enum.Enum):
asset = "asset"
avatar_item = "avatar_item"
experience_pass = "experience_pass"
class ListingStatus(str, enum.Enum):
active = "active"
sold = "sold"
delisted = "delisted"
removed_by_moderation = "removed_by_moderation"
class PurchaseStatus(str, enum.Enum):
pending_escrow = "pending_escrow"
completed = "completed"
refunded = "refunded"
reversed = "reversed"
class CatalogItem(Base):
__tablename__ = "catalog_items"
id = Column(BigInteger, primary_key=True, autoincrement=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, index=True)
item_type = Column(Enum(ItemType, name="catalog_item_type"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(String(2000), nullable=True)
metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
owner = relationship("User", foreign_keys=[owner_id])
asset = relationship("Asset", foreign_keys=[asset_id])
listings = relationship("MarketplaceListing", back_populates="catalog_item", cascade="all, delete-orphan")
class MarketplaceListing(Base):
__tablename__ = "marketplace_listings"
id = Column(BigInteger, primary_key=True, autoincrement=True)
catalog_item_id = Column(BigInteger, ForeignKey("catalog_items.id", ondelete="CASCADE"), nullable=False, index=True)
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
price = Column(BigInteger, nullable=False)
status = Column(
Enum(ListingStatus, name="marketplace_listing_status"),
nullable=False,
default=ListingStatus.active,
server_default=ListingStatus.active.value,
)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("price > 0", name="check_listing_price_positive"),
)
catalog_item = relationship("CatalogItem", back_populates="listings")
seller = relationship("User", foreign_keys=[seller_id])
class Purchase(Base):
__tablename__ = "purchases"
id = Column(BigInteger, primary_key=True, autoincrement=True)
listing_id = Column(BigInteger, ForeignKey("marketplace_listings.id", ondelete="SET NULL"), nullable=True, index=True)
buyer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
price = Column(BigInteger, nullable=False)
platform_fee = Column(BigInteger, nullable=False)
seller_net = Column(BigInteger, nullable=False)
status = Column(
Enum(PurchaseStatus, name="purchase_status"),
nullable=False,
default=PurchaseStatus.pending_escrow,
server_default=PurchaseStatus.pending_escrow.value,
)
escrow_hold_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
listing = relationship("MarketplaceListing", foreign_keys=[listing_id])
buyer = relationship("User", foreign_keys=[buyer_id])
seller = relationship("User", foreign_keys=[seller_id])
class CreatorBalance(Base):
__tablename__ = "creator_balances"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
available = Column(BigInteger, nullable=False, default=0, server_default="0")
pending = Column(BigInteger, nullable=False, default=0, server_default="0")
lifetime_earned = Column(BigInteger, nullable=False, default=0, server_default="0")
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
)
user = relationship("User", foreign_keys=[user_id])
class PayoutStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
paid = "paid"
rejected = "rejected"
class PayoutRequest(Base):
__tablename__ = "payout_requests"
id = Column(BigInteger, primary_key=True, autoincrement=True)
creator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
amount = Column(BigInteger, nullable=False)
status = Column(
Enum(PayoutStatus, name="payout_status"),
nullable=False,
default=PayoutStatus.pending,
server_default=PayoutStatus.pending.value,
)
external_ref = Column(String(255), nullable=True)
provider_ref = Column(String(255), nullable=True)
requested_by = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("amount > 0", name="check_payout_amount_positive"),
)
creator = relationship("User", foreign_keys=[creator_id])
requester = relationship("User", foreign_keys=[requested_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+129
View File
@@ -0,0 +1,129 @@
"""User profile: bio, avatar reference, privacy settings, favorite worlds.
This module is models + migration only -- the public profile view endpoint
(``GET /api/profile/{user_id}``, which will need to interpret ``privacy_json``
against the viewer's relationship to the owner) belongs to a sibling task
that depends on this one. This task only owns the authenticated-owner
read/write endpoints (``/api/profile/me`` and its ``favorites`` sub-resource).
Design choices:
- ``profiles.user_id`` is both the primary key and the FK to ``users.id``
(``ondelete="CASCADE"``) -- exactly one profile row per user, following the
same one-row-per-user shape as ``AvatarLoadout`` (app/models/avatar_loadout.py).
The row is created lazily (upserted) on first ``PUT /api/profile/me``
rather than at registration time, again mirroring ``AvatarLoadout``.
- ``avatar_asset_id`` -> ``assets.id`` with ``ondelete="SET NULL"``. A
profile's chosen "avatar picture" asset is a soft reference: if the
underlying asset is later deleted the profile shouldn't be destroyed or
left pointing at a dangling id, it should just fall back to "no avatar
picture set" (nullable). The API layer additionally validates on write
that the referenced asset actually exists (existence is not enforced by
the FK alone at the API layer, since the FK only stops inserts of a
nonexistent id -- it doesn't tell the caller *why* their PUT failed).
- ``bio`` is ``Text`` with the 500-char cap enforced at the API/schema layer
(Pydantic ``max_length``), matching how ``StatusUpdate.status_message`` in
presence.py enforces its length cap in the router rather than the schema.
- ``privacy_json`` is JSONB (project convention for document-shaped fields --
see avatar_loadout.py, world_content.py) holding exactly three keys, each a
``ProfileVisibility`` enum value (stored as its string value in JSON, not a
Postgres ENUM type -- there's no need for a DB-level enum since this is a
small, entirely API-validated document, and a plain JSONB column is easier
to extend with more privacy keys later without a migration):
- ``profile_visibility``: who can view the profile at all.
- ``friends_list_visibility``: who can see the user's friends list.
- ``favorites_visibility``: who can see the user's favorited worlds.
Each value is one of "public", "friends", "private". Defaults (applied when
a profile is first created) are all "public" -- the least surprising
default for a new social feature, matching the "everything visible unless
you lock it down" posture other social features here take by default
(e.g. presence/status has no privacy gate at all yet).
- Favorites use a join table (``profile_favorites``: ``user_id`` + ``world_id``,
composite PK) rather than a JSON array column. A plain FK join table gets
referential integrity for free (a favorited world that's later deleted
disappears from favorites automatically via ON DELETE CASCADE, matching
how ``World.assets``/``World.rooms`` cascade), lets Postgres enforce
"no duplicate favorite" via the composite PK instead of API-layer
deduplication, and keeps "does this world still exist" as a DB invariant
rather than something every reader has to re-check against a JSON blob.
The tradeoff (an extra table/join vs. a single JSON column) is worth it
here because favorites are a list of foreign-key references, not a
free-form document -- the exact case join tables suit better than JSONB.
"""
import enum
from sqlalchemy import Column, Integer, ForeignKey, Text, DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class ProfileVisibility(str, enum.Enum):
PUBLIC = "public"
FRIENDS = "friends"
PRIVATE = "private"
# Applied when a profile is created for the first time (lazy upsert on the
# first PUT /api/profile/me). Keys match the three privacy_json fields
# documented above.
DEFAULT_PRIVACY: dict = {
"profile_visibility": ProfileVisibility.PUBLIC.value,
"friends_list_visibility": ProfileVisibility.PUBLIC.value,
"favorites_visibility": ProfileVisibility.PUBLIC.value,
}
BIO_MAX_LEN = 500
class Profile(Base):
"""One row per user: bio, avatar picture reference, privacy settings."""
__tablename__ = "profiles"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
avatar_asset_id = Column(
Integer,
ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True,
)
bio = Column(Text, nullable=True)
privacy_json = Column(JSONB, nullable=False, server_default="{}")
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="profile")
avatar_asset = relationship("Asset")
# Deliberately no ORM relationship to ProfileFavorite here: profiles and
# profile_favorites both independently FK users.id (no direct FK between
# the two tables), and the router only ever needs to query/replace a
# user's favorites by user_id directly -- not traverse it off a loaded
# Profile object. Keeping it a plain queryable table avoids a
# hand-spelled, easy-to-get-wrong primaryjoin for no real benefit.
class ProfileFavorite(Base):
"""A user's favorited world. Composite PK -- see module docstring for why
this is a join table rather than a JSON array column."""
__tablename__ = "profile_favorites"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
world_id = Column(
Integer,
ForeignKey("worlds.id", ondelete="CASCADE"),
primary_key=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
world = relationship("World")
+106
View File
@@ -0,0 +1,106 @@
"""Social graph: friendships, follows, blocks.
docs/planning/social.md §4/§5 (US-1). Friends = mutual (request -> accept),
Follow = one-way (no approval), Block = hard deny. This module is models +
schema only -- the request/accept/block business logic (idempotent
duplicate/self handling, removing an existing friendship/follow on block,
etc.) belongs to the API router task that depends on this one.
"""
import enum
from sqlalchemy import Column, Integer, ForeignKey, DateTime, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class FriendshipStatus(str, enum.Enum):
PENDING = "pending"
ACCEPTED = "accepted"
class Friendship(Base):
"""A friend request, directional until accepted.
``requester_id`` sent the request to ``addressee_id``. Once accepted the
friendship is mutual, but the row stays directional (who asked whom) for
audit purposes; the API layer treats it as symmetric once accepted.
"""
__tablename__ = "friendships"
id = Column(Integer, primary_key=True, index=True)
requester_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
addressee_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
status = Column(
ENUM(
FriendshipStatus,
name="friendship_status",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=FriendshipStatus.PENDING,
server_default=FriendshipStatus.PENDING.value,
)
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)
requester = relationship("User", foreign_keys=[requester_id], back_populates="sent_friend_requests")
addressee = relationship("User", foreign_keys=[addressee_id], back_populates="received_friend_requests")
__table_args__ = (
UniqueConstraint("requester_id", "addressee_id", name="uq_friendships_pair"),
)
class Follow(Base):
"""A one-way follow relationship: ``follower_id`` follows ``followed_id``."""
__tablename__ = "follows"
id = Column(Integer, primary_key=True, index=True)
follower_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
followed_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)
follower = relationship("User", foreign_keys=[follower_id], back_populates="following")
followed = relationship("User", foreign_keys=[followed_id], back_populates="followers")
__table_args__ = (
UniqueConstraint("follower_id", "followed_id", name="uq_follows_pair"),
)
class Block(Base):
"""A hard deny: ``blocker_id`` has blocked ``blocked_id``."""
__tablename__ = "blocks"
id = Column(Integer, primary_key=True, index=True)
blocker_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
blocked_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)
blocker = relationship("User", foreign_keys=[blocker_id], back_populates="blocks_initiated")
blocked = relationship("User", foreign_keys=[blocked_id], back_populates="blocked_by")
__table_args__ = (
UniqueConstraint("blocker_id", "blocked_id", name="uq_blocks_pair"),
)
+62 -1
View File
@@ -1,7 +1,7 @@
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -20,6 +20,10 @@ class User(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Presence / status (backend/app/services/presence.py, app/routers/presence.py)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
status_message = Column(Text, nullable=True)
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
@@ -31,6 +35,63 @@ class User(Base):
cascade="all, delete-orphan",
)
# Profile (app/models/profile.py): bio/avatar picture/privacy + favorites.
profile = relationship(
"Profile",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
# Social graph (docs/planning/social.md US-1; app/models/social.py)
sent_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.requester_id",
back_populates="requester",
cascade="all, delete-orphan",
)
received_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.addressee_id",
back_populates="addressee",
cascade="all, delete-orphan",
)
following = relationship(
"Follow",
foreign_keys="Follow.follower_id",
back_populates="follower",
cascade="all, delete-orphan",
)
followers = relationship(
"Follow",
foreign_keys="Follow.followed_id",
back_populates="followed",
cascade="all, delete-orphan",
)
blocks_initiated = relationship(
"Block",
foreign_keys="Block.blocker_id",
back_populates="blocker",
cascade="all, delete-orphan",
)
blocked_by = relationship(
"Block",
foreign_keys="Block.blocked_id",
back_populates="blocked",
cascade="all, delete-orphan",
)
# Groups (docs/planning/social.md US-5; app/models/groups.py)
owned_groups = relationship(
"Group",
back_populates="owner",
)
group_memberships = relationship(
"GroupMember",
back_populates="user",
cascade="all, delete-orphan",
)
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
+104
View File
@@ -0,0 +1,104 @@
"""Wallet and Transaction ledger models for Credits virtual currency.
docs/planning/economy.md US-1.
"""
from datetime import datetime, timezone
import enum
from sqlalchemy import (
BigInteger,
CheckConstraint,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON
from sqlalchemy.orm import relationship
from app.database import Base
class TransactionType(str, enum.Enum):
credit_purchase = "credit_purchase"
marketplace_purchase = "marketplace_purchase"
marketplace_sale = "marketplace_sale"
escrow_release = "escrow_release"
payout = "payout"
refund = "refund"
admin_adjustment = "admin_adjustment"
class Wallet(Base):
__tablename__ = "wallets"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
balance = Column(BigInteger, nullable=False, default=0, server_default="0")
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("balance >= 0", name="check_wallet_balance_non_negative"),
)
user = relationship("User", backref="wallet", uselist=False)
transactions = relationship(
"Transaction",
back_populates="wallet",
cascade="all, delete-orphan",
order_by="Transaction.created_at.desc()",
)
class Transaction(Base):
__tablename__ = "transactions"
id = Column(BigInteger, primary_key=True, autoincrement=True)
wallet_id = Column(
BigInteger,
ForeignKey("wallets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
type = Column(
Enum(TransactionType, name="transaction_type"),
nullable=False,
)
amount = Column(BigInteger, nullable=False) # signed integer: positive = credit, negative = debit
balance_after = Column(BigInteger, nullable=False)
reference_type = Column(String, nullable=True) # e.g. "purchase", "sale", "payout_request"
reference_id = Column(String, nullable=True)
idempotency_key = Column(String, unique=True, nullable=True, index=True)
metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
index=True,
)
wallet = relationship("Wallet", back_populates="transactions")
+13 -2
View File
@@ -1,5 +1,5 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import ENUM, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
@@ -29,8 +29,19 @@ class World(Base):
),
default=WorldVisibility.PUBLIC,
)
category = Column(String(100), nullable=True, index=True)
tags = Column(ARRAY(String), nullable=True, server_default="{}")
play_count = Column(Integer, nullable=False, server_default="0", index=True)
last_played_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
__table_args__ = (
Index("ix_worlds_tags", "tags", postgresql_using="gin"),
)
owner = relationship("User", back_populates="worlds")
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")
visits = relationship("WorldVisit", back_populates="world", cascade="all, delete-orphan")
+153
View File
@@ -0,0 +1,153 @@
"""World content persistence models.
Implements the schema decided in ``docs/architecture/world_persistence_impl.md``
§4: chunk content (rooms, chunk payloads, chunk revisions) lives in Postgres,
alongside ``worlds``, rather than in a per-world SQLite file
(``docs/architecture/world.md`` §4, superseded).
The chunk *payload* format itself (the 48-byte header + sectioned binary
body) is unchanged from ``world.md`` §3.6 and is decoded/encoded by
``app.services.chunk_codec``, not by these models -- these just store the
opaque ``BYTEA``.
Conventions follow the existing models (``app/models/world.py``,
``app/models/avatar_loadout.py``): declarative ``Column(...)`` style,
``Integer`` surrogate PKs, ``DateTime(timezone=True)`` with
``server_default=func.now()``, JSONB for document-shaped fields.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
LargeBinary,
String,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# Default chunk grid, matching Chunk.CHUNK_SIZE_* in scripts/chunk.gd (the
# client's source of truth for the same constants) and world.md §2.2.
DEFAULT_ROOM_GRID: dict = {"size": [32, 32, 1], "origin": [0, 0, 0]}
# The room every world gets automatically (world_persistence_impl.md §4.1).
MAIN_ROOM_SLUG = "main"
MAIN_ROOM_NAME = "Main"
class WorldRoom(Base):
"""A bounded region within a world -- a level, building, or zone
(world.md §2.1). Holds the chunk grid config so a world can later hold
rooms with different grids without touching the ``worlds`` table.
"""
__tablename__ = "world_rooms"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(
Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True
)
slug = Column(String(64), nullable=False) # url-safe; "main" by default
name = Column(String(255), nullable=False)
# {"size": [x, y, z], "origin": [x, y, z]}
grid = Column(
JSONB, nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}',
)
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
__table_args__ = (
UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),
)
world = relationship("World", back_populates="rooms")
chunks = relationship("WorldChunk", back_populates="room", cascade="all, delete-orphan")
class WorldChunk(Base):
"""One chunk's persisted content -- the atomic load/save/stream unit
(world.md §2.1). ``payload`` is the world.md §3.6 binary blob, stored
verbatim as BYTEA.
Deltas from world.md §4.4 (SQLite design): a surrogate integer PK +
unique coordinate constraint (rather than ``chunk_key`` as the PK), no
``dirty`` column (write-behind doesn't apply over Postgres -- see
world_persistence_impl.md §3.3/§5), and content flags folded into this
row as JSONB + an indexed ``is_mature`` boolean rather than a separate
``content_flags`` table (world.md §4.6).
"""
__tablename__ = "world_chunks"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(
Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True
) # denormalized: world-scoped queries without a join through rooms
room_id = Column(
Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"), nullable=False, index=True
)
chunk_key = Column(String(160), nullable=False, unique=True) # world.md §2.3 / §4.5
cx = Column(Integer, nullable=False)
cy = Column(Integer, nullable=False)
cz = Column(Integer, nullable=False, server_default="0")
format_version = Column(Integer, nullable=False, server_default="1")
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes
payload_len = Column(Integer, nullable=False)
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
revision = Column(Integer, nullable=False, server_default="1")
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
is_mature = Column(Boolean, nullable=False, server_default="false")
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
__table_args__ = (
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
)
room = relationship("WorldRoom", back_populates="chunks")
class WorldChunkRevision(Base):
"""Audit trail of chunk writes (world.md §4.7). ``chunk_key``/``world_id``
are deliberately **not** foreign keys, so deleting a chunk or a world
preserves the audit trail.
Retention (world_persistence_impl.md §4.3, required): only the newest
``settings.WORLD_CHUNK_REVISION_KEEP`` rows per ``chunk_key`` keep a
non-null ``payload``; older rows are pruned (payload nulled, metadata
row kept) by ``WorldStore`` in the same transaction as the write that
triggers it.
"""
__tablename__ = "world_chunk_revisions"
id = Column(Integer, primary_key=True, index=True)
chunk_key = Column(String(160), nullable=False, index=True)
world_id = Column(Integer, nullable=False, index=True)
revision = Column(Integer, nullable=False)
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
checksum = Column(String(64), nullable=False)
payload = Column(LargeBinary, nullable=True) # full payload at this revision; nulled by retention
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
+27
View File
@@ -0,0 +1,27 @@
"""World visit tracking model for discovery analytics.
docs/planning/discovery.md (task b377353b8167).
"""
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class WorldVisit(Base):
__tablename__ = "world_visits"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
anonymous_token = Column(String(255), nullable=True)
visited_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
__table_args__ = (
Index("ix_world_visits_world_visited_at", "world_id", "visited_at"),
)
world = relationship("World", back_populates="visits")
user = relationship("User")
+42 -1
View File
@@ -54,6 +54,12 @@ def _client_key(request: Request) -> str:
# brute-forcer.
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
# Every limiter in the process -- named limits built via `rate_limit()`
# below register themselves here too, so `reset_all()` (the test hook) and
# any future process-wide accounting stay correct without each caller
# having to remember to wire it up.
_registered_limiters: list[SlidingWindowLimiter] = [_auth_limiter]
async def auth_rate_limit(request: Request) -> None:
"""FastAPI dependency: rate-limit auth attempts per client IP.
@@ -68,6 +74,41 @@ async def auth_rate_limit(request: Request) -> None:
_auth_limiter.check(f"auth:{_client_key(request)}")
def rate_limit(name: str, *, max_hits: int, window_seconds: float):
"""Build a named, per-client-IP FastAPI rate-limit dependency.
Generalizes the ``auth_rate_limit`` pattern above for other endpoints
(e.g. chat send, message report) that want their own independent cap:
report_rate_limit = rate_limit("message_report", max_hits=5, window_seconds=60)
@router.post("/report", dependencies=[Depends(report_rate_limit)])
...
Each call gets its own ``SlidingWindowLimiter`` -- counters for one
named limit never bleed into another, even for the same client IP --
and reuses ``SlidingWindowLimiter.check`` for the actual sliding-window
accounting and the 429 + ``Retry-After`` response. Like
``auth_rate_limit``, the returned dependency is a no-op when
``settings.RATE_LIMIT_ENABLED`` is false, so the checkpoint smoke suite
(one client IP for the whole run) doesn't trip on it.
``name`` only needs to be unique within the process; it namespaces the
per-IP key so it doesn't collide with ``auth_rate_limit`` or any other
named limit.
"""
limiter = SlidingWindowLimiter(max_hits=max_hits, window_seconds=window_seconds)
_registered_limiters.append(limiter)
async def _dependency(request: Request) -> None:
if not settings.RATE_LIMIT_ENABLED:
return
limiter.check(f"{name}:{_client_key(request)}")
return _dependency
def reset_all() -> None:
"""Test hook."""
_auth_limiter.reset()
for limiter in _registered_limiters:
limiter.reset()
+399
View File
@@ -0,0 +1,399 @@
"""Direct-message (DM) chat router.
docs/planning/social.md §4/§5 (US-4). Builds the send/list REST surface on
top of ``app/models/chat.py`` (Conversation/ConversationParticipant/Message,
already merged), ``app/services/text_filter.py`` (already merged), and
``app/rate_limit.py`` (already merged).
Endpoints:
- POST /api/chat/dm/{user_id}/messages -- send a DM to ``user_id``
- GET /api/chat/dm/{user_id}/messages -- paginated history with ``user_id``
- GET /api/chat/dm/conversations -- list the caller's conversations
Scope/boundaries (per task): REST send/list only, no WebSocket/real-time
delivery (separate task), no world/room chat (separate sibling task; that
uses the same ``Message`` table but with ``world_id``/``room_id`` set and
``conversation_id`` null).
Design decisions this task owns:
- **Blocking.** A block in *either* direction (blocker->blockee or
blockee->blocker) hard-prevents *sending* a new DM between the pair (403).
It does not hide history the pair already has -- ``GET .../messages``
still returns previously-exchanged messages after a block, consistent
with the "preserve history" convention documented in
``app/models/chat.py``'s module docstring. Only sending is blocked.
- **Text filtering.** Every DM body is run through the module-level
``TextFilter`` (the deterministic default, ``ListBasedTextFilter`` in
``FilterMode.REDACT``) before storing:
- ALLOW -> store the body as-is, ``filtered=False``.
- REDACT -> store ``filtered_text`` (matched spans starred out),
``filtered=True``.
- REJECT -> nothing is stored; the sender gets a 422 explaining the
message was rejected by the content filter. (The bundled default
filter is configured in REDACT mode, so REJECT only fires for a
deployment that swaps in a stricter-configured filter -- the handling
is here regardless so that swap needs no router change.)
- **Rate limiting.** Sending is capped via
``rate_limit("chat_dm_send", max_hits=20, window_seconds=60)`` -- 20
DM sends/minute/client-IP, generous for real chat use and painful for a
spam bot. A no-op when ``settings.RATE_LIMIT_ENABLED`` is false (see
``app/rate_limit.py``), matching every other rate-limited endpoint.
- **Unread tracking.** A last-read timestamp per participant
(``ConversationParticipant.last_read_at``, migration 0010). Fetching a
conversation's *latest* page of history (``GET .../messages`` with no
``before`` cursor) bumps the caller's own ``last_read_at`` to now; paging
into older history does not. ``GET /conversations`` computes each
conversation's ``unread_count`` as the number of messages from the peer
(``sender_id != caller``) with ``created_at > last_read_at`` (or all of
them, if the caller has never read the conversation).
- **Pagination.**
- History (``GET {user_id}/messages``): cursor-based, newest-first.
``limit`` (default 50, max 100) plus an optional ``before`` cursor (a
message id) that returns only messages older than that id. Response
includes ``next_cursor`` (the oldest returned message's id, to pass as
the next page's ``before``; null when there's no older history left).
- Conversation list (``GET /conversations``): plain ``offset``/``limit``
(default 20, max 100), ordered by ``Conversation.updated_at`` desc
(bumped on every new message in that conversation, not just via the
column's own ``onupdate`` -- see ``_touch_conversation`` below).
- Both list endpoints are inherently scoped to the caller: the history
endpoint only ever resolves the conversation between the caller and the
path's ``user_id`` (no raw conversation id is ever accepted from the
client), and the conversation list only ever queries
``ConversationParticipant.user_id == caller``. There is no way for a
client to address another pair's conversation.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.chat import Conversation, ConversationParticipant, Message
from app.models.social import Block
from app.models.user import User
from app.rate_limit import rate_limit
from app.services.chat_pubsub import publish_dm_chat_event
from app.services.text_filter import FilterAction, default_text_filter
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_text_filter = default_text_filter()
chat_dm_send_rate_limit = rate_limit("chat_dm_send", max_hits=20, window_seconds=60)
_BODY_MAX_LEN = 4000
# ──────────────────────────── Pydantic schemas ────────────────────────────
class SendDMRequest(BaseModel):
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN)
class DMMessageResponse(BaseModel):
id: int
conversation_id: int
sender_id: int
recipient_id: int
body: str
filtered: bool
created_at: datetime
model_config = {"from_attributes": True}
class DMMessageListResponse(BaseModel):
messages: list[DMMessageResponse]
next_cursor: int | None = None
class DMConversationSummary(BaseModel):
conversation_id: int
peer_id: int
peer_username: str
last_message: DMMessageResponse | None
unread_count: int
updated_at: datetime
class DMConversationListResponse(BaseModel):
conversations: list[DMConversationSummary]
total: int
# ──────────────────────────────── Helpers ──────────────────────────────────
async def _get_target_user(db: AsyncSession, user_id: int) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
async def _is_blocked_either_direction(db: AsyncSession, user_a: int, user_b: int) -> bool:
result = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == user_a, Block.blocked_id == user_b),
and_(Block.blocker_id == user_b, Block.blocked_id == user_a),
)
).limit(1)
)
return result.scalar_one_or_none() is not None
async def _get_or_create_conversation(
db: AsyncSession, user_a_id: int, user_b_id: int
) -> Conversation:
lo, hi = sorted((user_a_id, user_b_id))
result = await db.execute(
select(Conversation).where(
Conversation.user_a_id == lo, Conversation.user_b_id == hi
)
)
conversation = result.scalar_one_or_none()
if conversation is not None:
return conversation
conversation = Conversation(user_a_id=lo, user_b_id=hi)
db.add(conversation)
await db.flush() # assign conversation.id
db.add(ConversationParticipant(conversation_id=conversation.id, user_id=lo))
db.add(ConversationParticipant(conversation_id=conversation.id, user_id=hi))
await db.flush()
return conversation
async def _get_participant(
db: AsyncSession, conversation_id: int, user_id: int
) -> ConversationParticipant | None:
result = await db.execute(
select(ConversationParticipant).where(
ConversationParticipant.conversation_id == conversation_id,
ConversationParticipant.user_id == user_id,
)
)
return result.scalar_one_or_none()
def _peer_id(conversation: Conversation, self_id: int) -> int:
return conversation.user_b_id if conversation.user_a_id == self_id else conversation.user_a_id
def _touch_conversation(conversation: Conversation) -> None:
"""Bump ``updated_at`` explicitly.
``Conversation.updated_at`` has ``onupdate=func.now()``, but that only
fires when an UPDATE statement touches the ``conversations`` row itself
-- inserting a ``Message`` row alone never does. Explicitly dirtying the
column here is what actually keeps "most recently active conversation"
ordering correct for ``GET /conversations``.
"""
conversation.updated_at = datetime.now(timezone.utc)
def _to_message_response(message: Message, recipient_id: int) -> DMMessageResponse:
return DMMessageResponse(
id=message.id,
conversation_id=message.conversation_id,
sender_id=message.sender_id,
recipient_id=recipient_id,
body=message.body,
filtered=message.filtered,
created_at=message.created_at,
)
# ──────────────────────────────── Endpoints ────────────────────────────────
@router.post(
"/{user_id}/messages",
response_model=DMMessageResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(chat_dm_send_rate_limit)],
)
async def send_dm(
user_id: int,
body: SendDMRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Send a DM to ``user_id``."""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a DM to yourself"
)
await _get_target_user(db, user_id)
if await _is_blocked_either_direction(db, current_user.id, user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot message this user",
)
result = _text_filter.check(body.body)
if result.action is FilterAction.REJECT:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Message rejected by the content filter.",
)
conversation = await _get_or_create_conversation(db, current_user.id, user_id)
message = Message(
sender_id=current_user.id,
conversation_id=conversation.id,
body=result.filtered_text,
filtered=(result.action is FilterAction.REDACT),
)
db.add(message)
_touch_conversation(conversation)
await db.commit()
await db.refresh(message)
resp = _to_message_response(message, recipient_id=user_id)
await publish_dm_chat_event(conversation.id, resp.model_dump(mode="json"))
return resp
@router.get("/{user_id}/messages", response_model=DMMessageListResponse)
async def get_dm_history(
user_id: int,
current_user: CurrentUser,
db: DbDep,
limit: int = Query(50, ge=1, le=100),
before: int | None = Query(
None, description="Return messages older than this message id (pagination cursor)."
),
):
"""Paginated DM history between the caller and ``user_id``, newest first.
Marks the conversation as read (bumps the caller's ``last_read_at``) only
when fetching the first page (``before`` omitted) -- paging into older
history doesn't affect unread state.
"""
await _get_target_user(db, user_id)
lo, hi = sorted((current_user.id, user_id))
conv_result = await db.execute(
select(Conversation).where(Conversation.user_a_id == lo, Conversation.user_b_id == hi)
)
conversation = conv_result.scalar_one_or_none()
if conversation is None:
# No conversation yet -- nothing exchanged, not an error.
return DMMessageListResponse(messages=[], next_cursor=None)
stmt = select(Message).where(Message.conversation_id == conversation.id)
if before is not None:
stmt = stmt.where(Message.id < before)
stmt = stmt.order_by(Message.id.desc()).limit(limit + 1)
rows = (await db.execute(stmt)).scalars().all()
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = page[-1].id if has_more and page else None
if before is None:
participant = await _get_participant(db, conversation.id, current_user.id)
if participant is not None:
participant.last_read_at = datetime.now(timezone.utc)
await db.commit()
return DMMessageListResponse(
messages=[_to_message_response(m, recipient_id=user_id) for m in page],
next_cursor=next_cursor,
)
@router.get("/conversations", response_model=DMConversationListResponse)
async def list_conversations(
current_user: CurrentUser,
db: DbDep,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List the caller's DM conversations, most recently active first."""
total_result = await db.execute(
select(func.count())
.select_from(ConversationParticipant)
.where(ConversationParticipant.user_id == current_user.id)
)
total = total_result.scalar_one()
stmt = (
select(Conversation, ConversationParticipant)
.join(
ConversationParticipant,
ConversationParticipant.conversation_id == Conversation.id,
)
.where(ConversationParticipant.user_id == current_user.id)
.order_by(Conversation.updated_at.desc())
.offset(offset)
.limit(limit)
)
rows = (await db.execute(stmt)).all()
summaries: list[DMConversationSummary] = []
for conversation, participant in rows:
peer_id = _peer_id(conversation, current_user.id)
peer = (
await db.execute(select(User).where(User.id == peer_id))
).scalar_one_or_none()
peer_username = peer.username if peer is not None else "(deleted user)"
last_message_row = (
await db.execute(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.id.desc())
.limit(1)
)
).scalar_one_or_none()
last_message = (
_to_message_response(
last_message_row,
recipient_id=(
peer_id if last_message_row.sender_id == current_user.id else current_user.id
),
)
if last_message_row is not None
else None
)
unread_filters = [
Message.conversation_id == conversation.id,
Message.sender_id != current_user.id,
]
if participant.last_read_at is not None:
unread_filters.append(Message.created_at > participant.last_read_at)
unread_count = (
await db.execute(select(func.count()).select_from(Message).where(*unread_filters))
).scalar_one()
summaries.append(
DMConversationSummary(
conversation_id=conversation.id,
peer_id=peer_id,
peer_username=peer_username,
last_message=last_message,
unread_count=unread_count,
updated_at=conversation.updated_at,
)
)
return DMConversationListResponse(conversations=summaries, total=total)
+197
View File
@@ -0,0 +1,197 @@
"""Message reporting and moderation review endpoints.
Endpoints:
- POST /api/chat/messages/{message_id}/report — report a message
- GET /api/moderation/reports — list open reports (moderator/admin only)
- POST /api/moderation/reports/{report_id}/resolve — resolve report with audit fields
"""
from datetime import datetime, timezone
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.chat import Message, MessageReport, ReportStatus
from app.models.user import User
chat_router = APIRouter()
moderation_router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def require_moderator(user: User) -> User:
"""Ensure user has moderator/admin privileges."""
if (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator or administrator privileges required",
)
class MessageReportCreate(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
category: str | None = Field(default=None, max_length=50)
class MessageReportResponse(BaseModel):
id: int
message_id: int | None
reporter_id: int | None
reason: str
status: ReportStatus
moderator_id: int | None = None
resolved_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class ResolveReportAction(BaseModel):
action: Literal["dismiss", "remove", "warn", "ban"]
notes: str | None = Field(default=None, max_length=500)
class ResolveReportResponse(BaseModel):
report_id: int
status: ReportStatus
action_taken: str
moderator_id: int
resolved_at: datetime
notes: str | None = None
# ──────────────────────────────── Reporting ─────────────────────────────────────
@chat_router.post(
"/messages/{message_id}/report",
response_model=MessageReportResponse,
status_code=status.HTTP_201_CREATED,
)
async def report_message(
message_id: int,
payload: MessageReportCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Report a message for moderation review."""
message = await db.get(Message, message_id)
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Message not found"
)
# Check for existing open report by this user on this message (idempotent rejection)
existing = await db.execute(
select(MessageReport).where(
MessageReport.message_id == message_id,
MessageReport.reporter_id == current_user.id,
MessageReport.status == ReportStatus.OPEN,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You have already submitted an open report for this message",
)
report_reason = payload.reason
if payload.category:
report_reason = f"[{payload.category}] {payload.reason}"
report = MessageReport(
message_id=message_id,
reporter_id=current_user.id,
reason=report_reason,
status=ReportStatus.OPEN,
)
db.add(report)
await db.commit()
await db.refresh(report)
return report
# ──────────────────────────────── Moderation ────────────────────────────────────
@moderation_router.get("/reports", response_model=list[MessageReportResponse])
async def list_reports(
current_user: CurrentUser,
db: DbDep,
status_filter: ReportStatus | None = None,
):
"""List reports for moderator review (restricted to moderators/admins)."""
require_moderator(current_user)
query = select(MessageReport).order_by(desc(MessageReport.created_at))
if status_filter:
query = query.where(MessageReport.status == status_filter)
else:
query = query.where(MessageReport.status == ReportStatus.OPEN)
result = await db.execute(query)
return list(result.scalars().all())
@moderation_router.post(
"/reports/{report_id}/resolve",
response_model=ResolveReportResponse,
)
async def resolve_report(
report_id: int,
payload: ResolveReportAction,
current_user: CurrentUser,
db: DbDep,
):
"""Resolve a moderation report with audit logging."""
require_moderator(current_user)
report = await db.get(MessageReport, report_id)
if report is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Report not found"
)
now = datetime.now(timezone.utc)
report.moderator_id = current_user.id
report.resolved_at = now
if payload.action == "dismiss":
report.status = ReportStatus.DISMISSED
else:
report.status = ReportStatus.RESOLVED
# Handle action-specific effects
if report.message_id:
msg = await db.get(Message, report.message_id)
if msg is not None:
if payload.action == "ban" and msg.sender_id:
sender = await db.get(User, msg.sender_id)
if sender:
sender.is_active = False
if payload.action in ("remove", "ban"):
await db.delete(msg)
await db.commit()
return ResolveReportResponse(
report_id=report.id,
status=report.status,
action_taken=payload.action,
moderator_id=current_user.id,
resolved_at=now,
notes=payload.notes,
)
+315
View File
@@ -0,0 +1,315 @@
"""In-experience world and room chat router.
docs/planning/social.md §4/§5 (US-4). Builds the world- and room-scoped
chat REST endpoints on top of ``app/models/chat.py`` (Message),
``app/services/text_filter.py``, ``app/rate_limit.py``, and
``app/services/chat_pubsub.py``.
Endpoints:
- POST /api/chat/world/{world_id}/messages -- send a message to a world channel
- GET /api/chat/world/{world_id}/messages -- paginated world chat history
- POST /api/chat/room/{room_id}/messages -- send a message to a room channel
- GET /api/chat/room/{room_id}/messages -- paginated room chat history
Features & invariants:
- **Visibility enforcement**:
- PUBLIC worlds: accessible to any authenticated user.
- PRIVATE worlds: accessible only to the world owner.
- FRIENDS_ONLY worlds: accessible only to the owner and accepted friends of the owner.
- **Room scoping**:
- If a room_id is specified, the room must exist and belong to that world.
- **Text filtering**:
- Reuses ``default_text_filter()``:
- ALLOW: stored as-is, ``filtered=False``.
- REDACT: starred out, ``filtered=True``.
- REJECT: rejected with 422 Unprocessable Entity.
- **Rate limiting**:
- ``rate_limit("chat_world_send", max_hits=30, window_seconds=60)``.
- **Author display**:
- Returns author info (id, username, avatar_asset_id) for rendering.
- **Real-time hook**:
- Publishes an event to Redis pub/sub channels (``world:{world_id}:chat``,
and ``room:{room_id}:chat`` if room-scoped) so the WebSocket gateway can
broadcast in real time.
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.chat import Message
from app.models.social import Friendship, FriendshipStatus
from app.models.user import User
from app.models.world import World, WorldVisibility
from app.models.world_content import WorldRoom
from app.rate_limit import rate_limit
from app.services.chat_pubsub import publish_world_chat_event
from app.services.text_filter import FilterAction, default_text_filter
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_text_filter = default_text_filter()
chat_world_send_rate_limit = rate_limit("chat_world_send", max_hits=30, window_seconds=60)
_BODY_MAX_LEN = 4000
# ──────────────────────────── Pydantic schemas ────────────────────────────
class SendWorldMessageRequest(BaseModel):
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN)
room_id: int | None = None
class AuthorInfo(BaseModel):
id: int | None
username: str
avatar_asset_id: int | None = None
model_config = {"from_attributes": True}
class WorldMessageResponse(BaseModel):
id: int
world_id: int
room_id: int | None
sender_id: int | None
author: AuthorInfo | None
body: str
filtered: bool
created_at: datetime
model_config = {"from_attributes": True}
class WorldMessageListResponse(BaseModel):
messages: list[WorldMessageResponse]
next_cursor: int | None = None
# ──────────────────────────────── Helpers ──────────────────────────────────
async def _can_access_world(db: AsyncSession, world: World, user_id: int) -> bool:
if world.visibility == WorldVisibility.PUBLIC:
return True
if world.owner_id == user_id:
return True
if world.visibility == WorldVisibility.FRIENDS_ONLY:
res = 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.scalar_one_or_none() is not None
return False
async def _get_world_and_check_access(db: AsyncSession, world_id: int, current_user_id: int) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if world is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
if not await _can_access_world(db, world, current_user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="You do not have access to this world"
)
return world
async def _get_room_and_check_access(
db: AsyncSession, room_id: int, current_user_id: int
) -> tuple[WorldRoom, World]:
result = await db.execute(select(WorldRoom).where(WorldRoom.id == room_id))
room = result.scalar_one_or_none()
if room is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
world = await _get_world_and_check_access(db, room.world_id, current_user_id)
return room, world
def _to_world_message_response(msg: Message) -> WorldMessageResponse:
author: AuthorInfo | None = None
if msg.sender is not None:
avatar_asset_id = None
if msg.sender.profile is not None:
avatar_asset_id = msg.sender.profile.avatar_asset_id
author = AuthorInfo(
id=msg.sender.id,
username=msg.sender.username,
avatar_asset_id=avatar_asset_id,
)
elif msg.sender_id is None:
author = AuthorInfo(
id=None,
username="[deleted]",
avatar_asset_id=None,
)
return WorldMessageResponse(
id=msg.id,
world_id=msg.world_id,
room_id=msg.room_id,
sender_id=msg.sender_id,
author=author,
body=msg.body,
filtered=msg.filtered,
created_at=msg.created_at,
)
# ──────────────────────────────── Endpoints ────────────────────────────────
@router.post(
"/world/{world_id}/messages",
response_model=WorldMessageResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(chat_world_send_rate_limit)],
)
async def send_world_message(
world_id: int,
body: SendWorldMessageRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Send a message to a world (and optional room) channel."""
world = await _get_world_and_check_access(db, world_id, current_user.id)
if body.room_id is not None:
result = await db.execute(select(WorldRoom).where(WorldRoom.id == body.room_id))
room = result.scalar_one_or_none()
if room is None or room.world_id != world.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Room not found in this world"
)
filter_res = _text_filter.check(body.body)
if filter_res.action is FilterAction.REJECT:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Message rejected by the content filter.",
)
msg = Message(
sender_id=current_user.id,
world_id=world.id,
room_id=body.room_id,
body=filter_res.filtered_text,
filtered=(filter_res.action is FilterAction.REDACT),
)
db.add(msg)
await db.commit()
stmt = (
select(Message)
.options(joinedload(Message.sender).joinedload(User.profile))
.where(Message.id == msg.id)
)
msg_loaded = (await db.execute(stmt)).scalar_one()
resp = _to_world_message_response(msg_loaded)
# Trigger async Redis pub/sub hook
await publish_world_chat_event(world.id, resp.model_dump(mode="json"), room_id=body.room_id)
return resp
@router.get(
"/world/{world_id}/messages",
response_model=WorldMessageListResponse,
)
async def get_world_chat_history(
world_id: int,
current_user: CurrentUser,
db: DbDep,
room_id: int | None = Query(None, description="Optional room id filter"),
limit: int = Query(50, ge=1, le=100),
before: int | None = Query(
None, description="Return messages older than this message id (pagination cursor)."
),
):
"""Paginated world chat history, newest first."""
world = await _get_world_and_check_access(db, world_id, current_user.id)
if room_id is not None:
result = await db.execute(select(WorldRoom).where(WorldRoom.id == room_id))
room = result.scalar_one_or_none()
if room is None or room.world_id != world.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Room not found in this world"
)
stmt = (
select(Message)
.options(joinedload(Message.sender).joinedload(User.profile))
.where(Message.world_id == world.id)
)
if room_id is not None:
stmt = stmt.where(Message.room_id == room_id)
if before is not None:
stmt = stmt.where(Message.id < before)
stmt = stmt.order_by(Message.id.desc()).limit(limit + 1)
rows = (await db.execute(stmt)).scalars().all()
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = page[-1].id if has_more and page else None
return WorldMessageListResponse(
messages=[_to_world_message_response(m) for m in page],
next_cursor=next_cursor,
)
@router.post(
"/room/{room_id}/messages",
response_model=WorldMessageResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(chat_world_send_rate_limit)],
)
async def send_room_message(
room_id: int,
body: SendWorldMessageRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Send a message directly addressed to a room channel."""
room, world = await _get_room_and_check_access(db, room_id, current_user.id)
forward_req = SendWorldMessageRequest(body=body.body, room_id=room.id)
return await send_world_message(world.id, forward_req, current_user, db)
@router.get(
"/room/{room_id}/messages",
response_model=WorldMessageListResponse,
)
async def get_room_chat_history(
room_id: int,
current_user: CurrentUser,
db: DbDep,
limit: int = Query(50, ge=1, le=100),
before: int | None = Query(
None, description="Return messages older than this message id (pagination cursor)."
),
):
"""Paginated room chat history, newest first."""
room, world = await _get_room_and_check_access(db, room_id, current_user.id)
return await get_world_chat_history(
world.id, current_user, db, room_id=room.id, limit=limit, before=before
)
+246
View File
@@ -0,0 +1,246 @@
"""Discovery router - visit tracking and world browsing.
docs/planning/discovery.md (tasks 42726e60fe10, 300da1dcddf3, 33706f401ad9).
"""
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import OptionalUser
from app.models.world import World, WorldVisibility
from app.models.world_visit import WorldVisit
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
class WorldOwnerSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class DiscoveredWorld(BaseModel):
id: int
name: str
description: str | None = None
category: str | None = None
tags: list[str] = []
play_count: int = 0
created_at: datetime | None = None
owner: WorldOwnerSummary
model_config = {"from_attributes": True}
class DiscoveryListResponse(BaseModel):
items: list[DiscoveredWorld]
total: int
skip: int
limit: int
class VisitRequest(BaseModel):
anonymous_token: str | None = None
idempotency_key: str | None = None
class VisitResponse(BaseModel):
play_count: int
last_played_at: datetime | None = None
model_config = {"from_attributes": True}
DEDUP_WINDOW_SECONDS = 60
@router.post("/worlds/{world_id}/visit", response_model=VisitResponse)
async def record_world_visit(
world_id: int,
request: Request,
db: DbDep,
current_user: OptionalUser = None,
visit_data: VisitRequest | None = None,
x_idempotency_key: Annotated[str | None, Header(alias="X-Idempotency-Key")] = None,
idempotency_key: str | None = Query(None),
):
"""Record a visit to a world.
- Increments worlds.play_count atomically (World.play_count + 1 in UPDATE).
- Updates worlds.last_played_at to func.now().
- Records row in world_visits (with world_id, user_id nullable, visited_at).
- Checks visibility: 404 if world not found or not PUBLIC and not owned by user.
- Idempotent: repeated calls within deduplication window or with same token/key do not duplicate.
"""
# 1. Fetch world
res = await db.execute(select(World).where(World.id == world_id))
world = res.scalar_one_or_none()
if not world:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
# 2. Check visibility: public or owned by requesting user
if world.visibility != WorldVisibility.PUBLIC:
if not current_user or world.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
# Determine token / idempotency key
explicit_key = (
(visit_data.idempotency_key if visit_data else None)
or (visit_data.anonymous_token if visit_data else None)
or x_idempotency_key
or idempotency_key
)
fallback_host = request.client.host if request.client else "unknown_client"
dedup_key = explicit_key or (f"client_{fallback_host}" if not current_user else None)
# 3. Deduplication check
cutoff = datetime.now(timezone.utc) - timedelta(seconds=DEDUP_WINDOW_SECONDS)
is_duplicate = False
if current_user:
dedup_res = await db.execute(
select(WorldVisit).where(
WorldVisit.world_id == world_id,
WorldVisit.user_id == current_user.id,
WorldVisit.visited_at >= cutoff,
).limit(1)
)
if dedup_res.scalar_one_or_none():
is_duplicate = True
elif dedup_key:
dedup_res = await db.execute(
select(WorldVisit).where(
WorldVisit.world_id == world_id,
WorldVisit.anonymous_token == dedup_key,
WorldVisit.visited_at >= cutoff,
).limit(1)
)
if dedup_res.scalar_one_or_none():
is_duplicate = True
if is_duplicate:
return VisitResponse(
play_count=world.play_count,
last_played_at=world.last_played_at,
)
# 4. Atomic increment and visit log in single transaction
await db.execute(
update(World)
.where(World.id == world_id)
.values(
play_count=World.play_count + 1,
last_played_at=func.now(),
)
)
visit = WorldVisit(
world_id=world_id,
user_id=current_user.id if current_user else None,
anonymous_token=dedup_key if not current_user else None,
)
db.add(visit)
await db.commit()
# Re-fetch or refresh to return updated fields
await db.refresh(world)
return VisitResponse(
play_count=world.play_count,
last_played_at=world.last_played_at,
)
@router.get("/categories", response_model=list[str])
async def list_categories(
db: DbDep,
):
"""List distinct categories of public worlds."""
res = await db.execute(
select(World.category)
.where(World.category.is_not(None), World.visibility == WorldVisibility.PUBLIC)
.distinct()
.order_by(World.category)
)
categories = [c for c in res.scalars().all() if c]
return categories
@router.get("/worlds", response_model=DiscoveryListResponse)
async def list_discovery_worlds(
db: DbDep,
current_user: OptionalUser = None,
q: str | None = Query(None, description="Substring search on world name"),
tags: list[str] | None = Query(None, description="Filter by tags"),
category: str | None = Query(None, description="Exact category filter"),
sort: str = Query("newest", description="Sort order (newest, most_played)"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""List public worlds for discovery with search, category/tag filters, and pagination."""
base_query = select(World).where(World.visibility == WorldVisibility.PUBLIC)
if q:
base_query = base_query.where(World.name.ilike(f"%{q}%"))
if category:
base_query = base_query.where(func.lower(World.category) == category.lower())
if tags:
parsed_tags = [t.strip() for item in tags for t in item.split(",") if t.strip()]
if parsed_tags:
base_query = base_query.where(World.tags.overlap(parsed_tags))
# Count total matching worlds
count_query = select(func.count()).select_from(base_query.subquery())
total = (await db.execute(count_query)).scalar_one()
# Sort
if sort == "most_played":
base_query = base_query.order_by(World.play_count.desc(), World.created_at.desc())
else: # default: newest
base_query = base_query.order_by(World.created_at.desc())
# Pagination & load owner
query = (
base_query
.offset(skip)
.limit(limit)
.options(selectinload(World.owner))
)
result = await db.execute(query)
worlds = result.scalars().all()
items = [
DiscoveredWorld(
id=w.id,
name=w.name,
description=w.description,
category=w.category,
tags=w.tags or [],
play_count=w.play_count,
created_at=w.created_at,
owner=WorldOwnerSummary(id=w.owner.id, username=w.owner.username),
)
for w in worlds
]
return DiscoveryListResponse(
items=items,
total=total,
skip=skip,
limit=limit,
)
+465
View File
@@ -0,0 +1,465 @@
"""Groups API router.
Endpoints:
- POST /api/groups -- create (creator becomes owner)
- GET /api/groups -- list groups (paginated), optional mine=true
- GET /api/groups/{id} -- group page (name/description/members)
- PATCH /api/groups/{id} -- update name/description (owner/admin)
- DELETE /api/groups/{id} -- delete (owner only)
- POST /api/groups/{id}/join -- join (idempotent)
- POST /api/groups/{id}/leave -- leave (owner must transfer first)
- POST /api/groups/{id}/members/{user_id}/role -- set role admin/member (owner only)
- POST /api/groups/{id}/transfer -- transfer ownership (owner only)
See app/models/groups.py for the schema and delete-behavior rationale
(docs/planning/social.md US-5). This module owns create/join/leave, role
authorization, and the group page aggregate.
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.groups import Group, GroupMember, GroupRole
from app.models.user import User
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_NAME_MAX_LEN = 100
_DESCRIPTION_MAX_LEN = 2000
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class GroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=_NAME_MAX_LEN)
description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN)
@field_validator("name")
@classmethod
def strip_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v
@field_validator("description")
@classmethod
def strip_description(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip() or None
return v
class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=_NAME_MAX_LEN)
description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN)
@field_validator("name")
@classmethod
def strip_name(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v
@field_validator("description")
@classmethod
def strip_description(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip() or None
return v
class GroupSummary(BaseModel):
id: int
name: str
description: str | None
owner_id: int | None
member_count: int
created_at: datetime
class GroupListResponse(BaseModel):
groups: list[GroupSummary]
total: int
class GroupMemberOut(BaseModel):
"""Public-safe member entry -- no email or other private User fields."""
user_id: int
username: str
role: GroupRole
joined_at: datetime
class GroupDetail(BaseModel):
id: int
name: str
description: str | None
owner_id: int | None
member_count: int
members: list[GroupMemberOut]
created_at: datetime
class RoleUpdateRequest(BaseModel):
role: GroupRole
@field_validator("role")
@classmethod
def only_admin_or_member(cls, v: GroupRole) -> GroupRole:
if v == GroupRole.OWNER:
raise ValueError(
"cannot set role to owner directly -- use POST /transfer"
)
return v
class TransferRequest(BaseModel):
new_owner_id: int
class MembershipOut(BaseModel):
group_id: int
user_id: int
role: GroupRole
joined_at: datetime
# ──────────────────────────────── Helpers ─────────────────────────────────────
async def _get_group_or_404(db: AsyncSession, group_id: int) -> Group:
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
return group
async def _get_membership(db: AsyncSession, group_id: int, user_id: int) -> GroupMember | None:
result = await db.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == user_id
)
)
return result.scalar_one_or_none()
async def _member_count(db: AsyncSession, group_id: int) -> int:
result = await db.execute(
select(func.count()).select_from(GroupMember).where(GroupMember.group_id == group_id)
)
return int(result.scalar_one())
def _require_role(membership: GroupMember | None, *allowed: GroupRole) -> None:
if membership is None or membership.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to perform this action",
)
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.post("", response_model=GroupDetail, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=GroupDetail, status_code=status.HTTP_201_CREATED, include_in_schema=False)
async def create_group(
body: GroupCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a group. The creator becomes its owner."""
group = Group(name=body.name, description=body.description, owner_id=current_user.id)
db.add(group)
await db.flush()
db.add(GroupMember(group_id=group.id, user_id=current_user.id, role=GroupRole.OWNER))
await db.commit()
await db.refresh(group)
return GroupDetail(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=1,
members=[
GroupMemberOut(
user_id=current_user.id,
username=current_user.username,
role=GroupRole.OWNER,
joined_at=group.created_at,
)
],
created_at=group.created_at,
)
@router.get("", response_model=GroupListResponse)
@router.get("/", response_model=GroupListResponse, include_in_schema=False)
async def list_groups(
current_user: CurrentUser,
db: DbDep,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
mine: bool = Query(False, description="If true, only groups the caller is a member of"),
):
"""List groups, paginated. With ``mine=true``, restrict to the caller's own groups."""
base_query = select(Group)
count_query = select(func.count()).select_from(Group)
if mine:
base_query = base_query.join(
GroupMember, GroupMember.group_id == Group.id
).where(GroupMember.user_id == current_user.id)
count_query = count_query.join(
GroupMember, GroupMember.group_id == Group.id
).where(GroupMember.user_id == current_user.id)
total = (await db.execute(count_query)).scalar_one()
result = await db.execute(
base_query.order_by(Group.created_at.desc()).offset(skip).limit(limit)
)
groups = result.scalars().all()
summaries = []
for group in groups:
summaries.append(
GroupSummary(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=await _member_count(db, group.id),
created_at=group.created_at,
)
)
return GroupListResponse(groups=summaries, total=total)
@router.get("/{group_id}", response_model=GroupDetail)
async def get_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Group page: name, description, member count, and member list with roles."""
group = await _get_group_or_404(db, group_id)
result = await db.execute(
select(GroupMember, User.username)
.join(User, User.id == GroupMember.user_id)
.where(GroupMember.group_id == group_id)
.order_by(GroupMember.joined_at.asc())
)
rows = result.all()
members = [
GroupMemberOut(
user_id=gm.user_id,
username=username,
role=gm.role,
joined_at=gm.joined_at,
)
for gm, username in rows
]
return GroupDetail(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=len(members),
members=members,
created_at=group.created_at,
)
@router.patch("/{group_id}", response_model=GroupDetail)
async def update_group(
group_id: int,
body: GroupUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update name/description. Owner/admin only."""
group = await _get_group_or_404(db, group_id)
membership = await _get_membership(db, group_id, current_user.id)
_require_role(membership, GroupRole.OWNER, GroupRole.ADMIN)
if body.name is not None:
group.name = body.name
if body.description is not None:
group.description = body.description if body.description else None
await db.commit()
await db.refresh(group)
return await get_group(group_id, current_user, db)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Delete a group. Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can delete this group",
)
await db.delete(group)
await db.commit()
return None
@router.post("/{group_id}/join", response_model=MembershipOut)
async def join_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Join a group. Idempotent -- joining an already-joined group is a no-op."""
await _get_group_or_404(db, group_id)
existing = await _get_membership(db, group_id, current_user.id)
if existing is not None:
return MembershipOut(
group_id=group_id,
user_id=current_user.id,
role=existing.role,
joined_at=existing.joined_at,
)
membership = GroupMember(group_id=group_id, user_id=current_user.id, role=GroupRole.MEMBER)
db.add(membership)
await db.commit()
await db.refresh(membership)
return MembershipOut(
group_id=group_id,
user_id=current_user.id,
role=membership.role,
joined_at=membership.joined_at,
)
@router.post("/{group_id}/leave", response_model=dict)
async def leave_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Leave a group. The owner cannot leave without transferring ownership first."""
await _get_group_or_404(db, group_id)
membership = await _get_membership(db, group_id, current_user.id)
if membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="You are not a member of this group",
)
if membership.role == GroupRole.OWNER:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Transfer ownership before leaving this group",
)
await db.delete(membership)
await db.commit()
return {"status": "ok"}
@router.post("/{group_id}/members/{user_id}/role", response_model=MembershipOut)
async def set_member_role(
group_id: int,
user_id: int,
body: RoleUpdateRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Set a member's role (admin/member). Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can change member roles",
)
target = await _get_membership(db, group_id, user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
if target.role == GroupRole.OWNER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Use POST /transfer to change ownership",
)
target.role = body.role
await db.commit()
await db.refresh(target)
return MembershipOut(
group_id=group_id, user_id=user_id, role=target.role, joined_at=target.joined_at
)
@router.post("/{group_id}/transfer", response_model=GroupDetail)
async def transfer_ownership(
group_id: int,
body: TransferRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Transfer group ownership to another existing member. Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can transfer ownership",
)
if body.new_owner_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You already own this group",
)
new_owner_membership = await _get_membership(db, group_id, body.new_owner_id)
if new_owner_membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="New owner must already be a member of this group",
)
current_owner_membership = await _get_membership(db, group_id, current_user.id)
new_owner_membership.role = GroupRole.OWNER
if current_owner_membership is not None:
current_owner_membership.role = GroupRole.ADMIN
group.owner_id = body.new_owner_id
await db.commit()
return await get_group(group_id, current_user, db)
+146
View File
@@ -0,0 +1,146 @@
"""Internal, dedicated-server-only content API.
Implements `docs/architecture/world_persistence_impl.md` §7 + §8.3. This is
how a spawned Godot dedicated server (`scripts/server.gd`, spawned by
`app.services.game_servers`) pulls its world's content read-only over HTTP
before binding its ENet port. Mounted at ``/api/internal`` -- a prefix that
can also be blocked at the reverse proxy, per the doc's rejected-alternative
note in §7.3.
Gated by ``require_game_server`` (§7.3): a bearer token minted per spawn by
``GameServerSupervisor._spawn`` and checked against
``supervisor.validate_token(world_id, token)``. There is no user identity
here at all -- these routes skip the per-user visibility check public
routes apply, and apply no mature-content gating (gating is a client/user
concern; the dedicated server still needs the geometry for collision).
Read-only by construction: there is no write route here, and adding one
would violate world_persistence_impl.md D2 (the FastAPI backend is the
only writer).
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.world import World
from app.models.world_content import MAIN_ROOM_SLUG
from app.services import chunk_codec
from app.services.game_servers import supervisor
from app.services.world_store import Bounds, WorldStore
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
async def require_game_server(
world_id: int,
authorization: Annotated[str | None, Header()] = None,
) -> None:
"""world_persistence_impl.md §7.3. A path whose ``world_id`` differs
from the token's world is 403 -- ``validate_token`` enforces that by
construction (it looks the token up *by* ``world_id``)."""
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
token = authorization.split(" ", 1)[1].strip()
if not token or not supervisor.validate_token(world_id, token):
raise HTTPException(status_code=403, detail="invalid or expired server token")
async def _get_world_or_404(world_id: int, db: AsyncSession) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if world is None:
raise HTTPException(status_code=404, detail="World not found")
return world
async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str):
room = await store.get_room(world_id, slug)
if room is None and slug == MAIN_ROOM_SLUG:
room = await store.get_or_create_main_room(world_id)
await db.commit()
return room
@router.get("/worlds/{world_id}/manifest", dependencies=[Depends(require_game_server)])
async def manifest(world_id: int, db: DbDep):
await _get_world_or_404(world_id, db)
store = WorldStore(db)
rooms = await store.list_rooms(world_id)
if not rooms:
rooms = [await store.get_or_create_main_room(world_id)]
await db.commit()
room_payloads = []
for r in rooms:
room_payloads.append({
"slug": r.slug, "name": r.name, "grid": r.grid, "bounds": r.bounds,
"spawn": r.spawn, "chunk_count": await store.count_chunks(r.id),
})
return {
"world_id": world_id,
"content_revision": await store.content_revision(world_id),
"rooms": room_payloads,
}
@router.get("/worlds/{world_id}/rooms/{slug}/chunks", dependencies=[Depends(require_game_server)])
async def internal_list_chunks(
world_id: int, slug: str, db: DbDep,
cx_min: int | None = None, cx_max: int | None = None,
cy_min: int | None = None, cy_max: int | None = None,
cz: int | None = None,
):
await _get_world_or_404(world_id, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz)
chunks = await store.list_chunks(world_id, room.id, bounds)
return {
"room": room.slug,
"content_revision": await store.content_revision(world_id),
"chunks": [
{
"key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz,
"revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len,
"is_mature": c.is_mature,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in chunks
],
}
@router.get(
"/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}",
dependencies=[Depends(require_game_server)],
)
async def internal_get_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, db: DbDep):
await _get_world_or_404(world_id, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
chunk = await store.get_chunk(key)
if chunk is None:
raise HTTPException(status_code=404, detail="chunk not found")
return Response(
content=chunk.payload, media_type="application/octet-stream",
headers={
"ETag": f'"{chunk.checksum}"', "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key,
},
)
+607
View File
@@ -0,0 +1,607 @@
"""Marketplace listings API router.
docs/planning/economy.md US-3 (task f51c7d95e237).
"""
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser, OptionalUser
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ItemType,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
from app.models.user import User
from app.services.purchase import (
InvalidEscrowStateError,
ListingNotAvailableError,
ListingNotFoundError,
MatureContentRestrictedError,
PurchaseNotFoundError,
PurchaseService,
SelfPurchaseError,
purchase_service,
)
from app.services.wallet import InsufficientFundsError
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def is_moderator_user(user: User) -> bool:
return (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
)
# ── Schemas ──
class CatalogItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=2000)
item_type: ItemType
asset_id: int | None = None
metadata_json: dict[str, Any] | None = None
class CatalogItemResponse(BaseModel):
id: int
owner_id: int
asset_id: int | None
item_type: ItemType
name: str
description: str | None
metadata_json: dict[str, Any] | None
created_at: datetime
model_config = {"from_attributes": True}
class ListingCreate(BaseModel):
catalog_item_id: int
price: int = Field(..., gt=0, description="Price in whole Credits (must be positive)")
class ListingUpdate(BaseModel):
price: int | None = Field(default=None, gt=0)
status: ListingStatus | None = None
class ListingResponse(BaseModel):
id: int
catalog_item_id: int
seller_id: int
price: int
status: ListingStatus
created_at: datetime
updated_at: datetime
catalog_item: CatalogItemResponse | None = None
seller_username: str | None = None
model_config = {"from_attributes": True}
class ListingListResponse(BaseModel):
listings: list[ListingResponse]
total: int
limit: int
offset: int
class PurchaseResponse(BaseModel):
id: int
listing_id: int | None
buyer_id: int
seller_id: int
price: int
platform_fee: int
seller_net: int
status: PurchaseStatus
escrow_hold_until: datetime | None
created_at: datetime
granted_item_id: int | None = None
model_config = {"from_attributes": True}
class CreatorBalanceResponse(BaseModel):
user_id: int
available: int
pending: int
lifetime_earned: int
updated_at: datetime
model_config = {"from_attributes": True}
class RefundRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ListingModerateRequest(BaseModel):
action: str = Field(..., pattern="^(delist|remove)$")
reason: str = Field(..., min_length=1, max_length=500)
class PurchaseReversalRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
# ── Catalog Items ──
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
async def create_catalog_item(
payload: CatalogItemCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new catalog item owned by the authenticated user."""
item = CatalogItem(
owner_id=current_user.id,
asset_id=payload.asset_id,
item_type=payload.item_type,
name=payload.name.strip(),
description=payload.description.strip() if payload.description else None,
metadata_json=payload.metadata_json,
)
db.add(item)
await db.commit()
await db.refresh(item)
return item
@router.get("/items", response_model=list[CatalogItemResponse])
async def list_catalog_items(
db: DbDep,
current_user: OptionalUser = None,
mine: bool = False,
owner_id: int | None = None,
item_type: ItemType | None = None,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List catalog items with optional filtering by owner and item type."""
stmt = select(CatalogItem)
if mine:
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required for mine=true")
stmt = stmt.where(CatalogItem.owner_id == current_user.id)
elif owner_id is not None:
stmt = stmt.where(CatalogItem.owner_id == owner_id)
if item_type is not None:
stmt = stmt.where(CatalogItem.item_type == item_type)
stmt = stmt.order_by(CatalogItem.created_at.desc(), CatalogItem.id.desc()).offset(offset).limit(limit)
res = await db.execute(stmt)
return list(res.scalars().all())
# ── Listings ──
@router.post("/listings", response_model=ListingResponse, status_code=status.HTTP_201_CREATED)
async def create_listing(
payload: ListingCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new marketplace listing for an owned catalog item."""
item = await db.get(CatalogItem, payload.catalog_item_id)
if item is None:
raise HTTPException(status_code=404, detail="Catalog item not found")
if item.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="You do not own this catalog item")
listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=current_user.id,
price=payload.price,
status=ListingStatus.active,
)
db.add(listing)
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=current_user.username,
)
@router.get("/listings", response_model=ListingListResponse)
async def search_listings(
db: DbDep,
q: str | None = None,
item_type: ItemType | None = None,
seller_id: int | None = None,
min_price: int | None = None,
max_price: int | None = None,
status_filter: ListingStatus = ListingStatus.active,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""Search and browse marketplace listings."""
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
)
if status_filter:
stmt = stmt.where(MarketplaceListing.status == status_filter)
if seller_id is not None:
stmt = stmt.where(MarketplaceListing.seller_id == seller_id)
if item_type is not None:
stmt = stmt.where(CatalogItem.item_type == item_type)
if min_price is not None:
stmt = stmt.where(MarketplaceListing.price >= min_price)
if max_price is not None:
stmt = stmt.where(MarketplaceListing.price <= max_price)
if q:
search_pattern = f"%{q.strip()}%"
stmt = stmt.where(
or_(
CatalogItem.name.ilike(search_pattern),
CatalogItem.description.ilike(search_pattern),
)
)
# Count
count_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
stmt = stmt.order_by(MarketplaceListing.created_at.desc(), MarketplaceListing.id.desc()).offset(offset).limit(limit)
rows = (await db.execute(stmt)).all()
listings = [
ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
for listing, item, uname in rows
]
return ListingListResponse(
listings=listings,
total=total,
limit=limit,
offset=offset,
)
@router.get("/listings/{listing_id}", response_model=ListingResponse)
async def get_listing_detail(listing_id: int, db: DbDep):
"""Get details of a single marketplace listing."""
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.patch("/listings/{listing_id}", response_model=ListingResponse)
async def update_listing(
listing_id: int,
payload: ListingUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update a listing's price or status (seller or admin only)."""
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="Not authorized to edit this listing")
if payload.price is not None:
listing.price = payload.price
if payload.status is not None:
listing.status = payload.status
listing.updated_at = datetime.now()
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.delete("/listings/{listing_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_or_delist_listing(
listing_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Delist or delete a listing (seller or admin only)."""
listing = await db.get(MarketplaceListing, listing_id)
if listing is None:
raise HTTPException(status_code=404, detail="Listing not found")
if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="Not authorized to delete this listing")
listing.status = ListingStatus.delisted
listing.updated_at = datetime.now()
await db.commit()
return None
# ── Purchases & Escrow ──
@router.post("/listings/{listing_id}/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED)
async def purchase_listing(
listing_id: int,
current_user: CurrentUser,
db: DbDep,
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
):
"""Purchase a marketplace listing atomically with escrow and fee split."""
try:
purchase, granted_item = await purchase_service.purchase_listing(
db=db,
buyer_id=current_user.id,
listing_id=listing_id,
buyer_is_adult=current_user.is_adult,
idempotency_key=idempotency_key,
)
except ListingNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except (ListingNotAvailableError, SelfPurchaseError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
except InsufficientFundsError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except MatureContentRestrictedError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
resp = PurchaseResponse.model_validate(purchase)
resp.granted_item_id = granted_item.id
return resp
@router.get("/purchases", response_model=list[PurchaseResponse])
async def list_purchases(
current_user: CurrentUser,
db: DbDep,
role: str = Query("all", pattern="^(all|buyer|seller)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List purchases where the authenticated user is the buyer or seller."""
stmt = select(Purchase)
if role == "buyer":
stmt = stmt.where(Purchase.buyer_id == current_user.id)
elif role == "seller":
stmt = stmt.where(Purchase.seller_id == current_user.id)
else:
stmt = stmt.where(
or_(Purchase.buyer_id == current_user.id, Purchase.seller_id == current_user.id)
)
stmt = stmt.order_by(Purchase.created_at.desc(), Purchase.id.desc()).offset(offset).limit(limit)
res = await db.execute(stmt)
return list(res.scalars().all())
@router.get("/purchases/{purchase_id}", response_model=PurchaseResponse)
async def get_purchase(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Get details of a purchase record."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
if (
purchase.buyer_id != current_user.id
and purchase.seller_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to view this purchase")
return purchase
@router.post("/purchases/{purchase_id}/release", response_model=PurchaseResponse)
async def release_escrow_endpoint(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Release escrow hold for a purchase to seller's available balance."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
# Only seller, buyer, or admin can trigger release
if (
purchase.seller_id != current_user.id
and purchase.buyer_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to release escrow")
try:
updated = await purchase_service.release_escrow(db, purchase_id)
return updated
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/purchases/{purchase_id}/refund", response_model=PurchaseResponse)
async def refund_purchase_endpoint(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
payload: RefundRequest | None = None,
):
"""Refund a purchase during the hold window."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
# Seller or admin can refund, or buyer if within hold window
if (
purchase.seller_id != current_user.id
and purchase.buyer_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to refund this purchase")
reason = payload.reason if payload else None
try:
refunded = await purchase_service.refund_purchase(db, purchase_id, reason=reason)
return refunded
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# ── Creator Balance ──
@router.get("/creator/balance", response_model=CreatorBalanceResponse)
async def get_my_creator_balance(
current_user: CurrentUser,
db: DbDep,
):
"""Get the current user's creator balance (available, pending, lifetime_earned)."""
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
return cb
# ── Moderation ──
@router.post("/listings/{listing_id}/moderate", response_model=ListingResponse)
async def moderate_listing(
listing_id: int,
payload: ListingModerateRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator action to delist or remove a listing with reason."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
if payload.action == "remove":
listing.status = ListingStatus.removed_by_moderation
else:
listing.status = ListingStatus.delisted
listing.updated_at = datetime.now()
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.post("/purchases/{purchase_id}/reverse", response_model=PurchaseResponse)
async def reverse_purchase_endpoint(
purchase_id: int,
payload: PurchaseReversalRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator/admin audited reversal of purchase."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
try:
reversed_purchase = await purchase_service.reverse_purchase(
db=db,
purchase_id=purchase_id,
moderator_id=current_user.id,
reason=payload.reason,
)
return reversed_purchase
except PurchaseNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+106
View File
@@ -0,0 +1,106 @@
"""Payments API endpoints for purchasing Credits.
docs/planning/economy.md US-2 (task 575fd14b5360).
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.wallet import TransactionType
from app.services.payment import ALLOWED_CREDIT_PACKS, payment_provider
from app.services.wallet import wallet_service
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
class PurchaseRequest(BaseModel):
credits: int = Field(..., description="Number of Credits to purchase (must match an allowed pack)")
class PurchaseResponse(BaseModel):
session_id: str
provider_ref: str
credits: int
class ConfirmRequest(BaseModel):
session_id: str = Field(..., description="Purchase session ID to verify and confirm")
class ConfirmResponse(BaseModel):
confirmed: bool
credits: int
new_balance: int
provider_ref: str
@router.post("/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED)
async def create_purchase_session(
payload: PurchaseRequest,
current_user: CurrentUser,
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
):
"""Initiate a Credit purchase session via the payment provider."""
if payload.credits not in ALLOWED_CREDIT_PACKS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid credit pack: {payload.credits}. Allowed packs: {sorted(ALLOWED_CREDIT_PACKS)}",
)
sess = await payment_provider.create_purchase(
user_id=current_user.id,
credits=payload.credits,
idempotency_key=idempotency_key,
)
return PurchaseResponse(
session_id=sess.session_id,
provider_ref=sess.provider_ref,
credits=sess.credits,
)
@router.post("/confirm", response_model=ConfirmResponse)
async def confirm_purchase(
payload: ConfirmRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Verify purchase session with provider and credit the user's wallet.
Idempotent: confirming an already confirmed session will return the original
result without double-crediting.
"""
conf = await payment_provider.confirm(payload.session_id)
if not conf.confirmed or conf.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payment confirmation failed, invalid session, or unauthorized.",
)
# Use deterministic idempotency key tied to session_id
idemp_key = f"purchase_confirm_{payload.session_id}"
wallet, tx = await wallet_service.credit(
db,
user_id=current_user.id,
amount=conf.credits,
tx_type=TransactionType.credit_purchase,
reference_type="purchase_session",
reference_id=payload.session_id,
idempotency_key=idemp_key,
metadata={"provider_ref": conf.provider_ref},
)
return ConfirmResponse(
confirmed=True,
credits=conf.credits,
new_balance=wallet.balance,
provider_ref=conf.provider_ref,
)
+178
View File
@@ -0,0 +1,178 @@
"""Creator payouts router: requests, listings, and admin review.
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.marketplace import PayoutRequest, PayoutStatus
from app.models.user import User
from app.services.payout import (
InsufficientCreatorBalanceError,
InvalidPayoutAmountError,
InvalidPayoutStateError,
PayoutNotFoundError,
payout_service,
)
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def is_admin_user(user: User) -> bool:
return (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
)
class PayoutCreate(BaseModel):
amount: int = Field(..., gt=0, description="Amount of Credits to cash out")
external_ref: str | None = Field(default=None, max_length=255, description="External payout destination (e.g. PayPal email / IBAN)")
class PayoutResponse(BaseModel):
id: int
creator_id: int
amount: int
status: PayoutStatus
external_ref: str | None
provider_ref: str | None
requested_by: int
reviewed_by: int | None
reviewed_at: datetime | None
created_at: datetime
model_config = {"from_attributes": True}
class PayoutRejectRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500)
@router.post("", response_model=PayoutResponse, status_code=status.HTTP_201_CREATED)
async def request_payout(
payload: PayoutCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Request a payout of earned creator balance."""
try:
req = await payout_service.request_payout(
db=db,
creator_id=current_user.id,
amount=payload.amount,
external_ref=payload.external_ref,
requested_by=current_user.id,
)
return req
except InvalidPayoutAmountError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except InsufficientCreatorBalanceError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("", response_model=list[PayoutResponse])
async def list_payouts(
current_user: CurrentUser,
db: DbDep,
creator_id: int | None = None,
status_filter: PayoutStatus | None = None,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List payout requests. Regular creators can only view their own."""
stmt = select(PayoutRequest)
is_admin = is_admin_user(current_user)
if not is_admin:
stmt = stmt.where(PayoutRequest.creator_id == current_user.id)
else:
if creator_id is not None:
stmt = stmt.where(PayoutRequest.creator_id == creator_id)
if status_filter is not None:
stmt = stmt.where(PayoutRequest.status == status_filter)
stmt = stmt.order_by(PayoutRequest.created_at.desc(), PayoutRequest.id.desc()).offset(offset).limit(limit)
res = await db.execute(stmt)
return list(res.scalars().all())
@router.get("/{payout_id}", response_model=PayoutResponse)
async def get_payout(
payout_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Get details of a single payout request."""
payout = await db.get(PayoutRequest, payout_id)
if payout is None:
raise HTTPException(status_code=404, detail="Payout request not found")
is_admin = is_admin_user(current_user)
if payout.creator_id != current_user.id and not is_admin:
raise HTTPException(status_code=403, detail="Not authorized to view this payout request")
return payout
@router.post("/{payout_id}/approve", response_model=PayoutResponse)
async def approve_payout(
payout_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Admin review: approve payout and disburse funds via provider."""
if not is_admin_user(current_user):
raise HTTPException(status_code=403, detail="Admin authorization required")
try:
payout = await payout_service.approve_and_pay(
db=db,
payout_id=payout_id,
reviewer_id=current_user.id,
)
return payout
except PayoutNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidPayoutStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/{payout_id}/reject", response_model=PayoutResponse)
async def reject_payout(
payout_id: int,
current_user: CurrentUser,
db: DbDep,
payload: PayoutRejectRequest | None = None,
):
"""Admin review: reject payout request and restore creator's available balance."""
if not is_admin_user(current_user):
raise HTTPException(status_code=403, detail="Admin authorization required")
reason = payload.reason if payload else None
try:
payout = await payout_service.reject_payout(
db=db,
payout_id=payout_id,
reviewer_id=current_user.id,
reason=reason,
)
return payout
except PayoutNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidPayoutStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+146
View File
@@ -0,0 +1,146 @@
"""Presence & status router.
Endpoints:
- POST /api/presence/heartbeat -- record a heartbeat (sets Redis TTL key)
- GET /api/presence/presence -- query presence for a list of user ids
- PATCH /api/presence/status -- update the authenticated user's status message
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User
from app.services.presence import presence_store
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# ──────────────────────────── Pydantic schemas ────────────────────────────────
_STATUS_MAX_LEN = 140
class HeartbeatRequest(BaseModel):
"""Empty body -- the caller's identity comes from the JWT."""
class PresenceUser(BaseModel):
user_id: int
is_online: bool
last_seen_at: datetime | None
status_message: str | None
class PresenceResponse(BaseModel):
users: list[PresenceUser]
class StatusUpdate(BaseModel):
status_message: str | None = Field(default=None, max_length=_STATUS_MAX_LEN)
@field_validator("status_message")
@classmethod
def strip_message(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
return v
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.post("/heartbeat", response_model=dict)
async def heartbeat(
_req: HeartbeatRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Record a heartbeat for the authenticated user.
Sets a Redis key with a TTL so the server can derive online/offline
status without trusting client claims. Also persists last_seen_at to
the database.
"""
# Update Redis TTL (presence heartbeat)
await presence_store.heartbeat(current_user.id)
# Persist last_seen_at to the database
now = datetime.now(timezone.utc)
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user:
user.last_seen_at = now
await db.commit()
return {"status": "ok", "user_id": current_user.id}
@router.get("/presence", response_model=PresenceResponse)
async def get_presence(
user_ids: str,
current_user: CurrentUser,
db: DbDep,
):
"""Return presence info for a comma-separated list of user ids.
Online/offline is derived from the Redis heartbeat TTL.
last_seen_at and status_message come from the database.
"""
ids = [int(uid.strip()) for uid in user_ids.split(",") if uid.strip()]
if not ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="user_ids must contain at least one integer",
)
# Check Redis for online status (single round trip via MGET).
online_ids = await presence_store.is_online_batch(ids)
# Fetch DB records for last_seen_at and status_message
result = await db.execute(select(User).where(User.id.in_(ids)))
users = {u.id: u for u in result.scalars().all()}
presence_users = []
for uid in ids:
user = users.get(uid)
if user is None:
continue
presence_users.append(
PresenceUser(
user_id=uid,
is_online=uid in online_ids,
last_seen_at=user.last_seen_at,
status_message=user.status_message,
)
)
return PresenceResponse(users=presence_users)
@router.patch("/status", response_model=dict)
async def update_status(
body: StatusUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update the authenticated user's status message (length-limited)."""
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user.status_message = body.status_message
await db.commit()
return {"status": "ok", "status_message": body.status_message}
+365
View File
@@ -0,0 +1,365 @@
"""Profile router -- the authenticated user's own profile + favorites.
Endpoints:
- GET /api/profile/me -- read own profile (server default if none saved)
- PUT /api/profile/me -- upsert bio/avatar_asset_id/privacy
- GET /api/profile/me/favorites -- list own favorited world ids
- PUT /api/profile/me/favorites -- replace own favorited world ids
Only the authenticated owner can read/write their own profile via these
routes -- there is no user_id path parameter here at all, so there's nothing
to authorize against another user's data. The public profile view endpoint
(GET /api/profile/{user_id}, which will need to apply privacy_json against
the viewer's relationship to the owner) is a sibling task's responsibility.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset
from app.models.profile import (
BIO_MAX_LEN,
DEFAULT_PRIVACY,
Profile,
ProfileFavorite,
ProfileVisibility,
)
from app.models.social import Friendship, Block, FriendshipStatus
from app.models.user import User
from app.models.world import World
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_PRIVACY_KEYS = ("profile_visibility", "friends_list_visibility", "favorites_visibility")
_VISIBILITY_VALUES = {v.value for v in ProfileVisibility}
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class PublicProfileWorld(BaseModel):
id: int
name: str
model_config = {"from_attributes": True}
class PublicProfileUser(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class PublicProfileResponse(BaseModel):
user_id: int
username: str
avatar_asset_id: int | None = None
bio: str | None = None
favorites: list[PublicProfileWorld] = Field(default_factory=list)
friends: list[PublicProfileUser] = Field(default_factory=list)
is_restricted: bool = False
class ProfileResponse(BaseModel):
user_id: int
avatar_asset_id: int | None
bio: str | None
privacy: dict[str, str]
is_default: bool = Field(
description="True when the user has never saved a profile and this "
"is the server default (no bio/avatar, default-open privacy)."
)
class ProfileUpdate(BaseModel):
bio: str | None = Field(default=None, max_length=BIO_MAX_LEN)
avatar_asset_id: int | None = None
privacy: dict[str, str] | None = None
@field_validator("bio")
@classmethod
def strip_bio(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
return v
class FavoritesResponse(BaseModel):
world_ids: list[int]
class FavoritesUpdate(BaseModel):
world_ids: list[int]
# ──────────────────────────────── Helpers ─────────────────────────────────────
def _validate_privacy(privacy: dict[str, str]) -> dict[str, str]:
"""Validate privacy dict has exactly the allowed keys, each a valid
ProfileVisibility value. Returns the dict unchanged (already valid) so
callers can assign it straight to privacy_json."""
extra = set(privacy.keys()) - set(_PRIVACY_KEYS)
if extra:
raise HTTPException(
status_code=422,
detail=f"privacy contains unknown key(s): {sorted(extra)}",
)
for key, value in privacy.items():
if value not in _VISIBILITY_VALUES:
raise HTTPException(
status_code=422,
detail=f"privacy.{key} must be one of {sorted(_VISIBILITY_VALUES)}, got {value!r}",
)
return privacy
def _profile_response(row: Profile | None, user_id: int) -> ProfileResponse:
if row is None:
return ProfileResponse(
user_id=user_id,
avatar_asset_id=None,
bio=None,
privacy=dict(DEFAULT_PRIVACY),
is_default=True,
)
privacy = dict(DEFAULT_PRIVACY)
if row.privacy_json:
privacy.update(row.privacy_json)
return ProfileResponse(
user_id=row.user_id,
avatar_asset_id=row.avatar_asset_id,
bio=row.bio,
privacy=privacy,
is_default=False,
)
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.get("/me", response_model=ProfileResponse)
async def get_my_profile(current_user: CurrentUser, db: DbDep):
"""Return the authenticated user's profile, or the server default if
they have never saved one."""
row = await db.get(Profile, current_user.id)
return _profile_response(row, current_user.id)
@router.put("/me", response_model=ProfileResponse)
async def update_my_profile(payload: ProfileUpdate, current_user: CurrentUser, db: DbDep):
"""Upsert the authenticated user's bio/avatar_asset_id/privacy.
Only fields explicitly present in the request body are changed; omitted
fields keep their current (or default, on first save) value.
"""
fields_set = payload.model_fields_set
if "avatar_asset_id" in fields_set and payload.avatar_asset_id is not None:
asset = await db.get(Asset, payload.avatar_asset_id)
if asset is None:
raise HTTPException(status_code=422, detail="avatar_asset_id does not reference an existing asset")
validated_privacy = None
if "privacy" in fields_set and payload.privacy is not None:
validated_privacy = _validate_privacy(payload.privacy)
row = await db.get(Profile, current_user.id)
if row is None:
row = Profile(
user_id=current_user.id,
bio=payload.bio if "bio" in fields_set else None,
avatar_asset_id=payload.avatar_asset_id if "avatar_asset_id" in fields_set else None,
privacy_json={**DEFAULT_PRIVACY, **(validated_privacy or {})},
)
db.add(row)
else:
if "bio" in fields_set:
row.bio = payload.bio
if "avatar_asset_id" in fields_set:
row.avatar_asset_id = payload.avatar_asset_id
if validated_privacy is not None:
merged = dict(row.privacy_json or DEFAULT_PRIVACY)
merged.update(validated_privacy)
row.privacy_json = merged
await db.commit()
await db.refresh(row)
return _profile_response(row, current_user.id)
@router.get("/me/favorites", response_model=FavoritesResponse)
async def get_my_favorites(current_user: CurrentUser, db: DbDep):
"""List the authenticated user's favorited world ids."""
result = await db.execute(
select(ProfileFavorite.world_id).where(ProfileFavorite.user_id == current_user.id)
)
return FavoritesResponse(world_ids=[wid for wid in result.scalars().all()])
@router.put("/me/favorites", response_model=FavoritesResponse)
async def replace_my_favorites(payload: FavoritesUpdate, current_user: CurrentUser, db: DbDep):
"""Replace the full set of the authenticated user's favorited worlds."""
world_ids = list(dict.fromkeys(payload.world_ids)) # de-dupe, preserve order
if world_ids:
result = await db.execute(select(World.id).where(World.id.in_(world_ids)))
existing_ids = set(result.scalars().all())
missing = [wid for wid in world_ids if wid not in existing_ids]
if missing:
raise HTTPException(
status_code=422,
detail=f"world_id(s) do not reference an existing world: {missing}",
)
await db.execute(
ProfileFavorite.__table__.delete().where(ProfileFavorite.user_id == current_user.id)
)
for wid in world_ids:
db.add(ProfileFavorite(user_id=current_user.id, world_id=wid))
await db.commit()
return FavoritesResponse(world_ids=world_ids)
@router.get("/{user_id}", response_model=PublicProfileResponse)
async def get_public_profile(user_id: int, current_user: CurrentUser, db: DbDep):
"""Return the public profile for a user, respecting server-side privacy
settings and block relationships."""
user_res = await db.execute(select(User).where(User.id == user_id))
target_user = user_res.scalar_one_or_none()
if target_user is None or not target_user.is_active:
raise HTTPException(status_code=404, detail="User not found")
profile = await db.get(Profile, user_id)
avatar_asset = None
if profile and profile.avatar_asset_id:
avatar_asset = await db.get(Asset, profile.avatar_asset_id)
privacy = dict(DEFAULT_PRIVACY)
if profile and profile.privacy_json:
privacy.update(profile.privacy_json)
is_self = current_user.id == user_id
is_blocked = False
is_friend = False
if not is_self:
block_res = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == current_user.id, Block.blocked_id == user_id),
and_(Block.blocker_id == user_id, Block.blocked_id == current_user.id),
)
)
)
is_blocked = block_res.scalar_one_or_none() is not None
if not is_blocked:
friend_res = await db.execute(
select(Friendship.id).where(
or_(
and_(Friendship.requester_id == current_user.id, Friendship.addressee_id == user_id),
and_(Friendship.requester_id == user_id, Friendship.addressee_id == current_user.id),
),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
is_friend = friend_res.scalar_one_or_none() is not None
profile_vis = privacy.get("profile_visibility", ProfileVisibility.PUBLIC.value)
if profile_vis is False:
profile_vis = ProfileVisibility.PRIVATE.value
elif profile_vis is True:
profile_vis = ProfileVisibility.PUBLIC.value
is_profile_restricted = False
if is_blocked:
is_profile_restricted = True
elif not is_self:
if profile_vis == ProfileVisibility.PRIVATE.value:
is_profile_restricted = True
elif profile_vis == ProfileVisibility.FRIENDS.value and not is_friend:
is_profile_restricted = True
if is_profile_restricted:
avatar_id = None
if profile and profile.avatar_asset_id:
if avatar_asset and avatar_asset.is_public:
avatar_id = profile.avatar_asset_id
return PublicProfileResponse(
user_id=target_user.id,
username=target_user.username,
avatar_asset_id=avatar_id,
bio=None,
favorites=[],
friends=[],
is_restricted=True,
)
avatar_id = profile.avatar_asset_id if profile else None
bio = profile.bio if profile else None
fav_vis = privacy.get("favorites_visibility", ProfileVisibility.PUBLIC.value)
if fav_vis is False:
fav_vis = ProfileVisibility.PRIVATE.value
elif fav_vis is True:
fav_vis = ProfileVisibility.PUBLIC.value
can_see_favorites = is_self or (fav_vis == ProfileVisibility.PUBLIC.value) or (fav_vis == ProfileVisibility.FRIENDS.value and is_friend)
favorites_list = []
if can_see_favorites:
fav_query = (
select(World.id, World.name)
.join(ProfileFavorite, ProfileFavorite.world_id == World.id)
.where(ProfileFavorite.user_id == user_id)
.order_by(ProfileFavorite.created_at.desc())
)
fav_res = await db.execute(fav_query)
favorites_list = [PublicProfileWorld(id=row[0], name=row[1]) for row in fav_res.all()]
fr_vis = privacy.get("friends_list_visibility", ProfileVisibility.PUBLIC.value)
if fr_vis is False:
fr_vis = ProfileVisibility.PRIVATE.value
elif fr_vis is True:
fr_vis = ProfileVisibility.PUBLIC.value
can_see_friends = is_self or (fr_vis == ProfileVisibility.PUBLIC.value) or (fr_vis == ProfileVisibility.FRIENDS.value and is_friend)
friends_list = []
if can_see_friends:
fr_res = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = fr_res.scalars().all()
other_ids = [
fr.addressee_id if fr.requester_id == user_id else fr.requester_id
for fr in friendships
]
if other_ids:
users_res = await db.execute(
select(User.id, User.username).where(User.id.in_(other_ids), User.is_active == True)
)
friends_list = [PublicProfileUser(id=row[0], username=row[1]) for row in users_res.all()]
return PublicProfileResponse(
user_id=target_user.id,
username=target_user.username,
avatar_asset_id=avatar_id,
bio=bio,
favorites=favorites_list,
friends=friends_list,
is_restricted=False,
)
+362
View File
@@ -0,0 +1,362 @@
"""Friends/follow/block router.
docs/planning/social.md §4/§5 (US-1). Builds the request/accept/block
business logic on top of the models in app/models/social.py (already
merged): friend requests are directional (requester -> addressee) until
accepted, at which point the relationship is treated as mutual; follow is
one-way with no approval step; block is a hard deny that clears any
existing friendship/follow between the two users and is queryable so a
later layer (e.g. chat) can check it -- enforcing the block anywhere but
here is out of scope for this router.
Endpoints:
- POST /api/friends/requests -- send a friend request
- GET /api/friends/requests -- list incoming (pending) requests
- POST /api/friends/requests/{id}/accept -- accept an incoming request
- POST /api/friends/requests/{id}/decline -- decline an incoming request
- DELETE /api/friends/{user_id} -- unfriend
- POST /api/friends/{user_id}/follow -- follow a user
- DELETE /api/friends/{user_id}/follow -- unfollow a user
- POST /api/friends/{user_id}/block -- block a user
- DELETE /api/friends/{user_id}/block -- unblock a user
- GET /api/friends -- list friends
- GET /api/friends/following -- list users followed
- GET /api/friends/blocked -- list blocked users
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel
from sqlalchemy import select, delete, or_, and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.user import User
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class FriendRequestCreate(BaseModel):
user_id: int
class FriendRequestResponse(BaseModel):
id: int
requester_id: int
addressee_id: int
status: FriendshipStatus
created_at: datetime
model_config = {"from_attributes": True}
class UserSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class FriendRequestListResponse(BaseModel):
requests: list[FriendRequestResponse]
class FriendListResponse(BaseModel):
friends: list[UserSummary]
class FollowListResponse(BaseModel):
following: list[UserSummary]
class BlockedListResponse(BaseModel):
blocked: list[UserSummary]
class ActionResponse(BaseModel):
status: str
# ──────────────────────────────── Helpers ──────────────────────────────────────
def _friendship_pair(a: int, b: int):
"""SQLAlchemy predicate matching a Friendship row between a and b,
regardless of which one is the requester/addressee."""
return or_(
and_(Friendship.requester_id == a, Friendship.addressee_id == b),
and_(Friendship.requester_id == b, Friendship.addressee_id == a),
)
def _follow_pair(a: int, b: int):
"""Predicate matching a Follow row in either direction between a and b."""
return or_(
and_(Follow.follower_id == a, Follow.followed_id == b),
and_(Follow.follower_id == b, Follow.followed_id == a),
)
async def _is_blocked_either_direction(db: AsyncSession, a: int, b: int) -> bool:
result = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == a, Block.blocked_id == b),
and_(Block.blocker_id == b, Block.blocked_id == a),
)
)
)
return result.scalar_one_or_none() is not None
async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
# ──────────────────────────────── Requests ─────────────────────────────────────
@router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED)
async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep):
"""Send a friend request to another user."""
target_id = body.user_id
if target_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself")
await _get_user_or_404(db, target_id)
if await _is_blocked_either_direction(db, current_user.id, target_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send a friend request to this user")
existing = await db.execute(select(Friendship).where(_friendship_pair(current_user.id, target_id)))
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
fr = Friendship(requester_id=current_user.id, addressee_id=target_id)
db.add(fr)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
await db.refresh(fr)
return fr
@router.get("/requests", response_model=FriendRequestListResponse)
async def list_incoming_requests(current_user: CurrentUser, db: DbDep):
"""List pending friend requests sent *to* the authenticated user."""
result = await db.execute(
select(Friendship).where(
Friendship.addressee_id == current_user.id,
Friendship.status == FriendshipStatus.PENDING,
)
)
return FriendRequestListResponse(requests=list(result.scalars().all()))
@router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse)
async def accept_friend_request(request_id: int, current_user: CurrentUser, db: DbDep):
"""Accept an incoming friend request, making the friendship mutual."""
result = await db.execute(select(Friendship).where(Friendship.id == request_id))
fr = result.scalar_one_or_none()
# 404 (not a 403) both when the row doesn't exist and when it isn't
# addressed to the caller, so ids can't be enumerated to probe other
# accounts' pending requests.
if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found")
fr.status = FriendshipStatus.ACCEPTED
await db.commit()
await db.refresh(fr)
return fr
@router.post("/requests/{request_id}/decline", response_model=ActionResponse)
async def decline_friend_request(request_id: int, current_user: CurrentUser, db: DbDep):
"""Decline (and remove) an incoming friend request."""
result = await db.execute(select(Friendship).where(Friendship.id == request_id))
fr = result.scalar_one_or_none()
if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found")
await db.delete(fr)
await db.commit()
return ActionResponse(status="declined")
# ──────────────────────────────── Friends ──────────────────────────────────────
@router.get("", response_model=FriendListResponse)
async def list_friends(current_user: CurrentUser, db: DbDep):
"""List the authenticated user's accepted friends."""
result = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == current_user.id, Friendship.addressee_id == current_user.id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = result.scalars().all()
other_ids = [
fr.addressee_id if fr.requester_id == current_user.id else fr.requester_id
for fr in friendships
]
if not other_ids:
return FriendListResponse(friends=[])
users_result = await db.execute(select(User).where(User.id.in_(other_ids)))
return FriendListResponse(friends=list(users_result.scalars().all()))
@router.delete("/{user_id}", response_model=ActionResponse)
async def unfriend(user_id: int, current_user: CurrentUser, db: DbDep):
"""Remove an existing (accepted) friendship."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfriend yourself")
result = await db.execute(
select(Friendship).where(
_friendship_pair(current_user.id, user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
fr = result.scalar_one_or_none()
if fr is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not friends with this user")
await db.delete(fr)
await db.commit()
return ActionResponse(status="unfriended")
# ──────────────────────────────── Follow ───────────────────────────────────────
@router.post("/{user_id}/follow", response_model=ActionResponse, status_code=status.HTTP_201_CREATED)
async def follow_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Follow another user (one-way, no approval)."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot follow yourself")
await _get_user_or_404(db, user_id)
if await _is_blocked_either_direction(db, current_user.id, user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot follow this user")
existing = await db.execute(
select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user")
db.add(Follow(follower_id=current_user.id, followed_id=user_id))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user")
return ActionResponse(status="followed")
@router.delete("/{user_id}/follow", response_model=ActionResponse)
async def unfollow_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Unfollow a user."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfollow yourself")
result = await db.execute(
select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id)
)
follow = result.scalar_one_or_none()
if follow is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not following this user")
await db.delete(follow)
await db.commit()
return ActionResponse(status="unfollowed")
@router.get("/following", response_model=FollowListResponse)
async def list_following(current_user: CurrentUser, db: DbDep):
"""List users the authenticated user follows."""
result = await db.execute(select(Follow).where(Follow.follower_id == current_user.id))
followed_ids = [f.followed_id for f in result.scalars().all()]
if not followed_ids:
return FollowListResponse(following=[])
users_result = await db.execute(select(User).where(User.id.in_(followed_ids)))
return FollowListResponse(following=list(users_result.scalars().all()))
# ──────────────────────────────── Block ────────────────────────────────────────
@router.post("/{user_id}/block", response_model=ActionResponse, status_code=status.HTTP_201_CREATED)
async def block_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Block a user: hard deny that also clears any existing friendship/follow
between the two users in either direction."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot block yourself")
await _get_user_or_404(db, user_id)
existing = await db.execute(
select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked")
db.add(Block(blocker_id=current_user.id, blocked_id=user_id))
await db.execute(delete(Friendship).where(_friendship_pair(current_user.id, user_id)))
await db.execute(delete(Follow).where(_follow_pair(current_user.id, user_id)))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked")
return ActionResponse(status="blocked")
@router.delete("/{user_id}/block", response_model=ActionResponse)
async def unblock_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Remove a block."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unblock yourself")
result = await db.execute(
select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id)
)
block = result.scalar_one_or_none()
if block is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User is not blocked")
await db.delete(block)
await db.commit()
return ActionResponse(status="unblocked")
@router.get("/blocked", response_model=BlockedListResponse)
async def list_blocked(current_user: CurrentUser, db: DbDep):
"""List users the authenticated user has blocked."""
result = await db.execute(select(Block).where(Block.blocker_id == current_user.id))
blocked_ids = [b.blocked_id for b in result.scalars().all()]
if not blocked_ids:
return BlockedListResponse(blocked=[])
users_result = await db.execute(select(User).where(User.id.in_(blocked_ids)))
return BlockedListResponse(blocked=list(users_result.scalars().all()))
+78
View File
@@ -0,0 +1,78 @@
"""Wallet & transactions API endpoints.
docs/planning/economy.md US-1 (task 909e3e9a59af).
"""
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.wallet import TransactionType
from app.services.wallet import wallet_service
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
class WalletResponse(BaseModel):
id: int
user_id: int
balance: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TransactionResponse(BaseModel):
id: int
wallet_id: int
type: TransactionType
amount: int
balance_after: int
reference_type: str | None = None
reference_id: str | None = None
idempotency_key: str | None = None
metadata_json: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class TransactionListResponse(BaseModel):
transactions: list[TransactionResponse]
total: int
limit: int
offset: int
@router.get("", response_model=WalletResponse)
@router.get("/", response_model=WalletResponse)
async def get_wallet(current_user: CurrentUser, db: DbDep):
"""Retrieve the current user's wallet balance and metadata."""
wallet = await wallet_service.get_or_create_wallet(db, current_user.id)
return wallet
@router.get("/transactions", response_model=TransactionListResponse)
async def get_wallet_transactions(
current_user: CurrentUser,
db: DbDep,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List the current user's ledger transactions, ordered newest-first."""
wallet = await wallet_service.get_or_create_wallet(db, current_user.id)
txs, total = await wallet_service.get_transactions(db, wallet.id, limit=limit, offset=offset)
return TransactionListResponse(
transactions=txs,
total=total,
limit=limit,
offset=offset,
)
+451
View File
@@ -0,0 +1,451 @@
"""World content router -- rooms + chunks, for the editor and player client.
Implements `docs/architecture/world_persistence_impl.md` §8.1. JWT-authenticated
(``CurrentUser``, same as ``app/routers/worlds.py``). All chunk content
reads/writes go through ``WorldStore``; this module is HTTP plumbing +
authorization only.
Authorization (doc §5.3):
- **Read** (world visibility, room listing, chunk fetch/index/revisions):
same rule as ``GET /api/worlds/{id}`` -- 404 (not 403) if the world is
neither PUBLIC nor owned by the caller. See ``_get_world_readable``.
- **Write** (room create, chunk PUT/DELETE/rollback): caller must be
``world.owner_id``, matching ``update_world`` -- 403 otherwise, with
*no* visibility gate (this deliberately mirrors ``update_world``, which
also has no visibility check; see ``_get_world_writable``).
"""
from __future__ import annotations
import base64
import binascii
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User
from app.models.world import World, WorldVisibility
from app.models.world_content import MAIN_ROOM_SLUG, WorldRoom
from app.services import chunk_codec
from app.services.game_servers import supervisor
from app.services.world_store import Bounds, RevisionConflict, RevisionNotAvailable, WorldStore
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# world_persistence_impl.md §8.1: "Bounds are capped (default 32x32 chunks
# per query) and return 422 beyond that."
MAX_BOUNDS_SPAN = 32
# §8.1 batch write cap, beyond WORLD_CHUNK_MAX_BATCH chunks: "Capped at ...
# 8 MB."
MAX_BATCH_TOTAL_BYTES = 8 * 1024 * 1024
# ─────────────────────────── auth helpers ───────────────────────────
async def _get_world_readable(world_id: int, current_user: User, db: AsyncSession) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if world is None:
raise HTTPException(status_code=404, detail="World not found")
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="World not found")
return world
async def _get_world_writable(world_id: int, current_user: User, db: AsyncSession) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if world is None:
raise HTTPException(status_code=404, detail="World not found")
if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world")
return world
async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str) -> WorldRoom | None:
"""world_persistence_impl.md §4.1: lazily create the main room for
worlds that predate this feature, on first read/write."""
room = await store.get_room(world_id, slug)
if room is None and slug == MAIN_ROOM_SLUG:
room = await store.get_or_create_main_room(world_id)
await db.commit()
return room
def _is_live(world_id: int) -> bool:
return any(s["world_id"] == str(world_id) for s in supervisor.debug_state())
def _validation_error_body(exc: chunk_codec.ChunkValidationError) -> dict:
return {"detail": "chunk validation failed", "reason": exc.reason}
def _conflict_body(exc: RevisionConflict) -> dict:
body: dict = {
"detail": "revision conflict",
"current_revision": exc.current_revision,
"current_checksum": exc.current_checksum,
}
if exc.coord is not None:
body["coord"] = list(exc.coord)
return body
# ─────────────────────────── schemas ───────────────────────────
class RoomResponse(BaseModel):
id: int
slug: str
name: str
grid: dict
bounds: dict | None
spawn: dict | None
chunk_count: int
class RoomCreate(BaseModel):
slug: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-z0-9_-]+$")
name: str = Field(..., min_length=1, max_length=255)
grid: dict | None = None
bounds: dict | None = None
spawn: dict | None = None
# ─────────────────────────── rooms ───────────────────────────
@router.get("/{world_id}/rooms", response_model=list[RoomResponse])
async def list_rooms(world_id: int, current_user: CurrentUser, db: DbDep):
await _get_world_readable(world_id, current_user, db)
store = WorldStore(db)
rooms = await store.list_rooms(world_id)
if not rooms:
rooms = [await store.get_or_create_main_room(world_id)]
await db.commit()
return [
RoomResponse(
id=r.id, slug=r.slug, name=r.name, grid=r.grid, bounds=r.bounds, spawn=r.spawn,
chunk_count=await store.count_chunks(r.id),
)
for r in rooms
]
@router.post("/{world_id}/rooms", response_model=RoomResponse, status_code=201)
async def create_room(world_id: int, body: RoomCreate, current_user: CurrentUser, db: DbDep):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
if await store.get_room(world_id, body.slug) is not None:
raise HTTPException(status_code=409, detail="a room with this slug already exists")
room = await store.create_room(
world_id, body.slug, body.name, grid=body.grid, bounds=body.bounds, spawn=body.spawn
)
await db.commit()
await db.refresh(room)
return RoomResponse(
id=room.id, slug=room.slug, name=room.name, grid=room.grid,
bounds=room.bounds, spawn=room.spawn, chunk_count=0,
)
# ─────────────────────────── chunk index ───────────────────────────
@router.get("/{world_id}/rooms/{slug}/chunks")
async def list_chunks(
world_id: int, slug: str, current_user: CurrentUser, db: DbDep,
cx_min: int | None = None, cx_max: int | None = None,
cy_min: int | None = None, cy_max: int | None = None,
cz: int | None = None,
):
await _get_world_readable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
if cx_min is not None and cx_max is not None and (cx_max - cx_min + 1) > MAX_BOUNDS_SPAN:
raise HTTPException(status_code=422, detail=f"cx bounds span exceeds {MAX_BOUNDS_SPAN}")
if cy_min is not None and cy_max is not None and (cy_max - cy_min + 1) > MAX_BOUNDS_SPAN:
raise HTTPException(status_code=422, detail=f"cy bounds span exceeds {MAX_BOUNDS_SPAN}")
bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz)
chunks = await store.list_chunks(world_id, room.id, bounds)
content_revision = await store.content_revision(world_id)
return {
"room": room.slug,
"content_revision": content_revision,
"chunks": [
{
"key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz,
"revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len,
"is_mature": c.is_mature,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in chunks
],
}
# ─────────────────────────── chunk fetch ───────────────────────────
@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}")
async def get_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int,
current_user: CurrentUser, db: DbDep,
accept: Annotated[str | None, Header()] = None,
if_none_match: Annotated[str | None, Header(alias="If-None-Match")] = None,
):
await _get_world_readable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
chunk = await store.get_chunk(key)
if chunk is None:
raise HTTPException(status_code=404, detail="chunk not found")
etag = f'"{chunk.checksum}"'
headers = {"ETag": etag, "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key}
if if_none_match is not None and if_none_match.strip('"') == chunk.checksum:
return Response(status_code=304, headers=headers)
wants_json = (
accept is not None
and "application/json" in accept
and "application/octet-stream" not in accept
)
if wants_json:
decoded = chunk_codec.decode_binary(chunk.payload)
body = chunk_codec.to_json_dict(decoded, key=key, chunk_checksum=chunk.checksum)
return JSONResponse(content=body, headers=headers)
return Response(content=chunk.payload, media_type="application/octet-stream", headers=headers)
# ─────────────────────────── chunk write ───────────────────────────
async def _read_write_payload(request: Request, x_base_revision: int | None) -> tuple[bytes, int]:
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.json()
try:
payload_bytes = base64.b64decode(body["payload_b64"], validate=True)
except (KeyError, binascii.Error, ValueError) as exc:
raise HTTPException(status_code=422, detail=f"invalid payload_b64: {exc}")
base_revision = int(body.get("base_revision", 0))
return payload_bytes, base_revision
payload_bytes = await request.body()
return payload_bytes, (x_base_revision if x_base_revision is not None else 0)
@router.put("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}")
async def put_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int,
request: Request, current_user: CurrentUser, db: DbDep,
x_base_revision: Annotated[int | None, Header(alias="X-Base-Revision")] = None,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
payload_bytes, base_revision = await _read_write_payload(request, x_base_revision)
if len(payload_bytes) > settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"payload exceeds WORLD_CHUNK_MAX_PAYLOAD_BYTES ({settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES})",
)
try:
chunk = await store.put_chunk(
room=room, cx=cx, cy=cy, cz=cz, payload_bytes=payload_bytes,
base_revision=base_revision, actor_id=current_user.id,
)
except chunk_codec.ChunkCodecError as exc:
await db.rollback()
return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)})
except chunk_codec.ChunkValidationError as exc:
await db.rollback()
return JSONResponse(status_code=422, content=_validation_error_body(exc))
except RevisionConflict as exc:
# Nothing must stick from a failed write. Returning a JSONResponse
# here (rather than raising) means no exception propagates through
# `get_db`, so this rollback has to be explicit -- see the note on
# the batch endpoint below, where this is load-bearing for
# all-or-nothing semantics.
await db.rollback()
return JSONResponse(status_code=409, content=_conflict_body(exc))
await db.commit()
await db.refresh(chunk)
return {
"key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum,
"payload_len": chunk.payload_len,
"updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None,
"is_mature": chunk.is_mature, "live_session": _is_live(world_id),
}
# ─────────────────────────── batch write ───────────────────────────
class BatchChunkItem(BaseModel):
cx: int
cy: int
cz: int
base_revision: int
payload_b64: str
class BatchPutRequest(BaseModel):
chunks: list[BatchChunkItem]
@router.put("/{world_id}/rooms/{slug}/chunks")
async def put_chunks_batch(
world_id: int, slug: str, body: BatchPutRequest, current_user: CurrentUser, db: DbDep,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
if len(body.chunks) > settings.WORLD_CHUNK_MAX_BATCH:
raise HTTPException(
status_code=422,
detail=f"batch of {len(body.chunks)} exceeds WORLD_CHUNK_MAX_BATCH ({settings.WORLD_CHUNK_MAX_BATCH})",
)
items = []
total_bytes = 0
for item in body.chunks:
try:
payload_bytes = base64.b64decode(item.payload_b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=422, detail=f"invalid payload_b64 for ({item.cx},{item.cy},{item.cz}): {exc}")
total_bytes += len(payload_bytes)
items.append({
"cx": item.cx, "cy": item.cy, "cz": item.cz,
"base_revision": item.base_revision, "payload_bytes": payload_bytes,
})
if total_bytes > MAX_BATCH_TOTAL_BYTES:
raise HTTPException(status_code=413, detail=f"batch exceeds {MAX_BATCH_TOTAL_BYTES} bytes")
try:
rows = await store.put_chunks_batch(room=room, items=items, actor_id=current_user.id)
except chunk_codec.ChunkCodecError as exc:
# All-or-nothing (world_persistence_impl.md §8.1): earlier items in
# this batch may already be flushed (but never committed) to this
# transaction. Rolling back here is what actually makes the batch
# atomic -- without it, returning a JSONResponse instead of raising
# means no exception reaches `get_db`, and its own transaction
# handling would otherwise commit those earlier writes anyway.
await db.rollback()
return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)})
except chunk_codec.ChunkValidationError as exc:
await db.rollback()
return JSONResponse(status_code=422, content=_validation_error_body(exc))
except RevisionConflict as exc:
await db.rollback()
return JSONResponse(status_code=409, content=_conflict_body(exc))
await db.commit()
for row in rows:
await db.refresh(row)
return {
"live_session": _is_live(world_id),
"chunks": [
{
"key": r.chunk_key, "revision": r.revision, "checksum": r.checksum,
"payload_len": r.payload_len,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
"is_mature": r.is_mature,
}
for r in rows
],
}
# ─────────────────────────── delete ───────────────────────────
@router.delete("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}", status_code=204)
async def delete_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
await store.delete_chunk(key, current_user.id)
await db.commit()
return Response(status_code=204)
# ─────────────────────────── revisions + rollback ───────────────────────────
@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions")
async def chunk_revisions(
world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep,
):
# doc: "(owner)" -- same gate as writes.
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
revisions = await store.list_revisions(key)
return [
{
"revision": r.revision, "action": r.action, "actor_id": r.actor_id,
"checksum": r.checksum, "payload_available": r.payload is not None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in revisions
]
class RollbackRequest(BaseModel):
revision: int
@router.post("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback")
async def rollback_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int,
body: RollbackRequest, current_user: CurrentUser, db: DbDep,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
try:
chunk = await store.rollback_chunk(key, body.revision, current_user.id)
except RevisionNotAvailable as exc:
await db.rollback()
return JSONResponse(status_code=404, content={"detail": str(exc)})
except chunk_codec.ChunkValidationError as exc:
await db.rollback()
return JSONResponse(status_code=422, content=_validation_error_body(exc))
await db.commit()
await db.refresh(chunk)
return {
"key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum,
"payload_len": chunk.payload_len,
"updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None,
"is_mature": chunk.is_mature, "live_session": _is_live(world_id),
}
+21 -1
View File
@@ -13,6 +13,7 @@ from app.database import get_db
from app.dependencies import CurrentUser
from app.models.world import World, WorldVisibility
from app.services.game_servers import supervisor
from app.services.world_store import WorldStore
router = APIRouter()
@@ -26,6 +27,8 @@ class WorldCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility = WorldVisibility.PUBLIC
category: str | None = None
tags: list[str] | None = None
class WorldUpdate(BaseModel):
@@ -33,6 +36,8 @@ class WorldUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility | None = None
category: str | None = None
tags: list[str] | None = None
class WorldResponse(BaseModel):
@@ -41,12 +46,17 @@ class WorldResponse(BaseModel):
name: str
description: str | None
visibility: WorldVisibility
category: str | None = None
tags: list[str] | None = None
play_count: int = 0
last_played_at: datetime | None = None
owner_id: int
created_at: datetime | None = None
model_config = {"from_attributes": True}
# --- Endpoints ---
@router.get("/", response_model=list[WorldResponse])
@@ -147,14 +157,24 @@ async def create_world(
current_user: CurrentUser,
db: DbDep,
):
"""Create a new world."""
"""Create a new world.
Also creates the world's "main" room (world_persistence_impl.md §4.1) --
every world gets one so chunk content has somewhere to live from the
start.
"""
world = World(
name=world_data.name,
description=world_data.description,
owner_id=current_user.id,
visibility=world_data.visibility,
category=world_data.category,
tags=world_data.tags or [],
)
db.add(world)
await db.flush() # assigns world.id, needed for the room's FK
await WorldStore(db).get_or_create_main_room(world.id)
await db.commit()
await db.refresh(world)
return world
+48
View File
@@ -0,0 +1,48 @@
"""Pub/sub messaging bus for chat events over Redis.
Provides asynchronous publish hooks for world/room chat and DMs, allowing the
WebSocket real-time gateway (and multi-instance replicas) to receive and fan out
chat messages in real time.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
async def publish_world_chat_event(
world_id: int, payload: dict[str, Any], room_id: int | None = None
) -> None:
"""Publish a world chat message event to Redis pub/sub for real-time delivery."""
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async with client:
channel = f"world:{world_id}:chat"
data = json.dumps(payload, default=str)
await client.publish(channel, data)
if room_id is not None:
await client.publish(f"room:{room_id}:chat", data)
except Exception as exc:
logger.debug("Failed to publish world chat event to Redis: %s", exc)
async def publish_dm_chat_event(
conversation_id: int, payload: dict[str, Any]
) -> None:
"""Publish a DM chat message event to Redis pub/sub for real-time delivery."""
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async with client:
channel = f"dm:{conversation_id}:chat"
data = json.dumps(payload, default=str)
await client.publish(channel, data)
except Exception as exc:
logger.debug("Failed to publish DM chat event to Redis: %s", exc)
+676
View File
@@ -0,0 +1,676 @@
"""Python port of the chunk binary/JSON codec, `docs/architecture/world.md` §3.
This is the server-side half of the "single shared format, two
implementations" contract world.md §5.2 and
`world_persistence_impl.md` §5.5/§9 call for. The Godot-side implementation
is `scripts/chunk_data_decoder.gd`; this module must decode anything it
produces and produce bytes it can decode.
IMPORTANT DEVIATION (see the "Known cross-codec divergence" note at the
bottom of this file): the current `scripts/chunk_data_decoder.gd` does not
actually implement IEEE-754 float32 for the `pos`/`rot`/`scale`/`bounds`
fields -- it performs a lossy numeric `int`/`float` cast instead of a bit
reinterpretation, which both truncates fractional values and does not
write IEEE-754 bits at all. This module implements the format as
*specified* (real `<f` IEEE-754 float32, matching every other field in the
spec and matching what any conformant decoder would assume). See the note
for what this means for cross-codec byte parity today.
Nothing here talks to a database or the FastAPI app; it is a pure,
side-effect-free codec so it can be golden-file tested in isolation.
"""
from __future__ import annotations
import hashlib
import json
import struct
from dataclasses import dataclass, field
MAGIC = b"AGPC"
HEADER_SIZE = 48
CURRENT_VERSION = 1
SECTION_META = 0x01
SECTION_TILE = 0x02
SECTION_OBJECT = 0x03
SECTION_FLAGS = 0x04
SECTION_REVISION_META = 0x05
CATEGORY_FIELDS = (
"violence", "sexual", "language", "substance",
"gambling", "horror", "hate", "custom",
)
class ChunkCodecError(Exception):
"""Malformed bytes -- the payload isn't shaped like a chunk at all."""
class ChunkValidationError(Exception):
"""A structurally-valid chunk failed a content/business-rule check.
``reason`` is the machine-readable code the API surface (world_persistence_impl.md
§8.1) puts in the 422 response body's ``reason`` field, e.g.
``"checksum_mismatch"``, ``"layer_dims"``, ``"object_out_of_bounds"``.
"""
def __init__(self, reason: str, message: str | None = None) -> None:
self.reason = reason
super().__init__(message or reason)
# ───────────────────────── Logical model ─────────────────────────
@dataclass
class TileDef:
id: int
asset_id: str = ""
solid: bool = False
flags: int = 0
variants: int = 1
@dataclass
class TileLayer:
name: str
layer_id: int = 0
width: int = 0
height: int = 0
depth: int = 1
palette: list[TileDef] = field(default_factory=list)
tiles: list[int] = field(default_factory=list) # flattened, row-major
@dataclass
class ContentFlags:
violence: int = 0
sexual: int = 0
language: int = 0
substance: int = 0
gambling: int = 0
horror: int = 0
hate: int = 0
custom: int = 0
mature: bool = False
sources: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"categories": {name: getattr(self, name) for name in CATEGORY_FIELDS},
"mature": self.mature,
"sources": list(self.sources),
}
@classmethod
def from_dict(cls, d: dict) -> "ContentFlags":
cats = d.get("categories", {})
return cls(
**{name: int(cats.get(name, 0)) for name in CATEGORY_FIELDS},
mature=bool(d.get("mature", False)),
sources=list(d.get("sources", [])),
)
@dataclass
class WorldObject:
id: str
type: str
asset_id: str
pos: tuple[float, float, float] = (0.0, 0.0, 0.0)
rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)
scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
dynamic: bool = False
properties: dict = field(default_factory=dict)
flags: ContentFlags = field(default_factory=ContentFlags)
@dataclass
class ChunkBounds:
"""Stored here as min-corner + size (matching the Godot client's AABB
type) for convenience, but the §3.6 BINARY wire format is min + max
(world.md §3.1, and the client's actual ChunkDataDecoder) -- fixed
2026-09-14 after a real client/server interop test caught this class's
original binary codec writing min+size on the wire, which the client
decoder (unaware of the "size" convention) read as a second min/max
pair, corrupting every chunk's bounds. The two representations are
fully recoverable from each other; `max` below is computed for exactly
this reason."""
min: tuple[float, float, float] = (0.0, 0.0, 0.0)
size: tuple[float, float, float] = (0.0, 0.0, 0.0)
@property
def max(self) -> tuple[float, float, float]:
return tuple(a + b for a, b in zip(self.min, self.size))
@dataclass
class ChunkPayload:
version: int = CURRENT_VERSION
world_id: str = ""
room_id: str = ""
coord: tuple[int, int, int] = (0, 0, 0)
bounds: ChunkBounds = field(default_factory=ChunkBounds)
tile_layers: list[TileLayer] = field(default_factory=list)
objects: list[WorldObject] = field(default_factory=list)
flags: ContentFlags = field(default_factory=ContentFlags)
created_at: int = 0
updated_at: int = 0
revision: int = 0
author: str = ""
def build_key(world_id: str | int, room_slug: str, cx: int, cy: int, cz: int) -> str:
"""world_persistence_impl.md §4.5: ``"{world.id}:{room.slug}:{cx}:{cy}:{cz}"``."""
return f"{world_id}:{room_slug}:{cx}:{cy}:{cz}"
# ───────────────────────── Binary primitives ─────────────────────────
# All integers little-endian, matching world.md §3.6.3. Floats are packed
# as real IEEE-754 float32 -- see the module docstring's deviation note.
def _read_u16(data: bytes, off: int) -> int:
return struct.unpack_from("<H", data, off)[0]
def _read_u32(data: bytes, off: int) -> int:
return struct.unpack_from("<I", data, off)[0]
def _read_i32(data: bytes, off: int) -> int:
return struct.unpack_from("<i", data, off)[0]
def _read_i64(data: bytes, off: int) -> int:
return struct.unpack_from("<q", data, off)[0]
def _read_f32(data: bytes, off: int) -> float:
return struct.unpack_from("<f", data, off)[0]
def _w_u16(v: int) -> bytes:
return struct.pack("<H", v & 0xFFFF)
def _w_u32(v: int) -> bytes:
return struct.pack("<I", v & 0xFFFFFFFF)
def _w_i32(v: int) -> bytes:
return struct.pack("<i", v)
def _w_i64(v: int) -> bytes:
return struct.pack("<q", v)
def _w_f32(v: float) -> bytes:
return struct.pack("<f", v)
def _read_str(data: bytes, off: int) -> tuple[str, int]:
"""Length-prefixed UTF-8 string. Returns (value, next_offset)."""
n = _read_u16(data, off)
off += 2
s = data[off:off + n].decode("utf-8")
return s, off + n
def _w_str(s: str) -> bytes:
b = s.encode("utf-8")
if len(b) > 0xFFFF:
raise ChunkCodecError(f"string field too long to encode ({len(b)} bytes)")
return _w_u16(len(b)) + b
def checksum(payload_body: bytes) -> str:
"""SHA-256 hex digest of the payload body (world.md §3.6.3)."""
return hashlib.sha256(payload_body).hexdigest()
# ───────────────────────── Section codecs ─────────────────────────
def _encode_metadata(payload: ChunkPayload) -> bytes:
out = bytearray()
out += _w_str(payload.world_id)
out += _w_str(payload.room_id)
out += _w_i32(payload.coord[0])
out += _w_i32(payload.coord[1])
out += _w_i32(payload.coord[2])
out += _w_f32(payload.bounds.min[0])
out += _w_f32(payload.bounds.min[1])
out += _w_f32(payload.bounds.min[2])
out += _w_f32(payload.bounds.max[0])
out += _w_f32(payload.bounds.max[1])
out += _w_f32(payload.bounds.max[2])
return bytes(out)
def _decode_metadata(data: bytes, result: ChunkPayload) -> None:
off = 0
result.world_id, off = _read_str(data, off)
result.room_id, off = _read_str(data, off)
coord = (_read_i32(data, off), _read_i32(data, off + 4), _read_i32(data, off + 8))
off += 12
min_pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
max_pos = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20))
size = tuple(a - b for a, b in zip(max_pos, min_pos))
result.coord = coord
result.bounds = ChunkBounds(min=min_pos, size=size)
def _encode_tile_layer(layer: TileLayer) -> bytes:
out = bytearray()
out += _w_str(layer.name)
out += _w_u16(layer.layer_id)
out += _w_u16(layer.width)
out += _w_u16(layer.height)
out += _w_u16(layer.depth)
out += _w_u16(len(layer.palette))
for td in layer.palette:
out += _w_u16(td.id)
out += _w_str(td.asset_id)
out += bytes([1 if td.solid else 0])
out += _w_u32(td.flags)
out += bytes([td.variants & 0xFF])
out += _w_u32(len(layer.tiles))
for tile_id in layer.tiles:
out += _w_u16(tile_id)
return bytes(out)
def _decode_tile_layer(data: bytes) -> TileLayer:
off = 0
name, off = _read_str(data, off)
layer_id = _read_u16(data, off); off += 2
width = _read_u16(data, off); off += 2
height = _read_u16(data, off); off += 2
depth = _read_u16(data, off); off += 2
palette_count = _read_u16(data, off); off += 2
palette: list[TileDef] = []
for _ in range(palette_count):
tid = _read_u16(data, off); off += 2
asset_id, off = _read_str(data, off)
solid = data[off] != 0; off += 1
flags = _read_u32(data, off); off += 4
variants = data[off]; off += 1
palette.append(TileDef(id=tid, asset_id=asset_id, solid=solid, flags=flags, variants=variants))
tile_count = _read_u32(data, off); off += 4
tiles: list[int] = []
for _ in range(tile_count):
tiles.append(_read_u16(data, off))
off += 2
return TileLayer(name=name, layer_id=layer_id, width=width, height=height,
depth=depth, palette=palette, tiles=tiles)
def _encode_flags(flags: ContentFlags) -> bytes:
out = bytearray()
for name in CATEGORY_FIELDS:
out.append(getattr(flags, name) & 0xFF)
out.append(1 if flags.mature else 0)
out += _w_u16(len(flags.sources))
for src in flags.sources:
out += _w_str(src)
return bytes(out)
def _decode_flags(data: bytes) -> ContentFlags:
off = 0
values = {}
for name in CATEGORY_FIELDS:
values[name] = data[off]
off += 1
mature = data[off] != 0
off += 1
source_count = _read_u16(data, off)
off += 2
sources = []
for _ in range(source_count):
s, off = _read_str(data, off)
sources.append(s)
return ContentFlags(**values, mature=mature, sources=sources)
def _encode_object(obj: WorldObject) -> bytes:
out = bytearray()
out += _w_str(obj.id)
out += _w_str(obj.type)
out += _w_str(obj.asset_id)
for v in obj.pos:
out += _w_f32(v)
for v in obj.rot:
out += _w_f32(v)
for v in obj.scale:
out += _w_f32(v)
out.append(1 if obj.dynamic else 0)
# Canonical JSON: keys sorted lexicographically (world.md §3.6.3).
props_json = json.dumps(obj.properties, sort_keys=True, separators=(",", ":"))
out += _w_str(props_json)
out += _encode_flags(obj.flags)
return bytes(out)
def _decode_object(data: bytes) -> WorldObject:
off = 0
oid, off = _read_str(data, off)
otype, off = _read_str(data, off)
asset_id, off = _read_str(data, off)
pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
off += 12
rot = (
_read_f32(data, off), _read_f32(data, off + 4),
_read_f32(data, off + 8), _read_f32(data, off + 12),
)
off += 16
scale = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
off += 12
dynamic = data[off] != 0
off += 1
props_str, off = _read_str(data, off)
try:
properties = json.loads(props_str) if props_str else {}
except json.JSONDecodeError as exc:
raise ChunkCodecError(f"object {oid!r}: invalid properties JSON") from exc
obj_flags = _decode_flags(data[off:])
return WorldObject(id=oid, type=otype, asset_id=asset_id, pos=pos, rot=rot,
scale=scale, dynamic=dynamic, properties=properties, flags=obj_flags)
def _encode_revision_meta(payload: ChunkPayload) -> bytes:
out = bytearray()
out += _w_i64(payload.created_at)
out += _w_i64(payload.updated_at)
out += _w_u32(payload.revision)
out += _w_str(payload.author)
return bytes(out)
def _decode_revision_meta(data: bytes, result: ChunkPayload) -> None:
off = 0
result.created_at = _read_i64(data, off); off += 8
result.updated_at = _read_i64(data, off); off += 8
result.revision = _read_u32(data, off); off += 4
result.author, off = _read_str(data, off)
def _wrap_section(section_id: int, data: bytes) -> bytes:
return bytes([section_id]) + _w_u32(len(data)) + data
# ───────────────────────── Top-level encode/decode ─────────────────────────
def encode_binary(payload: ChunkPayload) -> bytes:
"""Encode a ChunkPayload to the world.md §3.6 wire format."""
sections = bytearray()
sections += _wrap_section(SECTION_META, _encode_metadata(payload))
for layer in payload.tile_layers:
sections += _wrap_section(SECTION_TILE, _encode_tile_layer(layer))
for obj in payload.objects:
sections += _wrap_section(SECTION_OBJECT, _encode_object(obj))
sections += _wrap_section(SECTION_FLAGS, _encode_flags(payload.flags))
sections += _wrap_section(SECTION_REVISION_META, _encode_revision_meta(payload))
payload_body = bytes(sections)
digest = hashlib.sha256(payload_body).digest() # 32 raw bytes
header = bytearray()
header += MAGIC
header += _w_u16(CURRENT_VERSION)
header += _w_u16(HEADER_SIZE - 8) # header_len: bytes of header after this field (spec-correct)
header += _w_u32(0) # flags (reserved)
header += _w_u32(len(payload_body))
header += digest
return bytes(header) + payload_body
def decode_binary(data: bytes) -> ChunkPayload:
"""Decode a world.md §3.6 binary chunk. Raises ChunkCodecError on
structurally malformed input (short buffer, bad magic, unsupported
version, truncated payload)."""
if len(data) < HEADER_SIZE:
raise ChunkCodecError(f"data too short for header ({len(data)} bytes)")
if data[0:4] != MAGIC:
raise ChunkCodecError("bad magic bytes")
version = _read_u16(data, 4)
if version < 1 or version > CURRENT_VERSION:
raise ChunkCodecError(f"unsupported chunk format version {version}")
# header_len (offset 6) is intentionally not used for offset math --
# both this decoder and the Godot one always slice at the fixed
# HEADER_SIZE, matching the reference implementation's actual (if
# spec-inconsistent) behaviour.
payload_len = _read_u32(data, 12)
# bytes 16:48 are the header's advisory checksum -- decode_and_validate
# re-reads it directly and recomputes against the parsed payload body.
if len(data) - HEADER_SIZE != payload_len:
raise ChunkCodecError(
f"declared payload_len ({payload_len}) does not match actual "
f"remaining bytes ({len(data) - HEADER_SIZE})"
)
payload_body = data[HEADER_SIZE:HEADER_SIZE + payload_len]
result = ChunkPayload(version=version)
offset = 0
while offset < len(payload_body):
if offset + 5 > len(payload_body):
raise ChunkCodecError("truncated section header")
section_id = payload_body[offset]
section_len = _read_u32(payload_body, offset + 1)
data_start = offset + 5
data_end = data_start + section_len
if data_end > len(payload_body):
raise ChunkCodecError("truncated section body")
section_data = payload_body[data_start:data_end]
if section_id == SECTION_META:
_decode_metadata(section_data, result)
elif section_id == SECTION_TILE:
result.tile_layers.append(_decode_tile_layer(section_data))
elif section_id == SECTION_OBJECT:
result.objects.append(_decode_object(section_data))
elif section_id == SECTION_FLAGS:
result.flags = _decode_flags(section_data)
elif section_id == SECTION_REVISION_META:
_decode_revision_meta(section_data, result)
# Unknown section ids are skipped (forward-compat), matching the
# Godot decoder's match-with-no-default behaviour.
offset = data_end
return result
# ───────────────────────── JSON encoding (world.md §3.5) ─────────────────────────
# Derived, never stored (world_persistence_impl.md §8.1) -- produced here
# from a decoded ChunkPayload for the ``Accept: application/json`` path.
def to_json_dict(payload: ChunkPayload, *, key: str, chunk_checksum: str) -> dict:
return {
"version": payload.version,
"key": key,
"world_id": payload.world_id,
"room_id": payload.room_id,
"coord": {"cx": payload.coord[0], "cy": payload.coord[1], "cz": payload.coord[2]},
"bounds": {
"min_x": payload.bounds.min[0], "min_y": payload.bounds.min[1], "min_z": payload.bounds.min[2],
"max_x": payload.bounds.max[0], "max_y": payload.bounds.max[1], "max_z": payload.bounds.max[2],
},
"tiles": [
{
"name": layer.name,
"layer_id": layer.layer_id,
"width": layer.width,
"height": layer.height,
"depth": layer.depth,
"palette": [
{"id": td.id, "asset_id": td.asset_id, "solid": td.solid,
"flags": td.flags, "variants": td.variants}
for td in layer.palette
],
"tiles": list(layer.tiles),
}
for layer in payload.tile_layers
],
"objects": [
{
"id": obj.id, "type": obj.type, "asset_id": obj.asset_id,
"transform": {
"pos": list(obj.pos), "rot": list(obj.rot), "scale": list(obj.scale),
},
"properties": obj.properties,
"dynamic": obj.dynamic,
"flags": obj.flags.to_dict(),
}
for obj in payload.objects
],
"flags": payload.flags.to_dict(),
"meta": {
"created_at": payload.created_at, "updated_at": payload.updated_at,
"revision": payload.revision, "author": payload.author,
},
"checksum": chunk_checksum,
}
# ───────────────────────── Validation (world_persistence_impl.md §5.5) ─────────────────────────
# Not precisely quantified by the design doc beyond "length limits" --
# these are this implementation's conservative defaults (world.md §7).
MAX_STRING_FIELD_LEN = 256
MAX_PROPERTIES_JSON_LEN = 16_384
MAX_SOURCES_PER_FLAGSET = 32
def _check_string_lengths(obj: WorldObject) -> None:
for field_name, value in (("id", obj.id), ("type", obj.type), ("asset_id", obj.asset_id)):
if len(value) > MAX_STRING_FIELD_LEN:
raise ChunkValidationError(
"string_field_too_long", f"object.{field_name} exceeds {MAX_STRING_FIELD_LEN} chars"
)
props_json = json.dumps(obj.properties)
if len(props_json) > MAX_PROPERTIES_JSON_LEN:
raise ChunkValidationError(
"string_field_too_long", f"object {obj.id!r} properties exceed {MAX_PROPERTIES_JSON_LEN} bytes"
)
if len(obj.flags.sources) > MAX_SOURCES_PER_FLAGSET:
raise ChunkValidationError("string_field_too_long", f"object {obj.id!r} has too many flag sources")
def decode_and_validate(
data: bytes,
*,
expected_key: str,
grid_size: tuple[int, int, int],
max_objects: int,
) -> ChunkPayload:
"""Decode + fully validate a client-submitted chunk payload.
This is the boundary world_persistence_impl.md §5.5 requires: "the
backend must not trust the client". Raises ``ChunkCodecError`` for
structurally malformed bytes (mapped to a 422 by the router with a
generic reason) or ``ChunkValidationError`` with a specific ``reason``
code for a well-formed-but-invalid payload.
"""
payload = decode_binary(data) # raises ChunkCodecError for structural problems first
# Checksum is advisory in the header (world.md §7); the value that
# matters is recomputed from the actual payload body we just parsed.
payload_body = data[HEADER_SIZE:]
recomputed = checksum(payload_body)
header_checksum_hex = data[16:48].hex()
if header_checksum_hex != recomputed:
raise ChunkValidationError(
"checksum_mismatch",
"header checksum does not match the recomputed SHA-256 of the payload body",
)
actual_key = build_key(payload.world_id, payload.room_id, *payload.coord)
if actual_key != expected_key:
raise ChunkValidationError(
"chunk_key_mismatch",
f"payload key {actual_key!r} does not match URL {expected_key!r}",
)
for layer in payload.tile_layers:
if (layer.width, layer.height, layer.depth) != tuple(grid_size):
raise ChunkValidationError(
"layer_dims",
f"tile layer {layer.name!r} dims {(layer.width, layer.height, layer.depth)} "
f"!= room grid {tuple(grid_size)}",
)
valid_ids = {0} | {td.id for td in layer.palette}
for tile_id in layer.tiles:
if tile_id not in valid_ids:
raise ChunkValidationError(
"tile_out_of_palette",
f"tile layer {layer.name!r} references palette id {tile_id} not in its palette",
)
if len(layer.tiles) != layer.width * layer.height * layer.depth:
raise ChunkValidationError(
"layer_dims",
f"tile layer {layer.name!r} has {len(layer.tiles)} cells, expected "
f"{layer.width * layer.height * layer.depth}",
)
if len(payload.objects) > max_objects:
raise ChunkValidationError(
"too_many_objects", f"{len(payload.objects)} objects exceeds max {max_objects}"
)
bmin, bmax = payload.bounds.min, payload.bounds.max
for obj in payload.objects:
_check_string_lengths(obj)
if not all(bmin[i] <= obj.pos[i] <= bmax[i] for i in range(3)):
raise ChunkValidationError(
"object_out_of_bounds",
f"object {obj.id!r} position {obj.pos} outside chunk bounds "
f"[{bmin}, {bmax}]",
)
if len(payload.author) > MAX_STRING_FIELD_LEN:
raise ChunkValidationError("string_field_too_long", "meta.author too long")
return payload
# ───────────────────────── Known cross-codec divergence ─────────────────────────
#
# `scripts/chunk_data_decoder.gd`'s `_read_float32`/`_write_float32` do:
#
# static func _write_float32(val: float) -> PackedByteArray:
# var bits := val as int
# return _write_uint32(bits)
#
# static func _read_float32(data: PackedByteArray, offset: int) -> float:
# var bits := _read_uint32(data, offset)
# return bits as float
#
# `as` between int and float in GDScript is a numeric conversion, not a bit
# reinterpretation (verified empirically against Godot 4.6.2 headless:
# `1.5 as int` -> `1`, `1 as float` -> `1.0`; the IEEE-754 bit pattern of
# 1.5f is 0x3FC00000 = 1069547520, not 1). So the shipped client codec
# truncates every float32 field (bounds, object pos/rot/scale) to its
# integer part and stores that integer as a raw int32 -- not as float32
# bits at all. This is a real bug in already-merged, client-side code
# (scripts/ is out of scope for this task) that breaks the "byte-stable
# across two implementations" guarantee world.md §3.6.3 states.
#
# This module implements the *specified* format (real IEEE-754 float32)
# rather than replicating that bug, because world.md §3.6 is normative and
# says "float32" for these fields, and mirroring the bug would mean the
# backend also corrupts every non-integer transform/bounds value it
# stores. Practical effect until the client-side bug is fixed: payloads
# with integer-only floats will decode identically on both sides; any
# payload with a fractional position/rotation/scale/bounds value will not
# be byte-identical (or numerically identical) between this module and the
# current `scripts/chunk_data_decoder.gd`. This is flagged for the
# client/editor follow-on task, not fixed here.
+71 -14
View File
@@ -13,15 +13,26 @@ project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``);
the backend spawns them as subprocesses.
Design notes / limits (PC3):
- All worlds currently load the same test level (per-world content is M2/M7).
``world_id`` still scopes a distinct server process + session.
- Port allocation is a linear scan of the configured range.
- No crash-restart: if a server process dies, the next join respawns it.
With world content now durable in Postgres (world_persistence_impl.md
D2), this is genuinely safe rather than merely tolerated: the sim
process holds no unsaved world content, so a reap/crash/kill can never
lose world data.
Per-spawn auth token (world_persistence_impl.md §7.3): each spawned server
gets a random ``secrets.token_urlsafe(32)`` on its command line, which it
uses as a Bearer token against ``/api/internal`` to pull its world's
content read-only before binding its ENet port. The token grants access to
exactly one world and dies with its process (the reaper's
``del self._servers[wid]`` invalidates it; see ``validate_token``).
"""
from __future__ import annotations
import asyncio
import hmac
import json
import secrets
import socket
import subprocess
import tempfile
@@ -38,6 +49,7 @@ class _WorldServer:
port: int
proc: subprocess.Popen
status_path: Path
token: str
started_at: float = field(default_factory=time.time)
last_join: float = field(default_factory=time.time)
@@ -50,6 +62,9 @@ class _WorldServer:
def player_count(self) -> int:
return int(self.status().get("players", 0))
def is_ready(self) -> bool:
return self.status().get("ready") is True
def alive(self) -> bool:
return self.proc.poll() is None
@@ -104,16 +119,35 @@ class GameServerSupervisor:
if srv is None:
srv = self._spawn(world_id)
self._servers[world_id] = srv
# Give the engine a moment to bind the ENet port before we
# hand the address to a client.
await self._await_port(srv.port)
try:
await self._await_ready(srv)
except TimeoutError:
# Real failure now, not a silent "hope it's fine" --
# see _await_ready's docstring. Don't leave a possibly
# half-started process registered.
srv.stop()
del self._servers[world_id]
raise RuntimeError(
f"dedicated server for world {world_id} did not become ready in time"
)
srv.last_join = time.time()
return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id}
def validate_token(self, world_id: str, token: str) -> bool:
"""`require_game_server` dependency support (world_persistence_impl.md
§7.3): true iff *token* is the live token for *world_id*'s server.
A dict lookup, as the doc specifies, plus a constant-time compare
since it's a bearer credential."""
srv = self._servers.get(str(world_id))
if srv is None or not srv.alive():
return False
return hmac.compare_digest(srv.token, token)
# ── internals ────────────────────────────────────────────────────────
def _spawn(self, world_id: str) -> _WorldServer:
port = self._free_port()
token = secrets.token_urlsafe(32)
status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
status_path.unlink(missing_ok=True)
cmd = [
@@ -123,9 +157,13 @@ class GameServerSupervisor:
"--port", str(port),
"--world", str(world_id),
"--status-file", str(status_path),
"--api-url", settings.GAME_SERVER_INTERNAL_API_URL,
"--server-token", token,
]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return _WorldServer(world_id=world_id, port=port, proc=proc, status_path=status_path)
return _WorldServer(
world_id=world_id, port=port, proc=proc, status_path=status_path, token=token
)
def _free_port(self) -> int:
used = {s.port for s in self._servers.values()}
@@ -140,16 +178,35 @@ class GameServerSupervisor:
continue
raise RuntimeError("no free game-server port in the configured range")
async def _await_port(self, port: int, timeout: float = 8.0) -> None:
deadline = time.time() + timeout
async def _await_ready(self, srv: _WorldServer) -> None:
"""Wait for a ``"ready": true`` field in the status file
(world_persistence_impl.md §7.4).
This replaces the old ``_await_port``, which had two real bugs:
it treated a bound UDP port as "ready", but with content loading
the port binds *before* the server has fetched its world's rooms
and chunks (world_persistence_impl.md §7.2 step 4) -- so a client
could get handed an address for a server still mid-fetch. And it
*failed open*: past its 8s deadline it simply returned, with no
error, so ``ensure_server`` reported success for a server that may
never have started at all. This raises ``TimeoutError`` instead,
which ``ensure_server`` turns into a real 503 for the joining
client, and also bails out early (same exception) if the process
has already exited.
"""
deadline = time.time() + settings.GAME_SERVER_READY_TIMEOUT_SECONDS
while time.time() < deadline:
# ENet is UDP; a bind failure means something (our server) has it.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
try:
s.bind(("0.0.0.0", port))
except OSError:
return
if srv.is_ready():
return
if not srv.alive():
raise TimeoutError(
f"server process for world {srv.world_id} exited before becoming ready"
)
await asyncio.sleep(0.25)
raise TimeoutError(
f"server for world {srv.world_id} did not report ready within "
f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s"
)
async def _reap_loop(self) -> None:
try:
+89
View File
@@ -0,0 +1,89 @@
"""PaymentProvider interface and NoopPaymentProvider implementation.
docs/planning/economy.md US-2 (task 575fd14b5360).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Protocol
import uuid
ALLOWED_CREDIT_PACKS: set[int] = {100, 500, 1000, 5000}
@dataclass
class PurchaseSession:
session_id: str
user_id: int
credits: int
provider_ref: str
status: str = "created" # created, confirmed, failed
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class Confirmation:
confirmed: bool
provider_ref: str
credits: int
user_id: int
class PaymentProvider(Protocol):
"""Pluggable payment provider protocol (e.g. Stripe, PayPal, Noop)."""
async def create_purchase(
self, user_id: int, credits: int, idempotency_key: str | None = None
) -> PurchaseSession:
...
async def confirm(self, session_id: str) -> Confirmation:
...
class NoopPaymentProvider:
"""Deterministic, no-op payment provider for local development and testing.
Returns confirmed=True with a simulated provider reference. Clearly non-production.
"""
def __init__(self) -> None:
self._sessions: dict[str, PurchaseSession] = {}
async def create_purchase(
self, user_id: int, credits: int, idempotency_key: str | None = None
) -> PurchaseSession:
session_id = f"sess_{uuid.uuid4().hex[:16]}"
provider_ref = f"noop_tx_{uuid.uuid4().hex[:12]}"
sess = PurchaseSession(
session_id=session_id,
user_id=user_id,
credits=credits,
provider_ref=provider_ref,
status="created",
)
self._sessions[session_id] = sess
return sess
async def confirm(self, session_id: str) -> Confirmation:
sess = self._sessions.get(session_id)
if sess is None:
return Confirmation(
confirmed=False,
provider_ref="",
credits=0,
user_id=0,
)
sess.status = "confirmed"
return Confirmation(
confirmed=True,
provider_ref=sess.provider_ref,
credits=sess.credits,
user_id=sess.user_id,
)
payment_provider: PaymentProvider = NoopPaymentProvider()
+219
View File
@@ -0,0 +1,219 @@
"""Creator Payout Service: requests, review, pluggable PayoutProvider, and audit trail.
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import logging
from typing import Protocol
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus
from app.models.wallet import Transaction, TransactionType
from app.services.purchase import PurchaseService
from app.services.wallet import WalletService
logger = logging.getLogger(__name__)
MIN_PAYOUT_AMOUNT = 100
MAX_PAYOUT_AMOUNT = 100_000
LARGE_PAYOUT_THRESHOLD = 5000
class InsufficientCreatorBalanceError(Exception):
"""Raised when creator available balance is less than requested payout amount."""
pass
class InvalidPayoutAmountError(ValueError):
"""Raised when requested payout amount is out of valid bounds."""
pass
class PayoutNotFoundError(Exception):
"""Raised when payout request is not found."""
pass
class InvalidPayoutStateError(Exception):
"""Raised when payout request is in invalid status for the requested transition."""
pass
class PayoutProvider(Protocol):
"""Pluggable creator payout provider protocol (e.g. Stripe Connect, PayPal, Noop)."""
async def pay(
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
) -> tuple[bool, str]:
"""Execute payout to creator. Returns (success, provider_ref)."""
...
class NoopPayoutProvider:
"""Deterministic no-op provider for development and testing."""
async def pay(
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
) -> tuple[bool, str]:
ref = f"noop_payout_{uuid.uuid4().hex[:12]}"
logger.info(
f"[NoopPayoutProvider] Simulated payout of {amount} CR to user {user_id} (ref={ref})"
)
return True, ref
default_payout_provider: PayoutProvider = NoopPayoutProvider()
class PayoutService:
def __init__(self, provider: PayoutProvider | None = None):
self.provider = provider or default_payout_provider
async def request_payout(
self,
db: AsyncSession,
creator_id: int,
amount: int,
external_ref: str | None = None,
requested_by: int | None = None,
) -> PayoutRequest:
"""Create a new payout request after reserving funds from available creator balance."""
if amount < MIN_PAYOUT_AMOUNT:
raise InvalidPayoutAmountError(
f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Credits"
)
if amount > MAX_PAYOUT_AMOUNT:
raise InvalidPayoutAmountError(
f"Maximum payout amount is {MAX_PAYOUT_AMOUNT} Credits"
)
requester = requested_by if requested_by is not None else creator_id
# Lock creator balance row
cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True)
if cb.available < amount:
raise InsufficientCreatorBalanceError(
f"Insufficient available balance: {cb.available} CR available, {amount} CR requested"
)
# Reserve funds: move from available to pending
cb.available -= amount
cb.pending += amount
cb.updated_at = datetime.now(timezone.utc)
payout = PayoutRequest(
creator_id=creator_id,
amount=amount,
status=PayoutStatus.pending,
external_ref=external_ref,
requested_by=requester,
)
db.add(payout)
await db.commit()
await db.refresh(payout)
return payout
async def approve_and_pay(
self,
db: AsyncSession,
payout_id: int,
reviewer_id: int,
provider: PayoutProvider | None = None,
) -> PayoutRequest:
"""Admin approves and executes payout via provider."""
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
res = await db.execute(stmt)
payout = res.scalar_one_or_none()
if payout is None:
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
if payout.status != PayoutStatus.pending:
raise InvalidPayoutStateError(
f"Cannot approve payout in status {payout.status.value}"
)
active_provider = provider or self.provider
success, provider_ref = await active_provider.pay(
user_id=payout.creator_id,
amount=payout.amount,
external_ref=payout.external_ref,
idempotency_key=f"payout-{payout.id}",
)
if not success:
raise RuntimeError("Payout provider rejected transaction")
now = datetime.now(timezone.utc)
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
cb.pending = max(0, cb.pending - payout.amount)
cb.updated_at = now
payout.status = PayoutStatus.paid
payout.provider_ref = provider_ref
payout.reviewed_by = reviewer_id
payout.reviewed_at = now
# Record immutable ledger audit row
wallet = await WalletService.get_or_create_wallet(db, payout.creator_id, for_update=True)
tx = Transaction(
wallet_id=wallet.id,
type=TransactionType.payout,
amount=-payout.amount,
balance_after=wallet.balance,
reference_type="payout_request",
reference_id=str(payout.id),
idempotency_key=f"payout-audit-{payout.id}",
metadata_json={
"provider_ref": provider_ref,
"reviewed_by": reviewer_id,
"external_ref": payout.external_ref,
},
)
db.add(tx)
await db.commit()
await db.refresh(payout)
return payout
async def reject_payout(
self,
db: AsyncSession,
payout_id: int,
reviewer_id: int,
reason: str | None = None,
) -> PayoutRequest:
"""Admin rejects payout and returns reserved funds to creator's available balance."""
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
res = await db.execute(stmt)
payout = res.scalar_one_or_none()
if payout is None:
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
if payout.status != PayoutStatus.pending:
raise InvalidPayoutStateError(
f"Cannot reject payout in status {payout.status.value}"
)
now = datetime.now(timezone.utc)
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
cb.pending = max(0, cb.pending - payout.amount)
cb.available += payout.amount
cb.updated_at = now
payout.status = PayoutStatus.rejected
payout.reviewed_by = reviewer_id
payout.reviewed_at = now
await db.commit()
await db.refresh(payout)
return payout
payout_service = PayoutService()
+150
View File
@@ -0,0 +1,150 @@
"""Redis-backed presence store.
Presence is determined by a heartbeat TTL key in Redis:
- heartbeat(user_id) sets a TTL key per user (default 60 s).
- is_online(user_id) / is_online_batch(user_ids) check whether the key(s)
exist.
status_message and last_seen_at are persisted in the ``users`` table by the
router (app/routers/presence.py); this store only owns the Redis side.
The Redis client is created lazily, on first actual use, rather than
requiring an explicit ``connect()`` call during app startup. This matters
because FastAPI's lifespan handler does not run under the plain
``TestClient(app)`` instantiation this codebase's tests use (no
``with`` block), so anything that only connected during lifespan startup
would never connect in tests. Lazy connection means the store works
identically whether the app was started for real or is being driven by
pytest.
The client is also cached per-*event loop* rather than globally: the same
plain ``TestClient(app)`` pattern can dispatch different requests onto
different event loops (see the NullPool comment on ``_override_db`` in
tests/conftest.py for the analogous asyncpg problem), and an
``aioredis.Redis`` connection, like an asyncpg connection, breaks with
"Event loop is closed" if reused across loops. In the real running server
there is exactly one event loop for the process lifetime, so this
degrades to a single cached, pooled connection there.
"""
from __future__ import annotations
import asyncio
import logging
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
# Key prefix for heartbeat entries.
_PRESENCE_KEY_PREFIX = "presence:heartbeat:"
# Default heartbeat TTL in seconds (1 minute).
# Clients should heartbeat at least once per interval.
_DEFAULT_HEARTBEAT_TTL = 60
class PresenceStore:
"""Redis-backed presence store with async, lazily-connected operations."""
def __init__(self, redis_url: str | None = None, heartbeat_ttl: int = _DEFAULT_HEARTBEAT_TTL):
# redis_url is resolved from settings at first use (not at
# construction time) so tests that override settings.REDIS_URL
# before the first call still take effect -- this module is
# imported (and the module-level singleton below constructed)
# well before any such override runs.
self._redis_url = redis_url
self._heartbeat_ttl = heartbeat_ttl
# Keyed by id(running loop) -- see the module docstring.
self._clients: dict[int, aioredis.Redis] = {}
self._connect_lock = asyncio.Lock()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def _ensure_client(self) -> aioredis.Redis:
"""Return a connected client for the current event loop, connecting
on first use per loop."""
loop_key = id(asyncio.get_running_loop())
client = self._clients.get(loop_key)
if client is None:
async with self._connect_lock:
client = self._clients.get(loop_key)
if client is None:
url = self._redis_url or settings.REDIS_URL
client = aioredis.from_url(
url,
encoding="utf-8",
decode_responses=True,
)
try:
await client.ping()
except Exception:
logger.warning("Redis ping failed for %s", url)
raise
self._clients[loop_key] = client
return client
async def close(self) -> None:
"""Close the Redis connection for the current event loop, if one was
opened."""
loop_key = id(asyncio.get_running_loop())
client = self._clients.pop(loop_key, None)
if client is not None:
await client.aclose()
# ------------------------------------------------------------------
# Heartbeat
# ------------------------------------------------------------------
async def heartbeat(self, user_id: int) -> None:
"""Record a heartbeat for *user_id*.
Sets a TTL key so that the user is considered online as long as
heartbeats arrive at least once per heartbeat_ttl seconds.
"""
client = await self._ensure_client()
await client.set(self._heartbeat_key(user_id), "1", ex=self._heartbeat_ttl)
async def clear_heartbeat(self, user_id: int) -> None:
"""Clear heartbeat for *user_id* to mark them offline immediately."""
client = await self._ensure_client()
await client.delete(self._heartbeat_key(user_id))
# ------------------------------------------------------------------
# Online checks
# ------------------------------------------------------------------
async def is_online(self, user_id: int) -> bool:
"""Return True if the user has a valid heartbeat key."""
client = await self._ensure_client()
exists = await client.exists(self._heartbeat_key(user_id))
return bool(exists)
async def is_online_batch(self, user_ids: list[int]) -> set[int]:
"""Return the subset of user_ids that currently have a valid
heartbeat key. Uses MGET (one round trip) rather than one EXISTS
per user.
"""
if not user_ids:
return set()
client = await self._ensure_client()
keys = [self._heartbeat_key(uid) for uid in user_ids]
values = await client.mget(keys)
return {uid for uid, v in zip(user_ids, values) if v is not None}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _heartbeat_key(user_id: int) -> str:
return f"{_PRESENCE_KEY_PREFIX}{user_id}"
# Singleton instance shared by the router, so all requests share the same
# connection pool.
presence_store = PresenceStore()
+323
View File
@@ -0,0 +1,323 @@
"""Marketplace purchase service: atomic escrow, fee split, item granting, refunds.
docs/planning/economy.md US-4 (task bc90d3f481b4).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import math
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
from app.models.wallet import TransactionType
from app.services.wallet import InsufficientFundsError, WalletService
MARKETPLACE_FEE_RATE = 0.30 # 30% platform fee
DEFAULT_ESCROW_HOLD_HOURS = 72
class ListingNotFoundError(Exception):
"""Raised when the specified listing does not exist."""
pass
class ListingNotAvailableError(Exception):
"""Raised when the listing is not in active status."""
pass
class SelfPurchaseError(Exception):
"""Raised when a seller attempts to buy their own listing."""
pass
class PurchaseNotFoundError(Exception):
"""Raised when the specified purchase does not exist."""
pass
class InvalidEscrowStateError(Exception):
"""Raised when an operation cannot be performed on the current escrow state."""
pass
class MatureContentRestrictedError(Exception):
"""Raised when an unverified minor attempts to purchase mature content."""
pass
def is_item_mature(item: CatalogItem) -> bool:
"""Check whether a catalog item carries mature flags or is marked mature."""
if not item.metadata_json or not isinstance(item.metadata_json, dict):
return False
if item.metadata_json.get("is_mature") or item.metadata_json.get("mature"):
return True
flags = item.metadata_json.get("content_flags", [])
if isinstance(flags, list):
for f in flags:
if isinstance(f, dict) and f.get("mature"):
return True
if isinstance(f, str) and any(
m in f for m in ("gore", "sexual", "gambling", "hate", "explicit")
):
return True
return False
class PurchaseService:
@staticmethod
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
"""Compute platform fee and seller net amount.
Returns (platform_fee, seller_net).
"""
platform_fee = math.floor(price * fee_rate)
seller_net = price - platform_fee
return platform_fee, seller_net
@classmethod
async def get_or_create_creator_balance(
cls, db: AsyncSession, user_id: int, for_update: bool = False
) -> CreatorBalance:
"""Fetch or lazily create a creator balance row for user_id."""
stmt = select(CreatorBalance).where(CreatorBalance.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
cb = result.scalar_one_or_none()
if cb is not None:
return cb
new_cb = CreatorBalance(
user_id=user_id,
available=0,
pending=0,
lifetime_earned=0,
)
db.add(new_cb)
await db.flush()
return new_cb
@classmethod
async def purchase_listing(
cls,
db: AsyncSession,
buyer_id: int,
listing_id: int,
buyer_is_adult: bool = True,
idempotency_key: str | None = None,
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
) -> tuple[Purchase, CatalogItem]:
"""Perform atomic purchase of a marketplace listing.
Debits buyer, holds escrow, grants item to buyer, records purchase,
and updates creator pending balance.
"""
# 1. Lock and validate listing
stmt_listing = (
select(MarketplaceListing)
.where(MarketplaceListing.id == listing_id)
.with_for_update()
)
res_listing = await db.execute(stmt_listing)
listing = res_listing.scalar_one_or_none()
if listing is None:
raise ListingNotFoundError(f"Listing {listing_id} not found")
if listing.status != ListingStatus.active:
raise ListingNotAvailableError(f"Listing {listing_id} is not active ({listing.status.value})")
if listing.seller_id == buyer_id:
raise SelfPurchaseError("Cannot purchase your own listing")
# 2. Fetch catalog item
catalog_item = await db.get(CatalogItem, listing.catalog_item_id)
if catalog_item is None:
raise ListingNotFoundError("Catalog item referenced by listing does not exist")
if is_item_mature(catalog_item) and not buyer_is_adult:
raise MatureContentRestrictedError(
"MATURE_CONTENT_REQUIRES_VERIFICATION: Buyer must be 18+ to purchase mature items"
)
# 3. Calculate fee split
price = listing.price
platform_fee, seller_net = cls.compute_fee_split(price)
# 4. Debit buyer wallet atomically (commit=False)
tx_idemp = f"mkt-purchase-{idempotency_key}" if idempotency_key else None
await WalletService.debit(
db=db,
user_id=buyer_id,
amount=price,
tx_type=TransactionType.marketplace_purchase,
reference_type="marketplace_listing",
reference_id=str(listing.id),
idempotency_key=tx_idemp,
commit=False,
)
# 5. Grant catalog item to buyer
granted_item = CatalogItem(
owner_id=buyer_id,
asset_id=catalog_item.asset_id,
item_type=catalog_item.item_type,
name=catalog_item.name,
description=catalog_item.description,
metadata_json=catalog_item.metadata_json,
)
db.add(granted_item)
# 6. Mark listing as sold
now = datetime.now(timezone.utc)
listing.status = ListingStatus.sold
listing.updated_at = now
# 7. Create purchase record
hold_until = now + timedelta(hours=escrow_hours)
purchase = Purchase(
listing_id=listing.id,
buyer_id=buyer_id,
seller_id=listing.seller_id,
price=price,
platform_fee=platform_fee,
seller_net=seller_net,
status=PurchaseStatus.pending_escrow,
escrow_hold_until=hold_until,
)
db.add(purchase)
# 8. Update seller's CreatorBalance (pending escrow)
creator_balance = await cls.get_or_create_creator_balance(
db, listing.seller_id, for_update=True
)
creator_balance.pending += seller_net
creator_balance.updated_at = now
# Commit everything atomically
await db.commit()
await db.refresh(purchase)
await db.refresh(granted_item)
return purchase, granted_item
@classmethod
async def release_escrow(cls, db: AsyncSession, purchase_id: int) -> Purchase:
"""Release funds from escrow to seller's available creator balance."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status != PurchaseStatus.pending_escrow:
raise InvalidEscrowStateError(
f"Cannot release escrow in status {purchase.status.value}"
)
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
cb.pending = max(0, cb.pending - purchase.seller_net)
cb.available += purchase.seller_net
cb.lifetime_earned += purchase.seller_net
cb.updated_at = datetime.now(timezone.utc)
purchase.status = PurchaseStatus.completed
await db.commit()
await db.refresh(purchase)
return purchase
@classmethod
async def refund_purchase(
cls, db: AsyncSession, purchase_id: int, reason: str | None = None
) -> Purchase:
"""Refund a purchase during the escrow hold window."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status != PurchaseStatus.pending_escrow:
raise InvalidEscrowStateError(
f"Cannot refund purchase in status {purchase.status.value}"
)
# Reverse seller's pending creator balance
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
cb.pending = max(0, cb.pending - purchase.seller_net)
cb.updated_at = datetime.now(timezone.utc)
# Credit buyer wallet
await WalletService.credit(
db=db,
user_id=purchase.buyer_id,
amount=purchase.price,
tx_type=TransactionType.refund,
reference_type="purchase_refund",
reference_id=str(purchase.id),
metadata={"reason": reason} if reason else None,
commit=False,
)
purchase.status = PurchaseStatus.refunded
await db.commit()
await db.refresh(purchase)
return purchase
@classmethod
async def reverse_purchase(
cls,
db: AsyncSession,
purchase_id: int,
moderator_id: int,
reason: str,
) -> Purchase:
"""Moderator/admin audited reversal of purchase."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status in (PurchaseStatus.reversed, PurchaseStatus.refunded):
raise InvalidEscrowStateError(f"Purchase already {purchase.status.value}")
# Debit seller creator balance (from pending if in escrow, else available)
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
if purchase.status == PurchaseStatus.pending_escrow:
cb.pending = max(0, cb.pending - purchase.seller_net)
else:
cb.available = max(0, cb.available - purchase.seller_net)
cb.updated_at = datetime.now(timezone.utc)
# Refund buyer wallet
await WalletService.credit(
db=db,
user_id=purchase.buyer_id,
amount=purchase.price,
tx_type=TransactionType.refund,
reference_type="purchase_reversal",
reference_id=str(purchase.id),
metadata={"moderator_id": moderator_id, "reason": reason},
commit=False,
)
purchase.status = PurchaseStatus.reversed
await db.commit()
await db.refresh(purchase)
return purchase
purchase_service = PurchaseService()
+304
View File
@@ -0,0 +1,304 @@
"""Pluggable server-side text filtering, `docs/planning/social.md` §4/§5 (US-4).
social.md §4 calls for a "Pluggable `TextFilter` interface with a
deterministic default (profanity list + normalization) so it runs without
external services; third-party hook later". This module is that interface
plus the default implementation:
- `TextFilter` is a `Protocol` -- the extension point. A future third-party
moderation provider (a paid API, an ML classifier, etc.) just needs to
implement `check(text) -> FilterResult`; nothing else in this module
needs to change, and callers depend on the protocol, not a concrete
class.
- `ListBasedTextFilter` is the deterministic default: a configurable
term list (JSON file, see `text_filter_terms.json` next to this module)
matched against a normalized form of the input text so that trivial
evasion (case changes, leetspeak substitutions, and punctuation/spacing
inserted between letters) doesn't slip through.
Nothing here talks to a database, an HTTP client, or the FastAPI app -- it
is a pure, side-effect-free (aside from reading the term list file once, at
construction) service so it can be unit tested in isolation. It is NOT
wired into any router/endpoint by this task; that's for the chat feature
tasks that depend on it (social.md §6) to do.
Normalization approach
-----------------------
Matching naively against raw text misses "sh1t", "@ss", or "s.h.i.t". To
catch these without a real tokenizer, every character of the input is
independently normalized to at most one lowercase ASCII alphanumeric
character (case-folded, then run through a small leetspeak substitution
table); characters that don't reduce to `[a-z0-9]` (punctuation, spacing,
symbols, non-ASCII) are dropped entirely rather than kept as separators.
This collapses "s.h.i.t", "s h i t", and "SHIT" to the same normalized
string ("shit") that a plain lowercase term list entry matches, while a
side table (`index_map`) remembers which original character produced each
surviving normalized character -- so a match found in the normalized
string can still be translated back into a span of the *original* text for
redaction.
This is a deliberate precision/recall tradeoff common to list-based
filters: stripping separators and folding leetspeak increases recall
against evasion at the cost of occasionally matching a term inside an
unrelated longer word (e.g. a 3-letter term inside a longer benign word).
That tradeoff is exactly why this is pluggable -- a deployment that needs
tighter precision can swap in a smarter `TextFilter` implementation behind
the same protocol.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Iterable, Protocol, runtime_checkable
# ---------------------------------------------------------------------------
# Public result / protocol
# ---------------------------------------------------------------------------
class FilterAction(str, Enum):
"""What the caller should do with a checked message."""
ALLOW = "allow"
REDACT = "redact"
REJECT = "reject"
@dataclass(frozen=True)
class FilterResult:
"""Outcome of running a piece of text through a `TextFilter`.
- `action`: ALLOW (no matches), REDACT (matches replaced in
`filtered_text`), or REJECT (matches found; the message should be
blocked -- callers should not persist/deliver it even though
`filtered_text` is populated for logging/audit purposes).
- `filtered_text`: the text to actually store/deliver. Equal to the
input for ALLOW and REJECT; has matched spans replaced for REDACT.
- `matched_terms`: the distinct matched terms (original, un-normalized
list entries), in first-occurrence order. Empty for ALLOW.
"""
action: FilterAction
filtered_text: str
matched_terms: tuple[str, ...] = ()
@runtime_checkable
class TextFilter(Protocol):
"""Extension point for text filtering/moderation providers.
Any implementation -- the deterministic default below, or a future
third-party service call -- just needs this one method.
"""
def check(self, text: str) -> FilterResult:
"""Check `text` and return the action to take plus filtered text."""
...
class FilterMode(str, Enum):
"""Configures what `ListBasedTextFilter` does when it finds a match."""
REDACT = "redact"
REJECT = "reject"
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
# Common leetspeak / lookalike substitutions, applied after case-folding.
# Keys are already-lowercased single characters.
_LEET_MAP: dict[str, str] = {
"0": "o",
"1": "i",
"!": "i",
"3": "e",
"4": "a",
"@": "a",
"5": "s",
"$": "s",
"7": "t",
"+": "t",
"8": "b",
"|": "l",
}
_ALNUM_RE = re.compile(r"[a-z0-9]")
def _normalize(text: str) -> tuple[str, list[int]]:
"""Fold `text` to a lowercase-alnum-only string plus an index map.
Returns `(normalized, index_map)` where `index_map[i]` is the index
into the *original* `text` that produced `normalized[i]`. Characters
that don't reduce to a single `[a-z0-9]` character (punctuation,
whitespace, symbols, most non-ASCII) are dropped from both outputs.
"""
normalized_chars: list[str] = []
index_map: list[int] = []
for i, ch in enumerate(text):
folded = ch.casefold()
# case-fold can expand to multiple characters for some unicode
# (e.g. German "ß" -> "ss"); such characters are not part of the
# leetspeak table and are not plain ascii alnum, so just skip them
# like any other non-matching character rather than guessing.
if len(folded) != 1:
continue
folded = _LEET_MAP.get(folded, folded)
if _ALNUM_RE.fullmatch(folded):
normalized_chars.append(folded)
index_map.append(i)
return "".join(normalized_chars), index_map
def _normalize_term(term: str) -> str:
normalized, _ = _normalize(term)
return normalized
# ---------------------------------------------------------------------------
# Default term list loading
# ---------------------------------------------------------------------------
DEFAULT_TERMS_PATH = Path(__file__).with_name("text_filter_terms.json")
def load_terms(path: Path | str | None = None) -> list[str]:
"""Load a term list from a JSON file shaped `{"terms": [...]}`.
Any other top-level keys (e.g. `_readme`) are ignored. Defaults to the
bundled `text_filter_terms.json` next to this module.
"""
resolved = Path(path) if path is not None else DEFAULT_TERMS_PATH
with resolved.open("r", encoding="utf-8") as f:
data = json.load(f)
terms = data.get("terms", [])
if not isinstance(terms, list):
raise ValueError(f"{resolved}: 'terms' must be a list")
return [str(t) for t in terms]
# ---------------------------------------------------------------------------
# Span merging / redaction helpers
# ---------------------------------------------------------------------------
def _merge_spans(spans: list[tuple[int, int, str]]) -> list[tuple[int, int]]:
"""Merge overlapping/adjacent (start, end) spans, sorted by start."""
if not spans:
return []
ordered = sorted((s, e) for s, e, _ in spans)
merged: list[tuple[int, int]] = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def _apply_redactions(text: str, merged_spans: list[tuple[int, int]], redaction_char: str) -> str:
if not merged_spans:
return text
pieces: list[str] = []
cursor = 0
for start, end in merged_spans:
pieces.append(text[cursor:start])
pieces.append(redaction_char * (end - start))
cursor = end
pieces.append(text[cursor:])
return "".join(pieces)
# ---------------------------------------------------------------------------
# Default implementation
# ---------------------------------------------------------------------------
class ListBasedTextFilter:
"""Deterministic `TextFilter`: normalized substring match against a term list.
`mode` controls what happens on a match:
- `FilterMode.REDACT` (default): matched spans in the *original* text
are replaced with `redaction_char` repeated to the matched span's
original length; `action` is REDACT if anything matched, else ALLOW.
- `FilterMode.REJECT`: `filtered_text` is returned unchanged but
`action` is REJECT if anything matched (callers should treat this as
"do not store/deliver"), else ALLOW.
An empty or whitespace-only string always returns ALLOW with no
matches -- there is nothing to filter.
"""
def __init__(
self,
terms: Iterable[str] | None = None,
*,
mode: FilterMode = FilterMode.REDACT,
terms_path: Path | str | None = None,
redaction_char: str = "*",
) -> None:
if terms is None:
terms = load_terms(terms_path)
# De-dupe while preserving order; drop blanks.
seen: dict[str, None] = {}
for t in terms:
t = t.strip()
if t:
seen.setdefault(t, None)
self._terms: tuple[str, ...] = tuple(seen)
# Pre-normalize once at construction; skip terms that normalize to
# nothing (e.g. a term that's pure punctuation).
self._normalized_terms: list[tuple[str, str]] = [
(normalized, original)
for original in self._terms
if (normalized := _normalize_term(original))
]
self.mode = mode
self.redaction_char = redaction_char
def check(self, text: str) -> FilterResult:
if not text or not text.strip():
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
normalized, index_map = _normalize(text)
if not normalized:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
spans: list[tuple[int, int, str]] = []
for normalized_term, original_term in self._normalized_terms:
start = 0
term_len = len(normalized_term)
while True:
idx = normalized.find(normalized_term, start)
if idx == -1:
break
end = idx + term_len
orig_start = index_map[idx]
orig_end = index_map[end - 1] + 1
spans.append((orig_start, orig_end, original_term))
start = end # non-overlapping: resume after this match
if not spans:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
spans.sort(key=lambda s: s[0])
matched_terms = tuple(dict.fromkeys(term for _, _, term in spans))
if self.mode is FilterMode.REJECT:
return FilterResult(action=FilterAction.REJECT, filtered_text=text, matched_terms=matched_terms)
merged = _merge_spans(spans)
filtered_text = _apply_redactions(text, merged, self.redaction_char)
return FilterResult(action=FilterAction.REDACT, filtered_text=filtered_text, matched_terms=matched_terms)
def default_text_filter(mode: FilterMode = FilterMode.REDACT) -> ListBasedTextFilter:
"""Convenience factory for the bundled default term list."""
return ListBasedTextFilter(mode=mode)
@@ -0,0 +1,20 @@
{
"_readme": "Default deterministic term list for app.services.text_filter.ListBasedTextFilter. This is a minimal, illustrative starter list (common mild-to-strong English profanity, no slurs) meant to be replaced/extended per-deployment -- swap this file, or pass an explicit `terms`/`terms_path` to the filter, rather than editing this list in place for production moderation policy.",
"terms": [
"fuck",
"shit",
"ass",
"asshole",
"bitch",
"bastard",
"damn",
"crap",
"dick",
"piss",
"slut",
"whore",
"cunt",
"douche",
"bullshit"
]
}
+208
View File
@@ -0,0 +1,208 @@
"""Wallet service: atomic credit/debit, row locking, and idempotency.
docs/planning/economy.md US-1 (task 909e3e9a59af).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.wallet import Transaction, TransactionType, Wallet
logger = logging.getLogger(__name__)
class InsufficientFundsError(Exception):
"""Raised when a debit would cause wallet balance to drop below zero."""
pass
class InvalidAmountError(ValueError):
"""Raised when an invalid (non-positive) amount is supplied."""
pass
class WalletService:
@staticmethod
async def get_or_create_wallet(
db: AsyncSession, user_id: int, for_update: bool = False
) -> Wallet:
"""Fetch existing wallet or lazily create one for user_id.
If for_update is True, locks the row with SELECT ... FOR UPDATE.
"""
stmt = select(Wallet).where(Wallet.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
wallet = result.scalar_one_or_none()
if wallet is not None:
return wallet
# If not found and not in update mode, create lazily
try:
new_wallet = Wallet(user_id=user_id, balance=0)
db.add(new_wallet)
await db.commit()
await db.refresh(new_wallet)
if for_update:
stmt_locked = select(Wallet).where(Wallet.id == new_wallet.id).with_for_update()
res = await db.execute(stmt_locked)
return res.scalar_one()
return new_wallet
except IntegrityError:
# Concurrent insert collision -- rollback and fetch the existing one
await db.rollback()
stmt = select(Wallet).where(Wallet.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one()
@classmethod
async def credit(
cls,
db: AsyncSession,
user_id: int,
amount: int,
tx_type: TransactionType | str,
reference_type: str | None = None,
reference_id: str | None = None,
idempotency_key: str | None = None,
metadata: dict[str, Any] | None = None,
commit: bool = True,
) -> tuple[Wallet, Transaction]:
"""Credit a user's wallet atomically with row locking.
If an idempotency_key is provided and already exists, returns the original
outcome without double-crediting.
"""
if amount <= 0:
raise InvalidAmountError("Credit amount must be strictly positive")
if isinstance(tx_type, str):
tx_type = TransactionType(tx_type)
# Check idempotency first
if idempotency_key:
stmt = select(Transaction).where(Transaction.idempotency_key == idempotency_key)
existing_tx = (await db.execute(stmt)).scalar_one_or_none()
if existing_tx is not None:
wallet = await cls.get_or_create_wallet(db, user_id, for_update=False)
return wallet, existing_tx
# Lock wallet row
wallet = await cls.get_or_create_wallet(db, user_id, for_update=True)
wallet.balance += amount
tx = Transaction(
wallet_id=wallet.id,
type=tx_type,
amount=amount,
balance_after=wallet.balance,
reference_type=reference_type,
reference_id=reference_id,
idempotency_key=idempotency_key,
metadata_json=metadata,
)
db.add(tx)
if commit:
await db.commit()
await db.refresh(wallet)
await db.refresh(tx)
else:
await db.flush()
return wallet, tx
@classmethod
async def debit(
cls,
db: AsyncSession,
user_id: int,
amount: int,
tx_type: TransactionType | str,
reference_type: str | None = None,
reference_id: str | None = None,
idempotency_key: str | None = None,
metadata: dict[str, Any] | None = None,
commit: bool = True,
) -> tuple[Wallet, Transaction]:
"""Debit a user's wallet atomically with row locking and balance check.
Raises InsufficientFundsError if wallet balance < amount.
If idempotency_key is provided and already exists, returns the original
outcome without double-debiting.
"""
if amount <= 0:
raise InvalidAmountError("Debit amount must be strictly positive")
if isinstance(tx_type, str):
tx_type = TransactionType(tx_type)
# Check idempotency first
if idempotency_key:
stmt = select(Transaction).where(Transaction.idempotency_key == idempotency_key)
existing_tx = (await db.execute(stmt)).scalar_one_or_none()
if existing_tx is not None:
wallet = await cls.get_or_create_wallet(db, user_id, for_update=False)
return wallet, existing_tx
# Lock wallet row
wallet = await cls.get_or_create_wallet(db, user_id, for_update=True)
cur_balance = wallet.balance
if cur_balance < amount:
if commit:
await db.rollback()
raise InsufficientFundsError(
f"Insufficient funds: available {cur_balance}, required {amount}"
)
wallet.balance -= amount
tx = Transaction(
wallet_id=wallet.id,
type=tx_type,
amount=-amount,
balance_after=wallet.balance,
reference_type=reference_type,
reference_id=reference_id,
idempotency_key=idempotency_key,
metadata_json=metadata,
)
db.add(tx)
if commit:
await db.commit()
await db.refresh(wallet)
await db.refresh(tx)
else:
await db.flush()
return wallet, tx
@staticmethod
async def get_transactions(
db: AsyncSession, wallet_id: int, limit: int = 50, offset: int = 0
) -> tuple[list[Transaction], int]:
"""Fetch paged transactions for a wallet ordered newest-first, plus total count."""
count_stmt = select(func.count()).select_from(Transaction).where(Transaction.wallet_id == wallet_id)
total = (await db.execute(count_stmt)).scalar() or 0
stmt = (
select(Transaction)
.where(Transaction.wallet_id == wallet_id)
.order_by(Transaction.created_at.desc(), Transaction.id.desc())
.offset(offset)
.limit(limit)
)
res = await db.execute(stmt)
return list(res.scalars().all()), total
wallet_service = WalletService()
+431
View File
@@ -0,0 +1,431 @@
"""World content storage service.
Implements the `world.md` §5.1 service interface over Postgres, per
`world_persistence_impl.md` §9 -- **minus** ``flush_dirty`` and
``rebuild_index``, which don't apply once there is no write-behind dirty
set and no derived index to rebuild (see that doc's §3.3 and §4.2).
This is the *only* place chunk content is written (world_persistence_impl.md
D2 / §5). Callers (the routers) own transaction boundaries: methods here
``flush()`` so later statements in the same request see earlier ones and so
integrity errors surface before a caller commits, but they never
``commit()`` -- that mirrors the existing convention in
``app/routers/worlds.py`` (``get_db`` docs: "Handlers are responsible for
committing or rolling back their own transactions").
Known gap this module had to resolve on its own (flagged, not silently
decided): `world_persistence_impl.md` §6/§7.2/§8.1 all reference a world's
monotonic ``content_revision`` ("bumped on every chunk write"), but no
column for it is defined anywhere in §4's schema. Rather than add an
undocumented column, ``content_revision()`` below derives it as
``MAX(id)`` over ``world_chunk_revisions`` scoped to the world: that table
is append-only (retention only nulls ``payload``, it never deletes rows),
so the value is genuinely monotonic and increments on every
create/update/delete/rollback.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import load_only
from app.config import settings
from app.models.world_content import (
MAIN_ROOM_NAME,
MAIN_ROOM_SLUG,
WorldChunk,
WorldChunkRevision,
WorldRoom,
)
from app.services import chunk_codec
class RevisionConflict(Exception):
"""Optimistic-concurrency conflict (world.md §5.4). ``current_revision``
is 0 and ``current_checksum`` is None when the caller's ``base_revision``
assumed a chunk exists but it doesn't."""
def __init__(
self,
current_revision: int,
current_checksum: str | None,
coord: tuple[int, int, int] | None = None,
) -> None:
self.current_revision = current_revision
self.current_checksum = current_checksum
self.coord = coord
super().__init__(f"revision conflict: current_revision={current_revision}")
class RevisionNotAvailable(Exception):
"""Raised by ``rollback_chunk`` when the requested revision doesn't
exist, or exists but its payload was pruned by the retention policy
(world_persistence_impl.md §4.3)."""
@dataclass
class Bounds:
cx_min: int | None = None
cx_max: int | None = None
cy_min: int | None = None
cy_max: int | None = None
cz: int | None = None
class WorldStore:
"""Owns chunk/room read/write operations over the async session
handed to it. One instance per request is the expected usage
(``WorldStore(db)`` where ``db`` is the ``DbDep`` session)."""
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ── rooms ────────────────────────────────────────────────────────────
async def get_room(self, world_id: int, slug: str) -> WorldRoom | None:
result = await self.db.execute(
select(WorldRoom).where(WorldRoom.world_id == world_id, WorldRoom.slug == slug)
)
return result.scalar_one_or_none()
async def list_rooms(self, world_id: int) -> list[WorldRoom]:
result = await self.db.execute(
select(WorldRoom).where(WorldRoom.world_id == world_id).order_by(WorldRoom.id)
)
return list(result.scalars().all())
async def create_room(
self,
world_id: int,
slug: str,
name: str,
*,
grid: dict | None = None,
bounds: dict | None = None,
spawn: dict | None = None,
) -> WorldRoom:
room = WorldRoom(world_id=world_id, slug=slug, name=name)
if grid is not None:
room.grid = grid
if bounds is not None:
room.bounds = bounds
if spawn is not None:
room.spawn = spawn
self.db.add(room)
await self.db.flush()
await self.db.refresh(room)
return room
async def get_or_create_main_room(self, world_id: int) -> WorldRoom:
"""world_persistence_impl.md §4.1: every world gets a ``main`` room.
Called from ``create_world`` for new worlds, and lazily here for
worlds that predate this migration."""
room = await self.get_room(world_id, MAIN_ROOM_SLUG)
if room is not None:
return room
try:
return await self.create_room(world_id, MAIN_ROOM_SLUG, MAIN_ROOM_NAME)
except IntegrityError:
# Lost a race with a concurrent creator; the row now exists.
await self.db.rollback()
room = await self.get_room(world_id, MAIN_ROOM_SLUG)
if room is None:
raise
return room
async def count_chunks(self, room_id: int) -> int:
result = await self.db.execute(
select(func.count()).select_from(WorldChunk).where(WorldChunk.room_id == room_id)
)
return int(result.scalar_one())
async def content_revision(self, world_id: int) -> int:
"""See module docstring for why this is derived rather than stored."""
result = await self.db.execute(
select(func.max(WorldChunkRevision.id)).where(WorldChunkRevision.world_id == world_id)
)
return int(result.scalar() or 0)
# ── chunk reads ──────────────────────────────────────────────────────
async def get_chunk(self, key: str) -> WorldChunk | None:
result = await self.db.execute(select(WorldChunk).where(WorldChunk.chunk_key == key))
return result.scalar_one_or_none()
async def get_chunk_by_coord(self, room_id: int, cx: int, cy: int, cz: int) -> WorldChunk | None:
result = await self.db.execute(
select(WorldChunk).where(
WorldChunk.room_id == room_id, WorldChunk.cx == cx,
WorldChunk.cy == cy, WorldChunk.cz == cz,
)
)
return result.scalar_one_or_none()
async def get_chunk_binary(self, key: str) -> bytes | None:
chunk = await self.get_chunk(key)
return chunk.payload if chunk is not None else None
async def get_chunk_flags(self, key: str) -> dict | None:
chunk = await self.get_chunk(key)
return chunk.content_flags if chunk is not None else None
async def list_chunks(
self,
world_id: int,
room_id: int | None = None,
bounds: Bounds | None = None,
) -> list[WorldChunk]:
"""Index query -- world.md §5.1's ``list_chunks``. Payload bytes are
deliberately not loaded (world_persistence_impl.md §8.1: "Payloads
are not included")."""
query = select(WorldChunk).options(
load_only(
WorldChunk.id, WorldChunk.world_id, WorldChunk.room_id, WorldChunk.chunk_key,
WorldChunk.cx, WorldChunk.cy, WorldChunk.cz, WorldChunk.revision,
WorldChunk.checksum, WorldChunk.payload_len, WorldChunk.is_mature,
WorldChunk.updated_at,
)
).where(WorldChunk.world_id == world_id)
if room_id is not None:
query = query.where(WorldChunk.room_id == room_id)
if bounds is not None:
if bounds.cx_min is not None:
query = query.where(WorldChunk.cx >= bounds.cx_min)
if bounds.cx_max is not None:
query = query.where(WorldChunk.cx <= bounds.cx_max)
if bounds.cy_min is not None:
query = query.where(WorldChunk.cy >= bounds.cy_min)
if bounds.cy_max is not None:
query = query.where(WorldChunk.cy <= bounds.cy_max)
if bounds.cz is not None:
query = query.where(WorldChunk.cz == bounds.cz)
query = query.order_by(WorldChunk.cx, WorldChunk.cy, WorldChunk.cz)
result = await self.db.execute(query)
return list(result.scalars().all())
# ── chunk writes ─────────────────────────────────────────────────────
async def put_chunk(
self,
*,
room: WorldRoom,
cx: int,
cy: int,
cz: int,
payload_bytes: bytes,
base_revision: int,
actor_id: int | None,
) -> WorldChunk:
"""Full-chunk write with optimistic concurrency (world.md §5.4,
world_persistence_impl.md §5.4/§8.2). Validates the payload
server-side (§5.5) before ever touching a row. ``base_revision=0``
means "create; 409 if it already exists"."""
key = chunk_codec.build_key(room.world_id, room.slug, cx, cy, cz)
grid_size = tuple(room.grid.get("size", [32, 32, 1]))
# Raises ChunkCodecError / ChunkValidationError -- the router maps
# those to 422. Nothing below trusts the request body's own claims
# about content_flags/is_mature; both come from `decoded`.
decoded = chunk_codec.decode_and_validate(
payload_bytes,
expected_key=key,
grid_size=grid_size,
max_objects=settings.WORLD_CHUNK_MAX_OBJECTS,
)
body_checksum = chunk_codec.checksum(payload_bytes[chunk_codec.HEADER_SIZE:])
result = await self.db.execute(
select(WorldChunk)
.where(
WorldChunk.room_id == room.id, WorldChunk.cx == cx,
WorldChunk.cy == cy, WorldChunk.cz == cz,
)
.with_for_update()
)
row = result.scalar_one_or_none()
if base_revision == 0:
if row is not None:
raise RevisionConflict(row.revision, row.checksum, (cx, cy, cz))
row = WorldChunk(
world_id=room.world_id, room_id=room.id, chunk_key=key,
cx=cx, cy=cy, cz=cz, revision=1,
)
self.db.add(row)
action = "create"
else:
if row is None or row.revision != base_revision:
raise RevisionConflict(
row.revision if row else 0, row.checksum if row else None, (cx, cy, cz)
)
row.revision = row.revision + 1
action = "update"
row.format_version = decoded.version
row.payload = payload_bytes
row.payload_len = len(payload_bytes)
row.checksum = body_checksum
row.content_flags = decoded.flags.to_dict()
row.is_mature = decoded.flags.mature
row.updated_by_id = actor_id
try:
await self.db.flush()
except IntegrityError:
# Lost a create race to a concurrent writer between our SELECT
# and this flush. Roll back and report the real current state.
await self.db.rollback()
existing = await self.get_chunk_by_coord(room.id, cx, cy, cz)
raise RevisionConflict(
existing.revision if existing else 0,
existing.checksum if existing else None,
(cx, cy, cz),
)
await self._record_revision(key, room.world_id, row.revision, action, actor_id, body_checksum, payload_bytes)
await self.db.refresh(row)
return row
async def put_chunks_batch(
self,
*,
room: WorldRoom,
items: list[dict],
actor_id: int | None,
) -> list[WorldChunk]:
"""Batch write (world_persistence_impl.md §8.1). One transaction,
all-or-nothing: none of this ``commit()``s, so a ``RevisionConflict``
raised partway leaves nothing persisted once the caller's request
handler lets the exception (or the HTTPException it becomes)
propagate through ``get_db``, which rolls back on any exception."""
rows: list[WorldChunk] = []
for item in items:
row = await self.put_chunk(
room=room, cx=item["cx"], cy=item["cy"], cz=item["cz"],
payload_bytes=item["payload_bytes"], base_revision=item["base_revision"],
actor_id=actor_id,
)
rows.append(row)
return rows
async def delete_chunk(self, key: str, actor_id: int | None) -> None:
row = await self.get_chunk(key)
if row is None:
return
await self._record_revision(key, row.world_id, row.revision, "delete", actor_id, row.checksum, None)
await self.db.delete(row)
await self.db.flush()
async def rollback_chunk(self, key: str, revision: int, actor_id: int | None) -> WorldChunk:
"""Restore a chunk to a previously-recorded revision's payload
(world.md §5.1). Raises ``RevisionNotAvailable`` if that revision
was never recorded, its payload was pruned by retention, or the
chunk has since been deleted outright (nothing to update in place)."""
rev_result = await self.db.execute(
select(WorldChunkRevision)
.where(WorldChunkRevision.chunk_key == key, WorldChunkRevision.revision == revision)
.order_by(WorldChunkRevision.id.desc())
.limit(1)
)
rev_row = rev_result.scalar_one_or_none()
if rev_row is None:
raise RevisionNotAvailable(f"no revision {revision} recorded for {key!r}")
if rev_row.payload is None:
raise RevisionNotAvailable(f"revision {revision} of {key!r} was pruned; payload unavailable")
chunk = await self.get_chunk(key)
if chunk is None:
raise RevisionNotAvailable(f"chunk {key!r} no longer exists; cannot roll back in place")
room_result = await self.db.execute(select(WorldRoom).where(WorldRoom.id == chunk.room_id))
room = room_result.scalar_one()
grid_size = tuple(room.grid.get("size", [32, 32, 1]))
decoded = chunk_codec.decode_and_validate(
rev_row.payload, expected_key=key, grid_size=grid_size,
max_objects=settings.WORLD_CHUNK_MAX_OBJECTS,
)
body_checksum = chunk_codec.checksum(rev_row.payload[chunk_codec.HEADER_SIZE:])
chunk.revision = chunk.revision + 1
chunk.payload = rev_row.payload
chunk.payload_len = len(rev_row.payload)
chunk.checksum = body_checksum
chunk.format_version = decoded.version
chunk.content_flags = decoded.flags.to_dict()
chunk.is_mature = decoded.flags.mature
chunk.updated_by_id = actor_id
await self.db.flush()
# Tagged "update" (not a fourth action value) to keep the
# documented 'create'|'update'|'delete' action enum intact.
await self._record_revision(
key, chunk.world_id, chunk.revision, "update", actor_id, body_checksum, rev_row.payload
)
await self.db.refresh(chunk)
return chunk
async def list_revisions(self, chunk_key: str, limit: int = 50) -> list[WorldChunkRevision]:
"""Not in world.md §5.1's interface by name, but required to
implement the revision-history endpoint world_persistence_impl.md
§8.1 lists (``GET .../revisions``)."""
result = await self.db.execute(
select(WorldChunkRevision)
.where(WorldChunkRevision.chunk_key == chunk_key)
.order_by(WorldChunkRevision.revision.desc(), WorldChunkRevision.id.desc())
.limit(limit)
)
return list(result.scalars().all())
async def migrate(self) -> None:
"""No-op. `world.md` §4.8's hand-rolled SQLite migration runner is
superseded by Alembic (`backend/alembic/`) now that content lives
in Postgres (world_persistence_impl.md §3.3). Kept only so this
class's shape still matches the §5.1 interface it's implementing
a subset of."""
return None
# ── internals ────────────────────────────────────────────────────────
async def _record_revision(
self,
chunk_key: str,
world_id: int,
revision: int,
action: str,
actor_id: int | None,
checksum_hex: str,
payload_bytes: bytes | None,
) -> None:
"""Append an audit row and enforce the retention policy
(world_persistence_impl.md §4.3, required): keep the newest
``WORLD_CHUNK_REVISION_KEEP`` payloads per ``chunk_key``, null out
``payload`` on older rows, in the same transaction as the write."""
rev = WorldChunkRevision(
chunk_key=chunk_key, world_id=world_id, revision=revision, action=action,
actor_id=actor_id, checksum=checksum_hex, payload=payload_bytes,
)
self.db.add(rev)
await self.db.flush()
keep = settings.WORLD_CHUNK_REVISION_KEEP
keep_ids_result = await self.db.execute(
select(WorldChunkRevision.id)
.where(WorldChunkRevision.chunk_key == chunk_key)
.order_by(WorldChunkRevision.id.desc())
.limit(keep)
)
keep_ids = [row[0] for row in keep_ids_result.all()]
if keep_ids:
await self.db.execute(
update(WorldChunkRevision)
.where(
WorldChunkRevision.chunk_key == chunk_key,
WorldChunkRevision.id.notin_(keep_ids),
WorldChunkRevision.payload.isnot(None),
)
.values(payload=None)
)
+119 -39
View File
@@ -1,18 +1,22 @@
"""Shared pytest fixtures + hermetic-DB bootstrap.
"""Shared pytest fixtures + hermetic-DB/Redis bootstrap.
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
with no manual setup, ``pytest_configure`` below brings up an ephemeral
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
not already pointing at a reachable database. When it is (dev loop, or the
checkpoint runner which stands the DB up itself), that path is skipped and
the existing database is reused.
native ENUMs) and, for presence tests, a real Redis (TTL semantics aren't
worth faking). To make ``cd backend && pytest`` work from a clean checkout
with no manual setup, ``pytest_configure`` below brings up ephemeral
Postgres/Redis containers via docker compose and runs the migrations IF
``DATABASE_URL``/``REDIS_URL`` aren't already pointing at something
reachable. When they are (dev loop, or the checkpoint runner which stands
these up itself), that path is skipped and the existing services are
reused.
If Docker isn't available and no DB is reachable, the whole DB-backed suite
is skipped with a clear message rather than erroring out 60+ times.
If Docker isn't available and nothing is reachable, the whole
DB/Redis-backed suite is skipped with a clear message rather than erroring
out dozens of times.
"""
import asyncio
import hashlib
import os
import socket
import subprocess
@@ -30,13 +34,58 @@ from app.config import settings
from app.database import get_db
from app.main import app
TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users")
TEST_TABLES = (
"avatar_loadouts", "assets",
# Profile (app/models/profile.py) -- both FK users.id, profile_favorites
# additionally FKs worlds.id; list explicitly for clarity/consistency.
"profile_favorites", "profiles",
# world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 /
# world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world
# must not lose the audit trail), so it won't be cascade-truncated via
# `worlds` the way world_rooms/world_chunks would be. List all three
# explicitly rather than rely on cascade for some of them.
"world_chunk_revisions", "world_chunks", "world_rooms",
# Discovery (app/models/world_visit.py) -- FK worlds.id with ON DELETE CASCADE
"world_visits",
"worlds",
# Social graph (app/models/social.py) -- all three FK users with
# ON DELETE CASCADE, but list explicitly for clarity/consistency.
"friendships", "follows", "blocks",
# Chat (app/models/chat.py). message_reports.message_id is ON DELETE SET
# NULL (not CASCADE, deliberately -- see chat.py), so it wouldn't be
# reached by cascading a truncate of messages/conversations; list it
# explicitly along with the rest for clarity/consistency.
"message_reports", "messages", "conversation_participants", "conversations",
# Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
# by cascading a truncate of users; list it explicitly for clarity too.
"group_members", "groups",
# Economy (app/models/wallet.py, app/models/marketplace.py)
"payout_requests", "purchases", "marketplace_listings", "catalog_items", "creator_balances",
"transactions", "wallets",
"sessions", "refresh_tokens", "users",
)
BACKEND_DIR = Path(__file__).resolve().parents[1]
_COMPOSE_PROJECT = "agp-pytest"
_EPHEMERAL_PG_PORT = 25432
# Set by _ensure_database() when it starts a container it owns; torn down in
# A hardcoded project name/port here would mean every concurrent checkout
# running `pytest` (e.g. several Claude Agent-tool subagents, each in their
# own ~/agp/workspace/task_<id> clone) fights over the exact same Docker
# Compose project and host ports -- one run's pytest_unconfigure teardown
# can yank the ephemeral Postgres out from under a completely unrelated
# concurrent run, surfacing as "connection was closed in the middle of
# operation" with no obvious cause. Confirmed live 2026-09-15 running 3
# parallel subagents. 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 port from a hash of the checkout
# directory, so different concurrent checkouts land on different projects/
# ports while the same checkout is stable across repeated runs.
_WORKSPACE_HASH = hashlib.sha256(str(BACKEND_DIR.parent).encode()).hexdigest()[:10]
_COMPOSE_PROJECT = f"agp-pytest-{_WORKSPACE_HASH}"
_EPHEMERAL_PG_PORT = 25400 + (int(_WORKSPACE_HASH, 16) % 100)
_EPHEMERAL_REDIS_PORT = 26300 + (int(_WORKSPACE_HASH, 16) % 100)
# Set by pytest_configure() when it starts container(s) it owns; torn down in
# pytest_unconfigure.
_started_container = False
_db_unavailable_reason: str | None = None
@@ -55,6 +104,11 @@ def _db_reachable(url: str) -> bool:
return _port_open(parts.hostname or "localhost", parts.port or 5432)
def _redis_reachable(url: str) -> bool:
parts = urlsplit(url)
return _port_open(parts.hostname or "localhost", parts.port or 6379)
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw)
@@ -62,14 +116,17 @@ def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
def pytest_configure(config):
global _started_container, _db_unavailable_reason
if _db_reachable(settings.DATABASE_URL):
return # dev loop / checkpoint runner already provided one
need_db = not _db_reachable(settings.DATABASE_URL)
need_redis = not _redis_reachable(settings.REDIS_URL)
if not need_db and not need_redis:
return # dev loop / checkpoint runner already provided both
if not _run(["docker", "--version"]).returncode == 0:
_db_unavailable_reason = (
"no reachable DATABASE_URL and docker is unavailable -- run the "
"checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres "
"manually"
"no reachable DATABASE_URL/REDIS_URL and docker is unavailable -- run "
"the checkpoint suite (scripts/run_checkpoint_tests.py) or start "
"Postgres/Redis manually"
)
return
@@ -77,33 +134,55 @@ def pytest_configure(config):
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
up = _run(["docker", "compose", "up", "-d", "postgres"], env=env)
env["REDIS_HOST_PORT"] = str(_EPHEMERAL_REDIS_PORT)
services = [s for s, need in (("postgres", need_db), ("redis", need_redis)) if need]
up = _run(["docker", "compose", "up", "-d", *services], env=env)
if up.returncode != 0:
_db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}"
_db_unavailable_reason = f"docker compose up {' '.join(services)} failed:\n{up.stderr[-800:]}"
return
_started_container = True
for _ in range(30):
r = _run(
["docker", "compose", "exec", "-T", "postgres",
"pg_isready", "-U", "agp_user", "-d", "agp_db"],
env=env,
)
if r.returncode == 0:
break
time.sleep(1)
else:
_db_unavailable_reason = "ephemeral Postgres never became ready"
return
if need_db:
for _ in range(30):
r = _run(
["docker", "compose", "exec", "-T", "postgres",
"pg_isready", "-U", "agp_user", "-d", "agp_db"],
env=env,
)
if r.returncode == 0:
break
time.sleep(1)
else:
_db_unavailable_reason = "ephemeral Postgres never became ready"
return
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
os.environ["DATABASE_URL"] = url
settings.DATABASE_URL = url
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
os.environ["DATABASE_URL"] = url
settings.DATABASE_URL = url
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
env={**os.environ, "DATABASE_URL": url})
if mig.returncode != 0:
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
env={**os.environ, "DATABASE_URL": url})
if mig.returncode != 0:
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
return
if need_redis:
for _ in range(30):
r = _run(
["docker", "compose", "exec", "-T", "redis", "redis-cli", "ping"],
env=env,
)
if r.returncode == 0 and "PONG" in r.stdout:
break
time.sleep(1)
else:
_db_unavailable_reason = "ephemeral Redis never became ready"
return
redis_url = f"redis://localhost:{_EPHEMERAL_REDIS_PORT}/0"
os.environ["REDIS_URL"] = redis_url
settings.REDIS_URL = redis_url
def pytest_unconfigure(config):
@@ -113,6 +192,7 @@ def pytest_unconfigure(config):
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
env["REDIS_HOST_PORT"] = str(_EPHEMERAL_REDIS_PORT)
_run(["docker", "compose", "down", "-v"], env=env)
Binary file not shown.
Binary file not shown.
+105
View File
@@ -0,0 +1,105 @@
"""Generates the golden-file chunk fixtures in this directory.
These pin the world.md §3.6 binary format so the Python codec
(app/services/chunk_codec.py) and any future Godot-side test fixture can be
checked for drift (world_persistence_impl.md §5.5's "drift control"
requirement). No Godot-side golden fixture existed at the time this was
written (searched scripts/ and tests/ for AGPC / binary-format test
vectors -- none found), so these are authored here, from this
implementation, as the first golden files for the format. The client/editor
follow-on task should decode these same files with
`ChunkDataDecoder.decode_binary()` and assert the same logical values
(see the note in chunk_codec.py about a confirmed float32 handling bug in
that decoder that will need fixing for full byte parity on non-integer
transforms).
Run with: `python tests/fixtures/chunks/generate_fixtures.py` from `backend/`.
Fixtures are checked in; this script is for regenerating them if the format
or these examples change, not run as part of the test suite.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # backend/
from app.services import chunk_codec as cc
FIXTURES_DIR = Path(__file__).resolve().parent
def _empty_chunk() -> cc.ChunkPayload:
return cc.ChunkPayload(
world_id="7", room_id="main", coord=(0, 0, 0),
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
tile_layers=[], objects=[],
flags=cc.ContentFlags(),
created_at=1_700_000_000, updated_at=1_700_000_000, revision=1, author="fixture-gen",
)
def _simple_tile_chunk() -> cc.ChunkPayload:
layer = cc.TileLayer(
name="ground", layer_id=0, width=4, height=4, depth=1,
palette=[
cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1),
cc.TileDef(id=1, asset_id="tiles/grass", solid=False, flags=1, variants=4),
cc.TileDef(id=2, asset_id="tiles/stone", solid=True, flags=2, variants=2),
],
tiles=[0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0],
)
return cc.ChunkPayload(
world_id="12", room_id="main", coord=(3, -1, 0),
bounds=cc.ChunkBounds(min=(96.0, -32.0, 0.0), size=(32.0, 32.0, 1.0)),
tile_layers=[layer], objects=[],
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
created_at=1_700_000_000, updated_at=1_700_000_500, revision=2, author="fixture-gen",
)
def _chunk_with_objects() -> cc.ChunkPayload:
layer = cc.TileLayer(
name="ground", layer_id=0, width=2, height=2, depth=1,
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
tiles=[0, 0, 0, 0],
)
obj1 = cc.WorldObject(
id="obj_0001", type="interactable", asset_id="props/chest",
pos=(101.5, -110.25, 0.5), rot=(0.0, 0.0, 0.0, 1.0), scale=(1.0, 1.0, 1.0),
dynamic=False,
properties={"locked": False, "loot_table": "chest_basic"},
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
)
obj2 = cc.WorldObject(
id="obj_0002", type="spawn_point", asset_id="",
pos=(96.0, -128.0, 1.0), rot=(0.0, 0.7071, 0.0, 0.7071), scale=(1.0, 1.0, 1.0),
dynamic=True,
properties={},
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
)
return cc.ChunkPayload(
world_id="12", room_id="main", coord=(3, -4, 0),
bounds=cc.ChunkBounds(min=(96.0, -128.0, 0.0), size=(32.0, 32.0, 1.0)),
tile_layers=[layer], objects=[obj1, obj2],
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
created_at=1_700_000_000, updated_at=1_700_001_000, revision=5, author="agent-bot",
)
FIXTURES = {
"empty_chunk.bin": _empty_chunk,
"simple_tile_chunk.bin": _simple_tile_chunk,
"chunk_with_objects.bin": _chunk_with_objects,
}
def main() -> None:
for filename, builder in FIXTURES.items():
payload = builder()
data = cc.encode_binary(payload)
(FIXTURES_DIR / filename).write_bytes(data)
print(f"wrote {filename} ({len(data)} bytes)")
if __name__ == "__main__":
main()
Binary file not shown.
+312
View File
@@ -0,0 +1,312 @@
"""Integration tests for the DM chat router (app/routers/chat_dm.py):
- POST /api/chat/dm/{user_id}/messages
- GET /api/chat/dm/{user_id}/messages
- GET /api/chat/dm/conversations
The block-relationship router doesn't exist yet (sibling task), so blocks
are inserted directly against the ``Block`` model, same pattern already
used by ``tests/test_chat_models.py`` for direct model setup.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app import rate_limit
from app.config import settings
from app.main import app
from app.models.social import Block
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
async def _insert_block(blocker_id: int, blocked_id: int) -> None:
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
session.add(Block(blocker_id=blocker_id, blocked_id=blocked_id))
await session.commit()
await engine.dispose()
class TestSendAndRetrieve:
def test_send_then_fetch_history(self):
client = TestClient(app)
alice = _register(client, "dm_alice1")
bob = _register(client, "dm_bob1")
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "hey bob"},
headers=alice["headers"],
)
assert resp.status_code == 201, resp.text
sent = resp.json()
assert sent["sender_id"] == alice["id"]
assert sent["recipient_id"] == bob["id"]
assert sent["body"] == "hey bob"
assert sent["filtered"] is False
assert sent["id"] is not None
assert sent["created_at"] is not None
hist_alice = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
assert hist_alice.status_code == 200, hist_alice.text
msgs = hist_alice.json()["messages"]
assert len(msgs) == 1
assert msgs[0]["body"] == "hey bob"
hist_bob = client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
assert hist_bob.status_code == 200, hist_bob.text
assert len(hist_bob.json()["messages"]) == 1
assert hist_bob.json()["messages"][0]["body"] == "hey bob"
def test_cannot_message_self(self):
client = TestClient(app)
alice = _register(client, "dm_self1")
resp = client.post(
f"/api/chat/dm/{alice['id']}/messages",
json={"body": "hi me"},
headers=alice["headers"],
)
assert resp.status_code == 400
def test_target_user_must_exist(self):
client = TestClient(app)
alice = _register(client, "dm_noexist1")
resp = client.post(
"/api/chat/dm/999999999/messages",
json={"body": "hello?"},
headers=alice["headers"],
)
assert resp.status_code == 404
def test_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/chat/dm/1/messages", json={"body": "hi"})
assert resp.status_code == 401
def test_history_pagination_cursor(self):
client = TestClient(app)
alice = _register(client, "dm_page_a")
bob = _register(client, "dm_page_b")
for i in range(5):
r = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": f"msg {i}"},
headers=alice["headers"],
)
assert r.status_code == 201, r.text
page1 = client.get(
f"/api/chat/dm/{bob['id']}/messages",
params={"limit": 2},
headers=alice["headers"],
)
assert page1.status_code == 200
body1 = page1.json()
assert len(body1["messages"]) == 2
assert body1["messages"][0]["body"] == "msg 4" # newest first
assert body1["next_cursor"] is not None
page2 = client.get(
f"/api/chat/dm/{bob['id']}/messages",
params={"limit": 2, "before": body1["next_cursor"]},
headers=alice["headers"],
)
assert page2.status_code == 200
body2 = page2.json()
assert [m["body"] for m in body2["messages"]] == ["msg 2", "msg 1"]
def test_no_conversation_yet_returns_empty_history(self):
client = TestClient(app)
alice = _register(client, "dm_empty_a")
bob = _register(client, "dm_empty_b")
resp = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
assert resp.status_code == 200
assert resp.json() == {"messages": [], "next_cursor": None}
class TestConversationList:
def test_lists_peer_last_message_and_unread_count(self):
client = TestClient(app)
alice = _register(client, "dm_conv_a")
bob = _register(client, "dm_conv_b")
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "first"},
headers=alice["headers"],
)
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "second"},
headers=alice["headers"],
)
convs_bob = client.get("/api/chat/dm/conversations", headers=bob["headers"])
assert convs_bob.status_code == 200, convs_bob.text
data = convs_bob.json()
assert data["total"] == 1
conv = data["conversations"][0]
assert conv["peer_id"] == alice["id"]
assert conv["peer_username"] == "dm_conv_a"
assert conv["last_message"]["body"] == "second"
assert conv["unread_count"] == 2 # bob hasn't read yet
# Bob reads the latest page -> unread resets to 0.
client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
convs_bob_after = client.get("/api/chat/dm/conversations", headers=bob["headers"])
assert convs_bob_after.json()["conversations"][0]["unread_count"] == 0
# Alice sent them, so her own unread count for this conversation is 0.
convs_alice = client.get("/api/chat/dm/conversations", headers=alice["headers"])
assert convs_alice.json()["conversations"][0]["unread_count"] == 0
def test_scoped_to_authenticated_user_only(self):
client = TestClient(app)
alice = _register(client, "dm_scope_a")
bob = _register(client, "dm_scope_b")
carol = _register(client, "dm_scope_c")
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "hi bob"},
headers=alice["headers"],
)
convs_carol = client.get("/api/chat/dm/conversations", headers=carol["headers"])
assert convs_carol.status_code == 200
assert convs_carol.json() == {"conversations": [], "total": 0}
def test_cannot_read_another_pairs_conversation(self):
"""A user's history endpoint only ever resolves *their own* pairing
with the given peer id -- there is no conversation id in the URL an
outsider could guess/pass to read someone else's DMs."""
client = TestClient(app)
alice = _register(client, "dm_iso_a")
bob = _register(client, "dm_iso_b")
carol = _register(client, "dm_iso_c")
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "secret to bob"},
headers=alice["headers"],
)
# Carol asking for her "history with bob" gets her own (empty)
# conversation, never alice's.
resp = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=carol["headers"])
assert resp.status_code == 200
assert resp.json()["messages"] == []
class TestBlocking:
async def test_blocker_cannot_send_to_blocked(self):
client = TestClient(app)
alice = _register(client, "dm_block_a1")
bob = _register(client, "dm_block_b1")
await _insert_block(alice["id"], bob["id"])
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "let me in"},
headers=alice["headers"],
)
assert resp.status_code == 403
async def test_blocked_cannot_send_to_blocker(self):
client = TestClient(app)
alice = _register(client, "dm_block_a2")
bob = _register(client, "dm_block_b2")
await _insert_block(alice["id"], bob["id"])
resp = client.post(
f"/api/chat/dm/{alice['id']}/messages",
json={"body": "please respond"},
headers=bob["headers"],
)
assert resp.status_code == 403
class TestTextFilter:
def test_message_tripping_filter_is_redacted_and_flagged(self):
client = TestClient(app)
alice = _register(client, "dm_filter_a")
bob = _register(client, "dm_filter_b")
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "you are a bastard"},
headers=alice["headers"],
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["filtered"] is True
assert "bastard" not in data["body"]
assert "*" in data["body"]
# The stored/delivered copy is the redacted one, not the raw text.
hist = client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
assert hist.json()["messages"][0]["body"] == data["body"]
def test_clean_message_is_not_flagged(self):
client = TestClient(app)
alice = _register(client, "dm_clean_a")
bob = _register(client, "dm_clean_b")
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "have a nice day"},
headers=alice["headers"],
)
assert resp.status_code == 201
assert resp.json()["filtered"] is False
assert resp.json()["body"] == "have a nice day"
class TestRateLimit:
@pytest.fixture(autouse=True)
def _enable_rate_limiting(self):
prev = settings.RATE_LIMIT_ENABLED
settings.RATE_LIMIT_ENABLED = True
rate_limit.reset_all()
yield
rate_limit.reset_all()
settings.RATE_LIMIT_ENABLED = prev
def test_send_rate_limit_exceeded_returns_429(self):
client = TestClient(app)
alice = _register(client, "dm_rl_a")
bob = _register(client, "dm_rl_b")
codes = []
for i in range(25):
r = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": f"spam {i}"},
headers=alice["headers"],
)
codes.append(r.status_code)
assert codes[:20] == [201] * 20
assert 429 in codes[20:]
assert codes[-1] == 429
+344
View File
@@ -0,0 +1,344 @@
"""Model-level tests for chat: conversations, messages, message reports.
app/models/chat.py / alembic/versions/0008_add_chat_tables.py
(docs/planning/social.md US-4). This is the models+migration task only --
there are no API endpoints yet (sibling tasks own filtering, rate limiting,
and the routers), so these tests exercise the ORM models and the real
Postgres constraints directly rather than going through HTTP.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.chat import (
Conversation,
ConversationParticipant,
Message,
MessageReport,
ReportStatus,
)
from app.models.user import User
from app.models.world import World
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
class TestConversationCreation:
async def test_create_conversation_with_participants_and_messages(self):
client = TestClient(app)
a = _register(client, "chat_conv_a")
b = _register(client, "chat_conv_b")
engine, factory = _session_factory()
try:
lo, hi = sorted((a, b))
async with factory() as session:
convo = Conversation(user_a_id=lo, user_b_id=hi)
session.add(convo)
await session.flush()
session.add_all(
[
ConversationParticipant(conversation_id=convo.id, user_id=a),
ConversationParticipant(conversation_id=convo.id, user_id=b),
]
)
session.add(
Message(sender_id=a, conversation_id=convo.id, body="hi there")
)
await session.commit()
convo_id = convo.id
async with factory() as session:
participants = (
await session.execute(
select(ConversationParticipant).where(
ConversationParticipant.conversation_id == convo_id
)
)
).scalars().all()
messages = (
await session.execute(
select(Message).where(Message.conversation_id == convo_id)
)
).scalars().all()
assert len(participants) == 2
assert len(messages) == 1
assert messages[0].sender_id == a
assert messages[0].filtered is False
finally:
await engine.dispose()
async def test_duplicate_pair_rejected(self):
client = TestClient(app)
a = _register(client, "chat_uq_a")
b = _register(client, "chat_uq_b")
lo, hi = sorted((a, b))
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Conversation(user_a_id=lo, user_b_id=hi))
await session.commit()
async with factory() as session:
session.add(Conversation(user_a_id=lo, user_b_id=hi))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
async def test_unordered_pair_rejected_by_check_constraint(self):
client = TestClient(app)
a = _register(client, "chat_ck_a")
b = _register(client, "chat_ck_b")
lo, hi = sorted((a, b))
engine, factory = _session_factory()
try:
async with factory() as session:
# Reversed order violates ck_conversations_ordered_pair.
session.add(Conversation(user_a_id=hi, user_b_id=lo))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestWorldRoomChat:
async def test_world_message_has_null_conversation(self):
client = TestClient(app)
owner = _register(client, "chat_world_owner")
engine, factory = _session_factory()
try:
async with factory() as session:
world = World(name="Chat Test World", owner_id=owner)
session.add(world)
await session.flush()
msg = Message(sender_id=owner, world_id=world.id, body="hello world")
session.add(msg)
await session.commit()
world_id = world.id
async with factory() as session:
rows = (
await session.execute(
select(Message).where(Message.world_id == world_id)
)
).scalars().all()
assert len(rows) == 1
assert rows[0].conversation_id is None
assert rows[0].room_id is None
finally:
await engine.dispose()
class TestMessageReports:
async def test_create_report_defaults_to_open(self):
client = TestClient(app)
sender = _register(client, "chat_rpt_sender")
reporter = _register(client, "chat_rpt_reporter")
engine, factory = _session_factory()
try:
async with factory() as session:
world = World(name="Report World", owner_id=sender)
session.add(world)
await session.flush()
msg = Message(sender_id=sender, world_id=world.id, body="bad words")
session.add(msg)
await session.flush()
report = MessageReport(
message_id=msg.id, reporter_id=reporter, reason="spam"
)
session.add(report)
await session.commit()
report_id = report.id
async with factory() as session:
report = await session.get(MessageReport, report_id)
assert report.status == ReportStatus.OPEN
assert report.moderator_id is None
assert report.resolved_at is None
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_conversation_deletes_participants_and_messages(self):
client = TestClient(app)
a = _register(client, "chat_cd_conv_a")
b = _register(client, "chat_cd_conv_b")
lo, hi = sorted((a, b))
engine, factory = _session_factory()
try:
async with factory() as session:
convo = Conversation(user_a_id=lo, user_b_id=hi)
session.add(convo)
await session.flush()
session.add_all(
[
ConversationParticipant(conversation_id=convo.id, user_id=a),
ConversationParticipant(conversation_id=convo.id, user_id=b),
]
)
session.add(Message(sender_id=a, conversation_id=convo.id, body="bye"))
await session.commit()
convo_id = convo.id
async with factory() as session:
# Core delete against the conversations table directly, so
# the real ON DELETE CASCADE does the work (not the ORM).
await session.execute(
delete(Conversation).where(Conversation.id == convo_id)
)
await session.commit()
async with factory() as session:
participants = (
await session.execute(
select(ConversationParticipant).where(
ConversationParticipant.conversation_id == convo_id
)
)
).scalars().all()
messages = (
await session.execute(
select(Message).where(Message.conversation_id == convo_id)
)
).scalars().all()
assert participants == []
assert messages == []
finally:
await engine.dispose()
async def test_deleting_user_removes_conversation_but_keeps_report_and_message_id_null(self):
"""Deleting a conversation participant cascades away the whole DM
(conversation + participants + messages -- there's no third party
left to care about the history). Deleting the sender of a
world-chat message, by contrast, must NOT delete the message or its
report -- sender_id is SET NULL and message_reports.message_id
follows the message (also SET NULL, never CASCADE) so the audit
trail survives account deletion."""
client = TestClient(app)
a = _register(client, "chat_cd_user_a")
b = _register(client, "chat_cd_user_b")
reporter = _register(client, "chat_cd_user_reporter")
lo, hi = sorted((a, b))
engine, factory = _session_factory()
try:
async with factory() as session:
convo = Conversation(user_a_id=lo, user_b_id=hi)
session.add(convo)
await session.flush()
session.add_all(
[
ConversationParticipant(conversation_id=convo.id, user_id=a),
ConversationParticipant(conversation_id=convo.id, user_id=b),
]
)
session.add(Message(sender_id=a, conversation_id=convo.id, body="dm"))
world = World(name="Sender Delete World", owner_id=b)
session.add(world)
await session.flush()
world_msg = Message(sender_id=a, world_id=world.id, body="world chat")
session.add(world_msg)
await session.flush()
report = MessageReport(
message_id=world_msg.id, reporter_id=reporter, reason="rude"
)
session.add(report)
await session.commit()
convo_id = convo.id
world_msg_id = world_msg.id
report_id = report.id
async with factory() as session:
# Delete `a`: participant in the DM (cascades the whole
# conversation) and sender of the world message (SET NULL).
await session.execute(delete(User).where(User.id == a))
await session.commit()
async with factory() as session:
convo_row = await session.get(Conversation, convo_id)
dm_messages = (
await session.execute(
select(Message).where(Message.conversation_id == convo_id)
)
).scalars().all()
world_msg = await session.get(Message, world_msg_id)
report = await session.get(MessageReport, report_id)
assert convo_row is None
assert dm_messages == []
# World message survives with sender_id nulled out.
assert world_msg is not None
assert world_msg.sender_id is None
assert world_msg.body == "world chat"
# Report still points at the surviving message.
assert report is not None
assert report.message_id == world_msg_id
finally:
await engine.dispose()
async def test_deleting_message_nulls_report_message_id(self):
client = TestClient(app)
sender = _register(client, "chat_cd_msg_sender")
reporter = _register(client, "chat_cd_msg_reporter")
engine, factory = _session_factory()
try:
async with factory() as session:
world = World(name="Msg Delete World", owner_id=sender)
session.add(world)
await session.flush()
msg = Message(sender_id=sender, world_id=world.id, body="to be removed")
session.add(msg)
await session.flush()
report = MessageReport(
message_id=msg.id, reporter_id=reporter, reason="spam"
)
session.add(report)
await session.commit()
msg_id = msg.id
report_id = report.id
async with factory() as session:
await session.execute(delete(Message).where(Message.id == msg_id))
await session.commit()
async with factory() as session:
report = await session.get(MessageReport, report_id)
assert report is not None
assert report.message_id is None
finally:
await engine.dispose()
+201
View File
@@ -0,0 +1,201 @@
"""Integration tests for message reporting and moderation queue:
- POST /api/chat/messages/{message_id}/report
- GET /api/moderation/reports
- POST /api/moderation/reports/{id}/resolve
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
def _send_dm(client: TestClient, sender: dict, recipient: dict, text: str = "hello") -> int:
resp = client.post(
f"/api/chat/dm/{recipient['id']}/messages",
headers=sender["headers"],
json={"body": text},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
class TestReportMessage:
def test_report_message_success(self):
client = TestClient(app)
alice = _register(client, "rep_alice1")
bob = _register(client, "rep_bob1")
msg_id = _send_dm(client, alice, bob, "spam message")
resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "Contains spam", "category": "spam"},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["message_id"] == msg_id
assert data["reporter_id"] == bob["id"]
assert data["status"] == "open"
assert "[spam] Contains spam" in data["reason"]
def test_duplicate_report_rejected(self):
client = TestClient(app)
alice = _register(client, "rep_alice2")
bob = _register(client, "rep_bob2")
msg_id = _send_dm(client, alice, bob, "bad message")
resp1 = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "offensive"},
)
assert resp1.status_code == 201
resp2 = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "offensive again"},
)
assert resp2.status_code == 409
def test_report_nonexistent_message_404(self):
client = TestClient(app)
bob = _register(client, "rep_bob3")
resp = client.post(
"/api/chat/messages/999999/report",
headers=bob["headers"],
json={"reason": "ghost"},
)
assert resp.status_code == 404
def test_report_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/chat/messages/1/report", json={"reason": "no auth"})
assert resp.status_code == 401
class TestModerationQueue:
def test_non_moderator_cannot_access_queue(self):
client = TestClient(app)
regular_user = _register(client, "rep_regular1")
resp = client.get("/api/moderation/reports", headers=regular_user["headers"])
assert resp.status_code == 403
def test_moderator_can_list_reports(self):
client = TestClient(app)
alice = _register(client, "rep_alice4")
bob = _register(client, "rep_bob4")
admin = _register(client, "admin_list")
msg_id = _send_dm(client, alice, bob, "something to report")
client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "needs review"},
)
resp = client.get("/api/moderation/reports", headers=admin["headers"])
assert resp.status_code == 200, resp.text
reports = resp.json()
assert len(reports) >= 1
assert any(r["message_id"] == msg_id for r in reports)
def test_moderator_resolve_dismiss(self):
client = TestClient(app)
alice = _register(client, "rep_alice5")
bob = _register(client, "rep_bob5")
admin = _register(client, "admin_dismiss")
msg_id = _send_dm(client, alice, bob, "benign message")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "false alarm"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin["headers"],
json={"action": "dismiss", "notes": "No violation found"},
)
assert resolve_resp.status_code == 200, resolve_resp.text
data = resolve_resp.json()
assert data["report_id"] == report_id
assert data["status"] == "dismissed"
assert data["action_taken"] == "dismiss"
assert data["moderator_id"] == admin["id"]
def test_moderator_resolve_remove(self):
client = TestClient(app)
alice = _register(client, "rep_alice6")
bob = _register(client, "rep_bob6")
admin = _register(client, "admin_remove")
msg_id = _send_dm(client, alice, bob, "offensive content")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "hate speech"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin["headers"],
json={"action": "remove", "notes": "Content removed"},
)
assert resolve_resp.status_code == 200
assert resolve_resp.json()["status"] == "resolved"
# The message is removed from conversation history
hist = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
assert all(m["id"] != msg_id for m in hist.json()["messages"])
def test_moderator_resolve_ban(self):
client = TestClient(app)
bad_user = _register(client, "bad_actor")
victim = _register(client, "rep_victim")
admin_auth = _register(client, "admin_ban")
msg_id = _send_dm(client, bad_user, victim, "terrible violation")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=victim["headers"],
json={"reason": "malicious user"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin_auth["headers"],
json={"action": "ban", "notes": "Banned malicious user"},
)
assert resolve_resp.status_code == 200
# Attempting to use bad_user's token fails with 403 inactive account
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
assert bad_me.status_code == 403
+246
View File
@@ -0,0 +1,246 @@
"""Integration tests for in-experience world/room chat:
- POST /api/chat/world/{world_id}/messages
- GET /api/chat/world/{world_id}/messages
- POST /api/chat/room/{room_id}/messages
- GET /api/chat/room/{room_id}/messages
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": "TestWorld", "visibility": visibility})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_friends(client: TestClient, user_a: dict, user_b: dict) -> None:
req = client.post("/api/friends/requests", json={"user_id": user_b["id"]}, headers=user_a["headers"])
assert req.status_code == 201, req.text
req_id = req.json()["id"]
acc = client.post(f"/api/friends/requests/{req_id}/accept", headers=user_b["headers"])
assert acc.status_code == 200, acc.text
class TestWorldChat:
def test_send_and_get_world_message(self):
client = TestClient(app)
alice = _register(client, "wch_alice1")
bob = _register(client, "wch_bob1")
world_id = _make_world(client, alice["headers"], visibility="public")
# Bob posts to Alice's public world
post_resp = client.post(
f"/api/chat/world/{world_id}/messages",
headers=bob["headers"],
json={"body": "Hello world from Bob!"},
)
assert post_resp.status_code == 201, post_resp.text
msg_data = post_resp.json()
assert msg_data["world_id"] == world_id
assert msg_data["room_id"] is None
assert msg_data["sender_id"] == bob["id"]
assert msg_data["body"] == "Hello world from Bob!"
assert msg_data["filtered"] is False
assert msg_data["author"]["username"] == "wch_bob1"
assert msg_data["author"]["id"] == bob["id"]
# Alice gets world chat history
get_resp = client.get(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
)
assert get_resp.status_code == 200, get_resp.text
history = get_resp.json()
assert len(history["messages"]) == 1
assert history["messages"][0]["id"] == msg_data["id"]
assert history["messages"][0]["author"]["username"] == "wch_bob1"
def test_room_scoped_messages(self):
client = TestClient(app)
alice = _register(client, "wch_alice2")
world_id = _make_world(client, alice["headers"])
# Fetch auto-created main room
rooms_resp = client.get(f"/api/worlds/{world_id}/rooms", headers=alice["headers"])
assert rooms_resp.status_code == 200, rooms_resp.text
rooms = rooms_resp.json()
assert len(rooms) >= 1
room_id = rooms[0]["id"]
# Post directly via room endpoint
post_room = client.post(
f"/api/chat/room/{room_id}/messages",
headers=alice["headers"],
json={"body": "Message in room"},
)
assert post_room.status_code == 201, post_room.text
msg_data = post_room.json()
assert msg_data["world_id"] == world_id
assert msg_data["room_id"] == room_id
assert msg_data["body"] == "Message in room"
# Get room history
get_room = client.get(
f"/api/chat/room/{room_id}/messages",
headers=alice["headers"],
)
assert get_room.status_code == 200, get_room.text
messages = get_room.json()["messages"]
assert len(messages) == 1
assert messages[0]["id"] == msg_data["id"]
# Also visible when querying world history with room_id filter
get_world_room = client.get(
f"/api/chat/world/{world_id}/messages?room_id={room_id}",
headers=alice["headers"],
)
assert get_world_room.status_code == 200, get_world_room.text
assert len(get_world_room.json()["messages"]) == 1
def test_visibility_access_control(self):
client = TestClient(app)
alice = _register(client, "wch_alice3")
bob = _register(client, "wch_bob3")
charlie = _register(client, "wch_charlie3")
# Private world: only Alice
priv_world_id = _make_world(client, alice["headers"], visibility="private")
# Bob cannot send or read
bob_send = client.post(
f"/api/chat/world/{priv_world_id}/messages",
headers=bob["headers"],
json={"body": "Trying to trespass"},
)
assert bob_send.status_code == 403, bob_send.text
bob_read = client.get(
f"/api/chat/world/{priv_world_id}/messages",
headers=bob["headers"],
)
assert bob_read.status_code == 403, bob_read.text
# Alice (owner) can send and read
alice_send = client.post(
f"/api/chat/world/{priv_world_id}/messages",
headers=alice["headers"],
json={"body": "My private sanctum"},
)
assert alice_send.status_code == 201, alice_send.text
# Friends-only world
fr_world_id = _make_world(client, alice["headers"], visibility="friends_only")
_make_friends(client, alice, bob)
# Bob (friend) can post
bob_fr_send = client.post(
f"/api/chat/world/{fr_world_id}/messages",
headers=bob["headers"],
json={"body": "Hello friend!"},
)
assert bob_fr_send.status_code == 201, bob_fr_send.text
# Charlie (not friend) cannot post or read
charlie_send = client.post(
f"/api/chat/world/{fr_world_id}/messages",
headers=charlie["headers"],
json={"body": "Can I join?"},
)
assert charlie_send.status_code == 403, charlie_send.text
charlie_read = client.get(
f"/api/chat/world/{fr_world_id}/messages",
headers=charlie["headers"],
)
assert charlie_read.status_code == 403, charlie_read.text
def test_not_found_validation(self):
client = TestClient(app)
alice = _register(client, "wch_alice4")
# Non-existent world
res = client.post(
"/api/chat/world/999999/messages",
headers=alice["headers"],
json={"body": "Ghost world"},
)
assert res.status_code == 404, res.text
# Non-existent room
world_id = _make_world(client, alice["headers"])
res2 = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": "Ghost room", "room_id": 999999},
)
assert res2.status_code == 404, res2.text
def test_text_filtering_in_world_chat(self):
client = TestClient(app)
alice = _register(client, "wch_alice5")
world_id = _make_world(client, alice["headers"])
# Default filter redacts forbidden words
res = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": "you are a bastard"},
)
assert res.status_code == 201, res.text
data = res.json()
assert data["filtered"] is True
assert "bastard" not in data["body"]
assert "*" in data["body"]
def test_pagination_and_next_cursor(self):
client = TestClient(app)
alice = _register(client, "wch_alice6")
world_id = _make_world(client, alice["headers"])
for i in range(5):
r = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": f"Msg {i}"},
)
assert r.status_code == 201
page1 = client.get(
f"/api/chat/world/{world_id}/messages?limit=2",
headers=alice["headers"],
).json()
assert len(page1["messages"]) == 2
assert page1["next_cursor"] is not None
assert page1["messages"][0]["body"] == "Msg 4"
assert page1["messages"][1]["body"] == "Msg 3"
page2 = client.get(
f"/api/chat/world/{world_id}/messages?limit=2&before={page1['next_cursor']}",
headers=alice["headers"],
).json()
assert len(page2["messages"]) == 2
assert page2["messages"][0]["body"] == "Msg 2"
assert page2["messages"][1]["body"] == "Msg 1"
+219
View File
@@ -0,0 +1,219 @@
"""Tests for app.services.chunk_codec -- the Python port of world.md §3.6.
Pure unit tests: no database, no app, no network. Golden fixtures live in
tests/fixtures/chunks/ (see generate_fixtures.py there for how/why).
"""
from pathlib import Path
import pytest
from app.services import chunk_codec as cc
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "chunks"
def _read_fixture(name: str) -> bytes:
return (FIXTURES_DIR / name).read_bytes()
class TestGoldenFixtures:
"""Byte-stability: decode a committed fixture, re-encode it, and the
bytes must match exactly (world.md §3.6.3)."""
@pytest.mark.parametrize("filename", [
"empty_chunk.bin", "simple_tile_chunk.bin", "chunk_with_objects.bin",
])
def test_round_trip_is_byte_identical(self, filename):
original = _read_fixture(filename)
decoded = cc.decode_binary(original)
re_encoded = cc.encode_binary(decoded)
assert re_encoded == original
def test_simple_tile_chunk_logical_values(self):
decoded = cc.decode_binary(_read_fixture("simple_tile_chunk.bin"))
assert decoded.world_id == "12"
assert decoded.room_id == "main"
assert decoded.coord == (3, -1, 0)
assert decoded.bounds.min == (96.0, -32.0, 0.0)
assert decoded.bounds.size == (32.0, 32.0, 1.0)
assert len(decoded.tile_layers) == 1
layer = decoded.tile_layers[0]
assert layer.name == "ground"
assert (layer.width, layer.height, layer.depth) == (4, 4, 1)
assert len(layer.palette) == 3
assert layer.tiles == [0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0]
assert decoded.revision == 2
assert decoded.author == "fixture-gen"
def test_chunk_with_objects_logical_values(self):
decoded = cc.decode_binary(_read_fixture("chunk_with_objects.bin"))
assert len(decoded.objects) == 2
obj1, obj2 = decoded.objects
assert obj1.id == "obj_0001"
assert obj1.type == "interactable"
assert obj1.pos == (101.5, -110.25, 0.5)
assert obj1.properties == {"locked": False, "loot_table": "chest_basic"}
assert obj2.id == "obj_0002"
assert obj2.dynamic is True
assert obj2.flags.violence == 1
assert obj2.flags.sources == ["asset:props/chest"]
assert decoded.flags.violence == 1
assert decoded.flags.sources == ["asset:props/chest"]
def test_empty_chunk_has_no_layers_or_objects(self):
decoded = cc.decode_binary(_read_fixture("empty_chunk.bin"))
assert decoded.tile_layers == []
assert decoded.objects == []
class TestChecksum:
def test_checksum_is_sha256_hex(self):
import hashlib
assert cc.checksum(b"") == hashlib.sha256(b"").hexdigest()
assert len(cc.checksum(b"")) == 64
def test_header_checksum_matches_recomputed_for_valid_fixture(self):
data = _read_fixture("simple_tile_chunk.bin")
header_checksum_hex = data[16:48].hex()
body = data[cc.HEADER_SIZE:]
assert header_checksum_hex == cc.checksum(body)
class TestBuildKey:
def test_matches_world_persistence_impl_4_5_format(self):
assert cc.build_key(12, "main", 3, -1, 0) == "12:main:3:-1:0"
assert cc.build_key("12", "main", 3, -1, 0) == "12:main:3:-1:0"
class TestFloat32Fidelity:
"""This is the concrete regression test for the cross-codec divergence
documented at the bottom of chunk_codec.py: this module must encode
real IEEE-754 float32, including fractional and negative values,
round-tripping exactly (up to float32 precision) -- unlike
scripts/chunk_data_decoder.gd's current int-truncating implementation."""
def test_fractional_and_negative_floats_survive_round_trip(self):
payload = cc.ChunkPayload(
world_id="1", room_id="main", coord=(0, 0, 0),
bounds=cc.ChunkBounds(min=(-17.25, 0.125, -3.0), size=(32.0, 32.0, 1.0)),
objects=[cc.WorldObject(
id="o1", type="prop", asset_id="a",
pos=(1.5, -2.75, 0.001), rot=(0.0, 0.7071068, 0.0, 0.7071068),
scale=(1.0, 1.0, 1.0),
)],
)
encoded = cc.encode_binary(payload)
decoded = cc.decode_binary(encoded)
assert decoded.bounds.min == pytest.approx((-17.25, 0.125, -3.0), abs=1e-6)
assert decoded.objects[0].pos == pytest.approx((1.5, -2.75, 0.001), abs=1e-6)
assert decoded.objects[0].rot == pytest.approx(
(0.0, 0.7071068, 0.0, 0.7071068), abs=1e-6
)
class TestMalformedInput:
def test_bad_magic_raises_codec_error(self):
data = bytearray(_read_fixture("empty_chunk.bin"))
data[0:4] = b"XXXX"
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(bytes(data))
def test_too_short_raises_codec_error(self):
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(b"AGPC\x01\x00")
def test_unsupported_version_raises_codec_error(self):
data = bytearray(_read_fixture("empty_chunk.bin"))
data[4:6] = (99).to_bytes(2, "little")
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(bytes(data))
def test_payload_len_mismatch_raises_codec_error(self):
data = bytearray(_read_fixture("empty_chunk.bin"))
# Corrupt the declared payload_len (offset 12..16).
data[12:16] = (99999).to_bytes(4, "little")
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(bytes(data))
class TestValidation:
"""decode_and_validate: the server-side "don't trust the client"
boundary (world_persistence_impl.md §5.5)."""
def _valid_args(self):
return {
"expected_key": "12:main:3:-1:0",
"grid_size": (4, 4, 1),
"max_objects": 512,
}
def test_valid_payload_passes(self):
data = _read_fixture("simple_tile_chunk.bin")
payload = cc.decode_and_validate(data, **self._valid_args())
assert payload.coord == (3, -1, 0)
def test_checksum_mismatch_detected(self):
data = bytearray(_read_fixture("simple_tile_chunk.bin"))
# Corrupt one byte of the header's advisory checksum field
# (offset 16..48) only -- magic/version/payload_len and the whole
# payload body stay untouched, so this exercises exactly the
# "recomputed checksum disagrees with the header" path rather than
# also breaking structural decoding.
data[16] ^= 0xFF
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(bytes(data), **self._valid_args())
assert exc_info.value.reason == "checksum_mismatch"
def test_chunk_key_mismatch_detected(self):
data = _read_fixture("simple_tile_chunk.bin")
args = self._valid_args()
args["expected_key"] = "12:main:99:99:0" # doesn't match the payload's own coord
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(data, **args)
assert exc_info.value.reason == "chunk_key_mismatch"
def test_layer_dims_mismatch_detected(self):
data = _read_fixture("simple_tile_chunk.bin")
args = self._valid_args()
args["grid_size"] = (8, 8, 1) # fixture's layer is 4x4x1
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(data, **args)
assert exc_info.value.reason == "layer_dims"
def test_too_many_objects_detected(self):
data = _read_fixture("chunk_with_objects.bin") # has 2 objects
args = self._valid_args()
args["expected_key"] = "12:main:3:-4:0"
args["grid_size"] = (2, 2, 1)
args["max_objects"] = 1
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(data, **args)
assert exc_info.value.reason == "too_many_objects"
def test_object_out_of_bounds_detected(self):
payload = cc.ChunkPayload(
world_id="1", room_id="main", coord=(0, 0, 0),
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
objects=[cc.WorldObject(id="o1", type="prop", asset_id="a", pos=(9999.0, 0.0, 0.0))],
)
data = cc.encode_binary(payload)
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(
data, expected_key="1:main:0:0:0", grid_size=(32, 32, 1), max_objects=10
)
assert exc_info.value.reason == "object_out_of_bounds"
def test_tile_out_of_palette_detected(self):
layer = cc.TileLayer(
name="ground", width=2, height=2, depth=1,
palette=[cc.TileDef(id=1)], # tile 5 below isn't in this palette
tiles=[1, 5, 0, 1],
)
payload = cc.ChunkPayload(world_id="1", room_id="main", coord=(0, 0, 0), tile_layers=[layer])
data = cc.encode_binary(payload)
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(
data, expected_key="1:main:0:0:0", grid_size=(2, 2, 1), max_objects=10
)
assert exc_info.value.reason == "tile_out_of_palette"
+188
View File
@@ -0,0 +1,188 @@
"""API tests for discovery browse & search endpoints.
docs/planning/discovery.md (task 300da1dcddf3).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
@pytest.mark.asyncio
async def test_discovery_public_worlds_only_and_fields():
client = TestClient(app)
uid, headers = _register(client, "creator_browse_1")
# Create public world
pub_resp = client.post(
"/api/worlds/",
headers=headers,
json={
"name": "Fantasy Kingdom",
"description": "A magical realm",
"visibility": "public",
"category": "RPG",
"tags": ["magic", "dragons"],
},
)
assert pub_resp.status_code == 201, pub_resp.text
pub_id = pub_resp.json()["id"]
# Create private world
priv_resp = client.post(
"/api/worlds/",
headers=headers,
json={
"name": "Secret Base",
"description": "Classified",
"visibility": "private",
"category": "RPG",
"tags": ["secret"],
},
)
assert priv_resp.status_code == 201, priv_resp.text
# 1. Anonymous listing: only public world is returned
anon_resp = client.get("/api/discovery/worlds")
assert anon_resp.status_code == 200, anon_resp.text
data = anon_resp.json()
assert data["total"] >= 1
items = data["items"]
ids = [w["id"] for w in items]
assert pub_id in ids
assert priv_resp.json()["id"] not in ids
# Check fields of returned item
pub_item = next(w for w in items if w["id"] == pub_id)
assert pub_item["name"] == "Fantasy Kingdom"
assert pub_item["description"] == "A magical realm"
assert pub_item["category"] == "RPG"
assert "magic" in pub_item["tags"]
assert pub_item["play_count"] == 0
assert pub_item["owner"]["id"] == uid
assert pub_item["owner"]["username"] == "creator_browse_1"
# 2. Authenticated caller also does NOT see private worlds in discovery
auth_resp = client.get("/api/discovery/worlds", headers=headers)
assert auth_resp.status_code == 200
auth_ids = [w["id"] for w in auth_resp.json()["items"]]
assert priv_resp.json()["id"] not in auth_ids
@pytest.mark.asyncio
async def test_discovery_filtering_and_categories():
client = TestClient(app)
uid, headers = _register(client, "creator_filter_2")
# Create distinct worlds
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Cyber Arena", "visibility": "public", "category": "Action", "tags": ["pvp", "fps"]},
)
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Peaceful Meadow", "visibility": "public", "category": "Social", "tags": ["relax", "hangout"]},
)
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Cyber Hangout", "visibility": "public", "category": "Social", "tags": ["cyberpunk", "chat"]},
)
# Search by q
res_q = client.get("/api/discovery/worlds?q=cyber")
assert res_q.status_code == 200
names = [w["name"] for w in res_q.json()["items"]]
assert "Cyber Arena" in names
assert "Cyber Hangout" in names
assert "Peaceful Meadow" not in names
# Filter by category (case-insensitive)
res_cat = client.get("/api/discovery/worlds?category=action")
assert res_cat.status_code == 200
names = [w["name"] for w in res_cat.json()["items"]]
assert "Cyber Arena" in names
assert "Peaceful Meadow" not in names
# Filter by tags (overlap)
res_tags = client.get("/api/discovery/worlds?tags=relax,pvp")
assert res_tags.status_code == 200
names = [w["name"] for w in res_tags.json()["items"]]
assert "Peaceful Meadow" in names
assert "Cyber Arena" in names
assert "Cyber Hangout" not in names
# Get distinct categories
res_cats = client.get("/api/discovery/categories")
assert res_cats.status_code == 200
cats = res_cats.json()
assert "Action" in cats
assert "Social" in cats
@pytest.mark.asyncio
async def test_discovery_sorting_and_pagination():
client = TestClient(app)
uid, headers = _register(client, "creator_sort_3")
w1 = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Sort World 1", "visibility": "public"},
).json()
w2 = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Sort World 2", "visibility": "public"},
).json()
# Record visits on w1 so play_count = 1
v_res = client.post(f"/api/discovery/worlds/{w1['id']}/visit")
assert v_res.status_code == 200
# Sort by most_played
res_mp = client.get("/api/discovery/worlds?sort=most_played")
assert res_mp.status_code == 200
items = res_mp.json()["items"]
# w1 should come before w2
idx_w1 = next(i for i, w in enumerate(items) if w["id"] == w1["id"])
idx_w2 = next(i for i, w in enumerate(items) if w["id"] == w2["id"])
assert idx_w1 < idx_w2
# Sort by newest (default)
res_new = client.get("/api/discovery/worlds?sort=newest")
assert res_new.status_code == 200
items_new = res_new.json()["items"]
idx_w1_n = next(i for i, w in enumerate(items_new) if w["id"] == w1["id"])
idx_w2_n = next(i for i, w in enumerate(items_new) if w["id"] == w2["id"])
# w2 was created after w1, so w2 is newer
assert idx_w2_n < idx_w1_n
# Pagination: limit 1, skip 0
p1 = client.get("/api/discovery/worlds?limit=1&skip=0")
assert p1.status_code == 200
assert len(p1.json()["items"]) == 1
assert p1.json()["total"] >= 2
assert p1.json()["skip"] == 0
assert p1.json()["limit"] == 1
+149
View File
@@ -0,0 +1,149 @@
"""Model-level integration tests for Discovery metadata and WorldVisit.
docs/planning/discovery.md (task b377353b8167).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.user import User
from app.models.world import World, WorldVisibility
from app.models.world_visit import WorldVisit
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_world_discovery_metadata_and_visits():
client = TestClient(app)
user_id = _register(client, "discoverer_1")
visitor_id = _register(client, "visitor_1")
engine, sf = _session_factory()
try:
async with sf() as session:
# 1. Create a world with discovery metadata
world = World(
name="Cyber Adventure",
description="An epic adventure world",
owner_id=user_id,
visibility=WorldVisibility.PUBLIC,
category="Adventure",
tags=["cyberpunk", "rpg", "quest"],
)
session.add(world)
await session.commit()
await session.refresh(world)
assert world.id is not None
assert world.category == "Adventure"
assert world.tags == ["cyberpunk", "rpg", "quest"]
assert world.play_count == 0
assert world.last_played_at is None
# 2. Add authenticated visit
visit1 = WorldVisit(
world_id=world.id,
user_id=visitor_id,
)
# 3. Add anonymous visit
visit2 = WorldVisit(
world_id=world.id,
user_id=None,
anonymous_token="anon_token_12345",
)
session.add_all([visit1, visit2])
await session.commit()
# Query world with visits
res = await session.execute(
select(World).where(World.id == world.id).options(selectinload(World.visits))
)
w = res.scalar_one()
assert len(w.visits) == 2
v_user = next(v for v in w.visits if v.user_id == visitor_id)
assert v_user.visited_at is not None
v_anon = next(v for v in w.visits if v.user_id is None)
assert v_anon.anonymous_token == "anon_token_12345"
# 4. Verify cascade delete on world
async with sf() as session:
res = await session.execute(select(World).where(World.id == world.id))
w = res.scalar_one()
await session.delete(w)
await session.commit()
# Visits should be gone
res_v = await session.execute(select(WorldVisit).where(WorldVisit.world_id == world.id))
assert len(res_v.scalars().all()) == 0
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_user_delete_sets_null_on_world_visits():
client = TestClient(app)
owner_id = _register(client, "world_owner_2")
visitor_id = _register(client, "temp_visitor_2")
engine, sf = _session_factory()
try:
async with sf() as session:
world = World(
name="Chill Lobby",
owner_id=owner_id,
visibility=WorldVisibility.PUBLIC,
category="Social",
tags=["hangout"],
)
session.add(world)
await session.commit()
await session.refresh(world)
visit = WorldVisit(world_id=world.id, user_id=visitor_id)
session.add(visit)
await session.commit()
await session.refresh(visit)
visit_id = visit.id
# Delete visitor user
visitor_user = await session.get(User, visitor_id)
await session.delete(visitor_user)
await session.commit()
# In a new session, query from database to see ON DELETE SET NULL effect
async with sf() as session:
v = await session.get(WorldVisit, visit_id)
assert v is not None
assert v.user_id is None
assert v.world_id == world.id
finally:
await engine.dispose()
+164
View File
@@ -0,0 +1,164 @@
"""API tests for world visit tracking endpoint.
docs/planning/discovery.md (task 42726e60fe10).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.world import World, WorldVisibility
from app.models.world_visit import WorldVisit
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_visit_world_atomic_increment_authenticated():
client = TestClient(app)
owner_id, owner_headers = _register(client, "visit_owner_1")
visitor_id, visitor_headers = _register(client, "visitor_user_1")
# Create world
resp = client.post(
"/api/worlds/",
headers=owner_headers,
json={"name": "Neon City", "visibility": "public", "category": "Roleplay"},
)
assert resp.status_code == 201, resp.text
world_id = resp.json()["id"]
# Visitor visits the world
v_resp = client.post(
f"/api/discovery/worlds/{world_id}/visit",
headers=visitor_headers,
)
assert v_resp.status_code == 200, v_resp.text
data = v_resp.json()
assert data["play_count"] == 1
assert data["last_played_at"] is not None
# Verify DB state
engine, sf = _session_factory()
try:
async with sf() as session:
w = (await session.execute(select(World).where(World.id == world_id))).scalar_one()
assert w.play_count == 1
assert w.last_played_at is not None
visits = (
await session.execute(
select(WorldVisit).where(WorldVisit.world_id == world_id)
)
).scalars().all()
assert len(visits) == 1
assert visits[0].user_id == visitor_id
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_visit_world_anonymous_and_idempotency():
client = TestClient(app)
owner_id, owner_headers = _register(client, "visit_owner_2")
resp = client.post(
"/api/worlds/",
headers=owner_headers,
json={"name": "Space Station", "visibility": "public"},
)
world_id = resp.json()["id"]
# Anonymous visit with token
anon_token = "guest_token_999"
v_resp1 = client.post(
f"/api/discovery/worlds/{world_id}/visit",
json={"anonymous_token": anon_token},
)
assert v_resp1.status_code == 200, v_resp1.text
assert v_resp1.json()["play_count"] == 1
# Idempotent second call with same anonymous token
v_resp2 = client.post(
f"/api/discovery/worlds/{world_id}/visit",
json={"anonymous_token": anon_token},
)
assert v_resp2.status_code == 200, v_resp2.text
assert v_resp2.json()["play_count"] == 1
engine, sf = _session_factory()
try:
async with sf() as session:
visits = (
await session.execute(
select(WorldVisit).where(WorldVisit.world_id == world_id)
)
).scalars().all()
assert len(visits) == 1
assert visits[0].user_id is None
assert visits[0].anonymous_token == anon_token
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_visit_world_visibility_and_404():
client = TestClient(app)
owner_id, owner_headers = _register(client, "visit_owner_3")
other_id, other_headers = _register(client, "other_user_3")
# 404 on non-existent world
res_404 = client.post("/api/discovery/worlds/999999/visit", headers=owner_headers)
assert res_404.status_code == 404
# Private world
resp = client.post(
"/api/worlds/",
headers=owner_headers,
json={"name": "Secret World", "visibility": "private"},
)
priv_world_id = resp.json()["id"]
# Other user gets 404
other_res = client.post(
f"/api/discovery/worlds/{priv_world_id}/visit",
headers=other_headers,
)
assert other_res.status_code == 404
# Anonymous user gets 404
anon_res = client.post(f"/api/discovery/worlds/{priv_world_id}/visit")
assert anon_res.status_code == 404
# Owner gets 200
owner_res = client.post(
f"/api/discovery/worlds/{priv_world_id}/visit",
headers=owner_headers,
)
assert owner_res.status_code == 200
assert owner_res.json()["play_count"] == 1
+153
View File
@@ -0,0 +1,153 @@
"""Unit tests for the game_servers.py changes made for world persistence
(world_persistence_impl.md §7.3/§7.4): per-spawn token validation, and the
``_await_ready`` replacement for the old, fail-open ``_await_port``.
No real Godot process is spawned here (that stays a manual/checkpoint
integration test, per test_world_join.py's docstring) -- these exercise
``GameServerSupervisor`` against a fake process object standing in for
``subprocess.Popen``.
"""
import json
import pytest
from app.config import settings
from app.services.game_servers import GameServerSupervisor, _WorldServer
class _FakeProc:
"""Stands in for subprocess.Popen: only .poll() is needed by the code
paths under test here."""
def __init__(self, exited: bool = False):
self._exited = exited
def poll(self):
return 0 if self._exited else None
def terminate(self):
self._exited = True
def wait(self, timeout=None):
return 0
def kill(self):
self._exited = True
def _make_srv(tmp_path, token="secret", alive=True) -> _WorldServer:
status_path = tmp_path / "status.json"
return _WorldServer(
world_id="7", port=27000, proc=_FakeProc(exited=not alive),
status_path=status_path, token=token,
)
class TestValidateToken:
def test_unknown_world_is_false(self):
sup = GameServerSupervisor()
assert sup.validate_token("no-such-world", "anything") is False
def test_correct_token_is_true(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
assert sup.validate_token("7", "secret") is True
# world_id is looked up as str(world_id), so an int works too --
# this matters because the FastAPI path param is an int.
assert sup.validate_token(7, "secret") is True
def test_wrong_token_is_false(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
assert sup.validate_token("7", "wrong") is False
def test_token_scoped_to_its_own_world_only(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
# Same token, different world_id -- must not authenticate (§7.3:
# "a path whose world_id differs from the token's world is 403").
assert sup.validate_token("8", "secret") is False
def test_dead_server_token_is_false(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret", alive=False)
assert sup.validate_token("7", "secret") is False
def test_reaping_invalidates_the_token(self, tmp_path):
"""§7.3: "dies with its process" -- once removed from _servers
(what the reaper does), the same token no longer validates."""
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
assert sup.validate_token("7", "secret") is True
del sup._servers["7"]
assert sup.validate_token("7", "secret") is False
class TestAwaitReady:
"""Replaces the old _await_port, which (a) treated a bound UDP port as
readiness even though content loading happens before create_server, and
(b) failed open past its deadline with no error at all."""
async def test_raises_timeout_error_when_never_ready(self, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path) # status file never written
with pytest.raises(TimeoutError):
await sup._await_ready(srv)
async def test_returns_once_status_file_reports_ready(self, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 5.0)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path)
srv.status_path.write_text(json.dumps({"ready": True, "players": 0}))
await sup._await_ready(srv) # must not raise / must not hang
async def test_ready_false_is_not_ready(self, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path)
srv.status_path.write_text(json.dumps({"ready": False, "players": 0}))
with pytest.raises(TimeoutError):
await sup._await_ready(srv)
async def test_raises_promptly_if_process_already_exited(self, tmp_path, monkeypatch):
# A long deadline, but the process is already dead -- must not wait
# out the full deadline (this is the "no crash restart mid-wait"
# case: don't silently poll a corpse for 20s).
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 20.0)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path, alive=False)
with pytest.raises(TimeoutError):
await sup._await_ready(srv)
class TestSpawnCommandLine:
def test_spawn_includes_api_url_and_server_token(self, monkeypatch, tmp_path):
"""world_persistence_impl.md §7.2 step 1 / §7.4: `_spawn` must pass
--api-url and --server-token on the command line."""
sup = GameServerSupervisor()
monkeypatch.setattr(sup, "_free_port", lambda: 27050)
captured = {}
class _Proc:
def poll(self):
return None
def _fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
return _Proc()
monkeypatch.setattr(
"app.services.game_servers.subprocess.Popen", _fake_popen
)
srv = sup._spawn("42")
cmd = captured["cmd"]
assert "--api-url" in cmd
assert cmd[cmd.index("--api-url") + 1] == settings.GAME_SERVER_INTERNAL_API_URL
assert "--server-token" in cmd
token_in_cmd = cmd[cmd.index("--server-token") + 1]
assert token_in_cmd == srv.token
assert len(srv.token) > 20 # secrets.token_urlsafe(32) -- not a short/guessable value
+258
View File
@@ -0,0 +1,258 @@
"""Model-level tests for groups: groups and group_members.
app/models/groups.py / alembic/versions/0009_add_group_tables.py
(docs/planning/social.md US-5). This is the models+migration task only --
there are no API endpoints yet (a sibling task, "Groups API", owns
create/join/leave/role authorization and the group page endpoint), so these
tests exercise the ORM models and the real Postgres constraints directly
rather than going through HTTP.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.groups import Group, GroupMember, GroupRole
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
class TestGroupCreationAndRoles:
async def test_create_group_with_members_of_different_roles(self):
client = TestClient(app)
owner = _register(client, "grp_role_owner")
admin = _register(client, "grp_role_admin")
member = _register(client, "grp_role_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Explorers", description="We explore worlds", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=admin, role=GroupRole.ADMIN),
GroupMember(group_id=group.id, user_id=member, role=GroupRole.MEMBER),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
fetched = await session.get(Group, group_id)
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
by_user = {m.user_id: m.role for m in members}
assert fetched.owner_id == owner
assert len(members) == 3
assert by_user[owner] == GroupRole.OWNER
assert by_user[admin] == GroupRole.ADMIN
assert by_user[member] == GroupRole.MEMBER
finally:
await engine.dispose()
async def test_membership_role_defaults_to_member(self):
client = TestClient(app)
owner = _register(client, "grp_default_owner")
joiner = _register(client, "grp_default_joiner")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Defaults", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=joiner))
await session.commit()
group_id = group.id
async with factory() as session:
gm = (
await session.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == joiner
)
)
).scalar_one()
assert gm.role == GroupRole.MEMBER
finally:
await engine.dispose()
class TestUniqueMembershipConstraint:
async def test_duplicate_membership_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_uq_owner")
member = _register(client, "grp_uq_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Uniqueness", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=member))
await session.commit()
group_id = group.id
async with factory() as session:
session.add(GroupMember(group_id=group_id, user_id=member))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_group_deletes_members(self):
client = TestClient(app)
owner = _register(client, "grp_cd_group_owner")
member = _register(client, "grp_cd_group_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="To Delete", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
# Core delete against groups directly, so the real ON DELETE
# CASCADE does the work (not the ORM).
await session.execute(delete(Group).where(Group.id == group_id))
await session.commit()
async with factory() as session:
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert members == []
finally:
await engine.dispose()
async def test_deleting_member_user_removes_only_their_membership(self):
client = TestClient(app)
owner = _register(client, "grp_cd_user_owner")
member = _register(client, "grp_cd_user_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Membership Cascade", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == member))
await session.commit()
async with factory() as session:
remaining = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
# Only the deleted member's row is gone; the group and the
# owner's membership survive.
assert len(remaining) == 1
assert remaining[0].user_id == owner
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id == owner
finally:
await engine.dispose()
async def test_deleting_owner_survives_with_owner_id_null(self):
"""Deleting the owner's account must not delete the group or its
other members -- only ``owner_id`` is nulled out (SET NULL), per the
module docstring in app/models/groups.py."""
client = TestClient(app)
owner = _register(client, "grp_cd_owner_owner")
member = _register(client, "grp_cd_owner_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Survives Owner Deletion", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == owner))
await session.commit()
async with factory() as session:
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id is None
assert group.name == "Survives Owner Deletion"
# The owner's own membership row is gone (user_id CASCADE),
# but the other member's row survives untouched.
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert len(members) == 1
assert members[0].user_id == member
finally:
await engine.dispose()
+437
View File
@@ -0,0 +1,437 @@
"""Integration tests for the Groups API router (app/routers/groups.py).
app/models/groups.py owns the schema (see test_group_models.py); this
covers create/join/leave, role authorization, ownership transfer, and the
group page's member-list privacy.
"""
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
def _create_group(client: TestClient, headers: dict, name: str, description: str | None = None) -> dict:
resp = client.post(
"/api/groups",
json={"name": name, "description": description},
headers=headers,
)
assert resp.status_code == 201, resp.text
return resp.json()
class TestCreateGroup:
def test_create_makes_creator_owner(self):
client = TestClient(app)
owner = _register(client, "grp_api_create_owner")
group = _create_group(client, owner["headers"], "Explorers", "We explore worlds")
assert group["name"] == "Explorers"
assert group["description"] == "We explore worlds"
assert group["owner_id"] == owner["id"]
assert group["member_count"] == 1
assert len(group["members"]) == 1
assert group["members"][0]["user_id"] == owner["id"]
assert group["members"][0]["role"] == "owner"
def test_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/groups", json={"name": "No Auth"})
assert resp.status_code == 401
def test_blank_name_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_blank_name")
resp = client.post("/api/groups", json={"name": " "}, headers=owner["headers"])
assert resp.status_code == 422
def test_name_over_max_length_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_long_name")
resp = client.post(
"/api/groups", json={"name": "x" * 101}, headers=owner["headers"]
)
assert resp.status_code == 422
class TestGroupPage:
def test_get_group_page_shows_members_and_roles_no_private_fields(self):
client = TestClient(app)
owner = _register(client, "grp_api_page_owner")
member = _register(client, "grp_api_page_member")
group = _create_group(client, owner["headers"], "Page Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.get(f"/api/groups/{group['id']}", headers=member["headers"])
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == "Page Group"
assert body["member_count"] == 2
by_user = {m["user_id"]: m for m in body["members"]}
assert by_user[owner["id"]]["role"] == "owner"
assert by_user[member["id"]]["role"] == "member"
assert by_user[member["id"]]["username"] == member["username"]
# No private User fields (email, hashed_password, etc.) leak.
for m in body["members"]:
assert set(m.keys()) == {"user_id", "username", "role", "joined_at"}
def test_get_nonexistent_group_is_404(self):
client = TestClient(app)
user = _register(client, "grp_api_404")
resp = client.get("/api/groups/999999999", headers=user["headers"])
assert resp.status_code == 404
class TestJoinLeave:
def test_join_is_idempotent(self):
client = TestClient(app)
owner = _register(client, "grp_api_join_owner")
joiner = _register(client, "grp_api_join_joiner")
group = _create_group(client, owner["headers"], "Join Group")
r1 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"])
assert r1.status_code == 200, r1.text
r2 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"])
assert r2.status_code == 200, r2.text
assert r1.json()["role"] == r2.json()["role"] == "member"
page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json()
assert page["member_count"] == 2 # not 3 -- duplicate join didn't add a row
def test_leave_removes_membership(self):
client = TestClient(app)
owner = _register(client, "grp_api_leave_owner")
member = _register(client, "grp_api_leave_member")
group = _create_group(client, owner["headers"], "Leave Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"])
assert resp.status_code == 200, resp.text
page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json()
assert page["member_count"] == 1
assert all(m["user_id"] != member["id"] for m in page["members"])
def test_owner_cannot_leave_without_transfer(self):
client = TestClient(app)
owner = _register(client, "grp_api_owner_leave")
group = _create_group(client, owner["headers"], "Owner Leave Group")
resp = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"])
assert resp.status_code == 409, resp.text
def test_leave_when_not_a_member_is_404(self):
client = TestClient(app)
owner = _register(client, "grp_api_notmember_owner")
stranger = _register(client, "grp_api_notmember_stranger")
group = _create_group(client, owner["headers"], "Not A Member Group")
resp = client.post(f"/api/groups/{group['id']}/leave", headers=stranger["headers"])
assert resp.status_code == 404
class TestRoleAuthorization:
def test_owner_can_promote_and_demote(self):
client = TestClient(app)
owner = _register(client, "grp_api_role_owner")
member = _register(client, "grp_api_role_member")
group = _create_group(client, owner["headers"], "Role Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
promote = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
assert promote.status_code == 200, promote.text
assert promote.json()["role"] == "admin"
demote = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "member"},
headers=owner["headers"],
)
assert demote.status_code == 200, demote.text
assert demote.json()["role"] == "member"
def test_plain_member_cannot_promote(self):
client = TestClient(app)
owner = _register(client, "grp_api_noauth_owner")
member = _register(client, "grp_api_noauth_member")
other = _register(client, "grp_api_noauth_other")
group = _create_group(client, owner["headers"], "No Auth Promote Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
client.post(f"/api/groups/{group['id']}/join", headers=other["headers"])
resp = client.post(
f"/api/groups/{group['id']}/members/{other['id']}/role",
json={"role": "admin"},
headers=member["headers"],
)
assert resp.status_code == 403
def test_admin_cannot_promote_or_demote(self):
"""Only the owner may change roles -- admins cannot, per spec."""
client = TestClient(app)
owner = _register(client, "grp_api_adminauth_owner")
admin = _register(client, "grp_api_adminauth_admin")
other = _register(client, "grp_api_adminauth_other")
group = _create_group(client, owner["headers"], "Admin Cannot Promote Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(f"/api/groups/{group['id']}/join", headers=other["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.post(
f"/api/groups/{group['id']}/members/{other['id']}/role",
json={"role": "admin"},
headers=admin["headers"],
)
assert resp.status_code == 403
def test_plain_member_cannot_delete_group(self):
client = TestClient(app)
owner = _register(client, "grp_api_nodelete_owner")
member = _register(client, "grp_api_nodelete_member")
group = _create_group(client, owner["headers"], "No Delete Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.delete(f"/api/groups/{group['id']}", headers=member["headers"])
assert resp.status_code == 403
def test_admin_cannot_delete_group(self):
client = TestClient(app)
owner = _register(client, "grp_api_admindelete_owner")
admin = _register(client, "grp_api_admindelete_admin")
group = _create_group(client, owner["headers"], "Admin No Delete Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.delete(f"/api/groups/{group['id']}", headers=admin["headers"])
assert resp.status_code == 403
def test_plain_member_cannot_transfer_ownership(self):
client = TestClient(app)
owner = _register(client, "grp_api_notransfer_owner")
member = _register(client, "grp_api_notransfer_member")
group = _create_group(client, owner["headers"], "No Transfer Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": member["id"]},
headers=member["headers"],
)
assert resp.status_code == 403
def test_cannot_set_role_to_owner_via_role_endpoint(self):
client = TestClient(app)
owner = _register(client, "grp_api_noownerrole_owner")
member = _register(client, "grp_api_noownerrole_member")
group = _create_group(client, owner["headers"], "No Owner Role Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "owner"},
headers=owner["headers"],
)
assert resp.status_code == 422
class TestOwnershipTransfer:
def test_transfer_moves_ownership_and_demotes_old_owner(self):
client = TestClient(app)
owner = _register(client, "grp_api_xfer_owner")
member = _register(client, "grp_api_xfer_member")
group = _create_group(client, owner["headers"], "Transfer Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": member["id"]},
headers=owner["headers"],
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["owner_id"] == member["id"]
by_user = {m["user_id"]: m for m in body["members"]}
assert by_user[member["id"]]["role"] == "owner"
assert by_user[owner["id"]]["role"] == "admin"
# Exactly one owner remains.
owners = [m for m in body["members"] if m["role"] == "owner"]
assert len(owners) == 1
# Old owner can now leave (no longer the owner).
leave = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"])
assert leave.status_code == 200, leave.text
# New owner cannot leave without transferring again.
leave2 = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"])
assert leave2.status_code == 409
def test_transfer_target_must_be_a_member(self):
client = TestClient(app)
owner = _register(client, "grp_api_xfernonmember_owner")
stranger = _register(client, "grp_api_xfernonmember_stranger")
group = _create_group(client, owner["headers"], "Transfer Nonmember Group")
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": stranger["id"]},
headers=owner["headers"],
)
assert resp.status_code == 404
def test_transfer_to_self_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_xferself_owner")
group = _create_group(client, owner["headers"], "Transfer Self Group")
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": owner["id"]},
headers=owner["headers"],
)
assert resp.status_code == 400
class TestUpdateGroup:
def test_owner_can_update_name_and_description(self):
client = TestClient(app)
owner = _register(client, "grp_api_update_owner")
group = _create_group(client, owner["headers"], "Old Name", "Old desc")
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "New Name", "description": "New desc"},
headers=owner["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "New Name"
assert resp.json()["description"] == "New desc"
def test_admin_can_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_adminupdate_owner")
admin = _register(client, "grp_api_adminupdate_admin")
group = _create_group(client, owner["headers"], "Admin Update Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Updated By Admin"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "Updated By Admin"
def test_plain_member_cannot_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_noupdate_owner")
member = _register(client, "grp_api_noupdate_member")
group = _create_group(client, owner["headers"], "No Update Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Hacked Name"},
headers=member["headers"],
)
assert resp.status_code == 403
def test_non_member_cannot_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_strangernoupdate_owner")
stranger = _register(client, "grp_api_strangernoupdate_stranger")
group = _create_group(client, owner["headers"], "Stranger No Update Group")
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Hacked Name"},
headers=stranger["headers"],
)
assert resp.status_code == 403
class TestDeleteGroup:
def test_owner_can_delete(self):
client = TestClient(app)
owner = _register(client, "grp_api_delete_owner")
group = _create_group(client, owner["headers"], "Delete Group")
resp = client.delete(f"/api/groups/{group['id']}", headers=owner["headers"])
assert resp.status_code == 204, resp.text
after = client.get(f"/api/groups/{group['id']}", headers=owner["headers"])
assert after.status_code == 404
class TestListGroups:
def test_list_all_groups_paginated(self):
client = TestClient(app)
owner = _register(client, "grp_api_list_owner")
_create_group(client, owner["headers"], "List Group A")
_create_group(client, owner["headers"], "List Group B")
resp = client.get("/api/groups", params={"limit": 1}, headers=owner["headers"])
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body["groups"]) == 1
assert body["total"] >= 2
def test_list_mine_only_returns_own_groups(self):
client = TestClient(app)
owner = _register(client, "grp_api_listmine_owner")
other_owner = _register(client, "grp_api_listmine_other")
mine = _create_group(client, owner["headers"], "Mine Group")
_create_group(client, other_owner["headers"], "Not Mine Group")
resp = client.get("/api/groups", params={"mine": True}, headers=owner["headers"])
assert resp.status_code == 200, resp.text
ids = [g["id"] for g in resp.json()["groups"]]
assert mine["id"] in ids
assert all(
g["id"] != mine["id"] or g["owner_id"] == owner["id"]
for g in resp.json()["groups"]
)
# The other owner's group must not appear.
other_group_ids = [
g["id"] for g in client.get(
"/api/groups", params={"mine": True}, headers=other_owner["headers"]
).json()["groups"]
]
assert mine["id"] not in other_group_ids
+167
View File
@@ -0,0 +1,167 @@
"""Integration tests for Marketplace Listings API.
docs/planning/economy.md US-3 (task f51c7d95e237).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_catalog_items_and_listings_crud():
client = TestClient(app)
uid1, h1 = _register(client, "mkt_user_1")
uid2, h2 = _register(client, "mkt_user_2")
# 1. Create catalog item by user 1
resp = client.post(
"/api/marketplace/items",
headers=h1,
json={
"name": "Wizard Hat",
"description": "A pointy blue hat with stars",
"item_type": "avatar_item",
"metadata_json": {"color": "blue", "rarity": "rare"},
},
)
assert resp.status_code == 201, resp.text
item_data = resp.json()
assert item_data["name"] == "Wizard Hat"
assert item_data["owner_id"] == uid1
item_id = item_data["id"]
# 2. List catalog items
resp = client.get("/api/marketplace/items", headers=h1, params={"mine": "true"})
assert resp.status_code == 200
my_items = resp.json()
assert len(my_items) >= 1
assert any(i["id"] == item_id for i in my_items)
resp = client.get("/api/marketplace/items", headers=h2, params={"mine": "true"})
assert resp.status_code == 200
assert not any(i["id"] == item_id for i in resp.json())
# 3. User 2 tries to list User 1's item -> 403
resp = client.post(
"/api/marketplace/listings",
headers=h2,
json={
"catalog_item_id": item_id,
"price": 100,
},
)
assert resp.status_code == 403, resp.text
# Invalid price <= 0 -> 422
resp = client.post(
"/api/marketplace/listings",
headers=h1,
json={
"catalog_item_id": item_id,
"price": 0,
},
)
assert resp.status_code == 422
# 4. User 1 successfully lists item
resp = client.post(
"/api/marketplace/listings",
headers=h1,
json={
"catalog_item_id": item_id,
"price": 250,
},
)
assert resp.status_code == 201, resp.text
listing = resp.json()
assert listing["price"] == 250
assert listing["seller_id"] == uid1
assert listing["status"] == "active"
assert listing["catalog_item"]["name"] == "Wizard Hat"
listing_id = listing["id"]
# 5. Search / browse listings
resp = client.get("/api/marketplace/listings", params={"q": "Wizard"})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] >= 1
assert any(l["id"] == listing_id for l in data["listings"])
# Filter by item_type
resp = client.get("/api/marketplace/listings", params={"item_type": "avatar_item"})
assert resp.status_code == 200
assert any(l["id"] == listing_id for l in resp.json()["listings"])
resp = client.get("/api/marketplace/listings", params={"item_type": "experience_pass"})
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
# Price range filter
resp = client.get("/api/marketplace/listings", params={"min_price": 300})
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
resp = client.get("/api/marketplace/listings", params={"max_price": 260})
assert resp.status_code == 200
assert any(l["id"] == listing_id for l in resp.json()["listings"])
# 6. Listing detail
resp = client.get(f"/api/marketplace/listings/{listing_id}")
assert resp.status_code == 200
assert resp.json()["id"] == listing_id
# 7. Update listing
# User 2 cannot update User 1's listing
resp = client.patch(
f"/api/marketplace/listings/{listing_id}",
headers=h2,
json={"price": 500},
)
assert resp.status_code == 403
# User 1 updates price
resp = client.patch(
f"/api/marketplace/listings/{listing_id}",
headers=h1,
json={"price": 350},
)
assert resp.status_code == 200
assert resp.json()["price"] == 350
# 8. Delist listing
# User 2 cannot delist
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h2)
assert resp.status_code == 403
# User 1 delists
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h1)
assert resp.status_code == 204
# Now detail shows delisted
resp = client.get(f"/api/marketplace/listings/{listing_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "delisted"
# Default search only shows active
resp = client.get("/api/marketplace/listings")
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
+175
View File
@@ -0,0 +1,175 @@
"""Model-level integration tests for Marketplace tables.
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ItemType,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_catalog_item_and_listing_creation():
client = TestClient(app)
seller_id = _register(client, "market_seller_1")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(
owner_id=seller_id,
item_type=ItemType.avatar_item,
name="Cyber Wings",
description="Glow in the dark wings",
metadata_json={"slot": "back"},
)
session.add(item)
await session.commit()
await session.refresh(item)
assert item.id is not None
assert item.name == "Cyber Wings"
listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=seller_id,
price=250,
status=ListingStatus.active,
)
session.add(listing)
await session.commit()
await session.refresh(listing)
assert listing.id is not None
assert listing.price == 250
assert listing.status == ListingStatus.active
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_listing_price_positive_constraint():
client = TestClient(app)
seller_id = _register(client, "market_seller_2")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(
owner_id=seller_id,
item_type=ItemType.asset,
name="Free Prop",
)
session.add(item)
await session.commit()
await session.refresh(item)
bad_listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=seller_id,
price=0, # Must be > 0
)
session.add(bad_listing)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_creator_balance_uniqueness_and_constraints():
client = TestClient(app)
creator_id = _register(client, "market_creator_1")
engine, sf = _session_factory()
try:
async with sf() as session:
cb1 = CreatorBalance(user_id=creator_id, available=100, pending=50, lifetime_earned=150)
session.add(cb1)
await session.commit()
await session.refresh(cb1)
assert cb1.available == 100
assert cb1.pending == 50
assert cb1.lifetime_earned == 150
# Unique user_id constraint
async with sf() as session:
cb2 = CreatorBalance(user_id=creator_id)
session.add(cb2)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_purchase_record_creation():
client = TestClient(app)
seller_id = _register(client, "market_seller_3")
buyer_id = _register(client, "market_buyer_3")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(owner_id=seller_id, item_type=ItemType.asset, name="Sword Prop")
session.add(item)
await session.commit()
await session.refresh(item)
listing = MarketplaceListing(catalog_item_id=item.id, seller_id=seller_id, price=100)
session.add(listing)
await session.commit()
await session.refresh(listing)
purchase = Purchase(
listing_id=listing.id,
buyer_id=buyer_id,
seller_id=seller_id,
price=100,
platform_fee=30,
seller_net=70,
status=PurchaseStatus.pending_escrow,
)
session.add(purchase)
await session.commit()
await session.refresh(purchase)
assert purchase.id is not None
assert purchase.seller_net == 70
assert purchase.status == PurchaseStatus.pending_escrow
finally:
await engine.dispose()
@@ -0,0 +1,190 @@
"""Integration tests for Marketplace Moderation, Content Flags, and Age Gate Enforcement.
docs/planning/economy.md US-3, Task 9 (task 4ba24a178c1b).
"""
from datetime import date
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(
client: TestClient, username: str, dob: str = "1990-01-01"
) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": dob,
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
async def _set_user_dob(user_id: int, dob: date):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.models.user import User
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
user.date_of_birth = dob
await session.commit()
await engine.dispose()
@pytest.mark.asyncio
async def test_marketplace_age_gate_enforcement():
client = TestClient(app)
seller_id, seller_headers = _register(client, "mature_seller_1")
# Register minor and set DOB in DB to 2015
minor_id, minor_headers = _register(client, "minor_buyer_1")
await _set_user_dob(minor_id, date(2015, 6, 1))
# Adult user
adult_id, adult_headers = _register(client, "adult_buyer_1")
# Fund minor and adult with 500 Credits
for h in (minor_headers, adult_headers):
s_resp = client.post("/api/payments/purchase", headers=h, json={"credits": 500})
client.post("/api/payments/confirm", headers=h, json={"session_id": s_resp.json()["session_id"]})
# Seller creates mature item
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={
"name": "Blood Sword",
"item_type": "avatar_item",
"metadata_json": {"is_mature": True, "content_flags": ["violence.gore"]},
},
)
assert item_resp.status_code == 201
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": item_id, "price": 100},
)
assert list_resp.status_code == 201
listing_id = list_resp.json()["id"]
# 1. Minor attempts to purchase mature item -> 403 Forbidden
minor_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=minor_headers,
)
assert minor_buy.status_code == 403
assert "MATURE_CONTENT_REQUIRES_VERIFICATION" in minor_buy.text
# 2. Adult purchases mature item -> 201 Created
adult_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=adult_headers,
)
assert adult_buy.status_code == 201, adult_buy.text
assert adult_buy.json()["status"] == "pending_escrow"
def test_moderator_delist_remove_and_reversal():
client = TestClient(app)
mod_id, mod_headers = _register(client, "admin_mkt_mod_1", dob="1980-01-01")
seller_id, seller_headers = _register(client, "seller_mod_test", dob="1990-01-01")
buyer_id, buyer_headers = _register(client, "buyer_mod_test", dob="1990-01-01")
# Fund buyer
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
# Create listing 1
i1 = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Violating Item", "item_type": "avatar_item"},
).json()
l1 = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": i1["id"], "price": 100},
).json()
l1_id = l1["id"]
# Non-moderator cannot moderate listing -> 403
unauth = client.post(
f"/api/marketplace/listings/{l1_id}/moderate",
headers=buyer_headers,
json={"action": "remove", "reason": "Copyright infringement"},
)
assert unauth.status_code == 403
# Moderator removes listing
mod_resp = client.post(
f"/api/marketplace/listings/{l1_id}/moderate",
headers=mod_headers,
json={"action": "remove", "reason": "Copyright infringement"},
)
assert mod_resp.status_code == 200, mod_resp.text
assert mod_resp.json()["status"] == "removed_by_moderation"
# Removed listing cannot be purchased
buy_fail = client.post(
f"/api/marketplace/listings/{l1_id}/purchase",
headers=buyer_headers,
)
assert buy_fail.status_code == 400
# Create listing 2 and purchase it
i2 = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Contested Item", "item_type": "avatar_item"},
).json()
l2 = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": i2["id"], "price": 150},
).json()
p2 = client.post(
f"/api/marketplace/listings/{l2['id']}/purchase",
headers=buyer_headers,
).json()
p2_id = p2["id"]
# Buyer balance is 350
w = client.get("/api/wallet", headers=buyer_headers).json()
assert w["balance"] == 350
# Non-moderator cannot reverse purchase -> 403
unauth_rev = client.post(
f"/api/marketplace/purchases/{p2_id}/reverse",
headers=buyer_headers,
json={"reason": "Buyer regret"},
)
assert unauth_rev.status_code == 403
# Moderator reverses purchase
rev_resp = client.post(
f"/api/marketplace/purchases/{p2_id}/reverse",
headers=mod_headers,
json={"reason": "Fraudulent listing detected"},
)
assert rev_resp.status_code == 200, rev_resp.text
assert rev_resp.json()["status"] == "reversed"
# Buyer balance restored to 500
w_after = client.get("/api/wallet", headers=buyer_headers).json()
assert w_after["balance"] == 500
+222
View File
@@ -0,0 +1,222 @@
"""Integration tests for Marketplace Purchase Service, Escrow, and Fee Splitting.
docs/planning/economy.md US-4 (task bc90d3f481b4).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.purchase import purchase_service
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_fee_split_calculation():
# 30% platform fee
fee, net = purchase_service.compute_fee_split(100)
assert fee == 30
assert net == 70
fee, net = purchase_service.compute_fee_split(250)
assert fee == 75
assert net == 175
def test_marketplace_purchase_atomic_flow():
client = TestClient(app)
seller_id, seller_headers = _register(client, "purchase_seller_1")
buyer_id, buyer_headers = _register(client, "purchase_buyer_1")
# 1. Seller creates catalog item and lists it for 200 Credits
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={
"name": "Dragon Armor",
"description": "Legendary chestplate",
"item_type": "avatar_item",
},
)
assert item_resp.status_code == 201, item_resp.text
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={
"catalog_item_id": item_id,
"price": 200,
},
)
assert list_resp.status_code == 201, list_resp.text
listing_id = list_resp.json()["id"]
# 2. Seller cannot purchase own listing
self_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=seller_headers,
)
assert self_buy.status_code == 400
assert "own listing" in self_buy.text
# 3. Buyer attempts purchase without enough credits -> 400
poor_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buyer_headers,
)
assert poor_buy.status_code == 400
assert "Insufficient funds" in poor_buy.text
# 4. Buyer purchases 500 Credits via payments API
sess_resp = client.post(
"/api/payments/purchase",
headers=buyer_headers,
json={"credits": 500},
)
assert sess_resp.status_code == 201, sess_resp.text
sess_id = sess_resp.json()["session_id"]
conf_resp = client.post(
"/api/payments/confirm",
headers=buyer_headers,
json={"session_id": sess_id},
)
assert conf_resp.status_code == 200, conf_resp.text
assert conf_resp.json()["new_balance"] == 500
# 5. Buyer purchases listing for 200 Credits
buy_headers = dict(buyer_headers)
buy_headers["Idempotency-Key"] = "buy-test-key-1"
buy_resp = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buy_headers,
)
assert buy_resp.status_code == 201, buy_resp.text
purchase_data = buy_resp.json()
assert purchase_data["price"] == 200
assert purchase_data["platform_fee"] == 60
assert purchase_data["seller_net"] == 140
assert purchase_data["status"] == "pending_escrow"
assert purchase_data["buyer_id"] == buyer_id
assert purchase_data["seller_id"] == seller_id
purchase_id = purchase_data["id"]
# 6. Check buyer wallet balance: 500 - 200 = 300
w_resp = client.get("/api/wallet", headers=buyer_headers)
assert w_resp.status_code == 200
assert w_resp.json()["balance"] == 300
# 7. Check buyer has granted item in inventory
inv_resp = client.get("/api/marketplace/items", headers=buyer_headers, params={"mine": "true"})
assert inv_resp.status_code == 200
my_items = inv_resp.json()
assert any(i["name"] == "Dragon Armor" and i["owner_id"] == buyer_id for i in my_items)
# 8. Listing is now sold and cannot be purchased again
re_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buyer_headers,
)
assert re_buy.status_code == 400
assert "not active" in re_buy.text
# 9. Seller's creator balance is pending escrow
cb_resp = client.get("/api/marketplace/creator/balance", headers=seller_headers)
assert cb_resp.status_code == 200, cb_resp.text
cb = cb_resp.json()
assert cb["pending"] == 140
assert cb["available"] == 0
# 10. List purchases
p_resp = client.get("/api/marketplace/purchases", headers=buyer_headers)
assert p_resp.status_code == 200
assert any(p["id"] == purchase_id for p in p_resp.json())
# 11. Escrow release
rel_resp = client.post(
f"/api/marketplace/purchases/{purchase_id}/release",
headers=seller_headers,
)
assert rel_resp.status_code == 200, rel_resp.text
assert rel_resp.json()["status"] == "completed"
# Check seller's creator balance now shows available = 140
cb_resp2 = client.get("/api/marketplace/creator/balance", headers=seller_headers)
assert cb_resp2.status_code == 200
cb2 = cb_resp2.json()
assert cb2["pending"] == 0
assert cb2["available"] == 140
assert cb2["lifetime_earned"] == 140
def test_marketplace_refund():
client = TestClient(app)
seller_id, seller_headers = _register(client, "refund_seller_1")
buyer_id, buyer_headers = _register(client, "refund_buyer_1")
# Item & Listing
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Magic Ring", "item_type": "avatar_item"},
)
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": item_id, "price": 100},
)
listing_id = list_resp.json()["id"]
# Fund buyer
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
assert s_resp.status_code == 201, s_resp.text
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
# Purchase
buy_resp = client.post(f"/api/marketplace/listings/{listing_id}/purchase", headers=buyer_headers)
assert buy_resp.status_code == 201
purchase_id = buy_resp.json()["id"]
# Buyer balance is 400
w1 = client.get("/api/wallet", headers=buyer_headers).json()
assert w1["balance"] == 400
# Seller pending is 70
cb1 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
assert cb1["pending"] == 70
# Refund
ref_resp = client.post(
f"/api/marketplace/purchases/{purchase_id}/refund",
headers=seller_headers,
json={"reason": "Buyer dissatisfaction"},
)
assert ref_resp.status_code == 200, ref_resp.text
assert ref_resp.json()["status"] == "refunded"
# Buyer balance refunded back to 500
w2 = client.get("/api/wallet", headers=buyer_headers).json()
assert w2["balance"] == 500
# Seller pending reset to 0
cb2 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
assert cb2["pending"] == 0
+123
View File
@@ -0,0 +1,123 @@
"""Integration tests for Premium Purchase and PaymentProvider.
docs/planning/economy.md US-2 (task 575fd14b5360).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_payments_unauthorized():
client = TestClient(app)
resp = client.post("/api/payments/purchase", json={"credits": 500})
assert resp.status_code == 401
resp = client.post("/api/payments/confirm", json={"session_id": "sess_123"})
assert resp.status_code == 401
def test_purchase_rejects_arbitrary_amounts():
client = TestClient(app)
_, headers = _register(client, "pay_user_1")
# 42 credits is not an allowed pack
resp = client.post("/api/payments/purchase", json={"credits": 42}, headers=headers)
assert resp.status_code == 422
# Negative / 0 credits
resp = client.post("/api/payments/purchase", json={"credits": -100}, headers=headers)
assert resp.status_code == 422
def test_purchase_and_confirm_flow():
client = TestClient(app)
uid, headers = _register(client, "pay_user_2")
# 1. Check initial balance
w_resp = client.get("/api/wallet", headers=headers)
assert w_resp.status_code == 200
assert w_resp.json()["balance"] == 0
# 2. Purchase session for 500 credits
p_resp = client.post(
"/api/payments/purchase",
json={"credits": 500},
headers={"Idempotency-Key": "sess_req_1", **headers},
)
assert p_resp.status_code == 201, p_resp.text
p_data = p_resp.json()
assert p_data["credits"] == 500
assert p_data["session_id"].startswith("sess_")
session_id = p_data["session_id"]
# Balance is still 0 before confirmation
w_resp = client.get("/api/wallet", headers=headers)
assert w_resp.json()["balance"] == 0
# 3. Confirm purchase session
c_resp = client.post(
"/api/payments/confirm",
json={"session_id": session_id},
headers=headers,
)
assert c_resp.status_code == 200, c_resp.text
c_data = c_resp.json()
assert c_data["confirmed"] is True
assert c_data["credits"] == 500
assert c_data["new_balance"] == 500
assert c_data["provider_ref"] != ""
# 4. Idempotent re-confirm must not double-credit
c_resp2 = client.post(
"/api/payments/confirm",
json={"session_id": session_id},
headers=headers,
)
assert c_resp2.status_code == 200
assert c_resp2.json()["new_balance"] == 500
# Check transactions in ledger: exactly 1 transaction
tx_resp = client.get("/api/wallet/transactions", headers=headers)
assert tx_resp.status_code == 200
tx_data = tx_resp.json()
assert tx_data["total"] == 1
assert tx_data["transactions"][0]["amount"] == 500
assert tx_data["transactions"][0]["type"] == "credit_purchase"
def test_confirm_invalid_or_wrong_user_fails():
client = TestClient(app)
uid1, headers1 = _register(client, "pay_user_3a")
uid2, headers2 = _register(client, "pay_user_3b")
# User 1 creates a session
p_resp = client.post("/api/payments/purchase", json={"credits": 100}, headers=headers1)
session_id = p_resp.json()["session_id"]
# User 2 tries to confirm User 1's session -> must fail 400
bad_resp = client.post("/api/payments/confirm", json={"session_id": session_id}, headers=headers2)
assert bad_resp.status_code == 400
# Non-existent session -> must fail 400
bad_resp2 = client.post("/api/payments/confirm", json={"session_id": "sess_nonexistent"}, headers=headers1)
assert bad_resp2.status_code == 400
+108
View File
@@ -0,0 +1,108 @@
"""Integration tests for Payout Admin Review, PayoutProvider, and Ledger Audit Trail.
docs/planning/economy.md US-5 (task b52008923e93).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.marketplace import CreatorBalance
from app.models.wallet import Transaction, TransactionType
from app.services.payout import NoopPayoutProvider
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
async def _fund_creator_balance(user_id: int, available_amount: int):
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
cb = CreatorBalance(
user_id=user_id,
available=available_amount,
pending=0,
lifetime_earned=available_amount,
)
session.add(cb)
await session.commit()
await engine.dispose()
@pytest.mark.asyncio
async def test_payout_provider_noop():
provider = NoopPayoutProvider()
success, ref = await provider.pay(user_id=1, amount=500, external_ref="test@example.com")
assert success is True
assert ref.startswith("noop_payout_")
@pytest.mark.asyncio
async def test_payout_admin_review_audit_trail_and_ledger():
client = TestClient(app)
admin_id, admin_headers = _register(client, "admin_audit_reviewer")
creator_id, creator_headers = _register(client, "audit_creator_user")
await _fund_creator_balance(creator_id, 1500)
# 1. Creator requests payout
req_resp = client.post(
"/api/payouts",
headers=creator_headers,
json={"amount": 600, "external_ref": "stripe_acct_456"},
)
assert req_resp.status_code == 201, req_resp.text
payout_id = req_resp.json()["id"]
# 2. Admin approves payout
appr_resp = client.post(
f"/api/payouts/{payout_id}/approve",
headers=admin_headers,
)
assert appr_resp.status_code == 200, appr_resp.text
data = appr_resp.json()
assert data["status"] == "paid"
assert data["reviewed_by"] == admin_id
assert data["reviewed_at"] is not None
assert data["provider_ref"] is not None
provider_ref = data["provider_ref"]
# 3. Verify ledger transaction row was recorded for audit trail
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
stmt = (
select(Transaction)
.where(
Transaction.reference_type == "payout_request",
Transaction.reference_id == str(payout_id),
)
)
tx = (await session.execute(stmt)).scalar_one_or_none()
assert tx is not None
assert tx.type == TransactionType.payout
assert tx.amount == -600
assert tx.metadata_json["provider_ref"] == provider_ref
assert tx.metadata_json["reviewed_by"] == admin_id
assert tx.metadata_json["external_ref"] == "stripe_acct_456"
await engine.dispose()
+186
View File
@@ -0,0 +1,186 @@
"""Integration tests for Creator Payouts: requests, list, admin review, and balance accounting.
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.marketplace import CreatorBalance
from app.models.user import User
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
async def _fund_creator_balance(user_id: int, available_amount: int):
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
cb = CreatorBalance(
user_id=user_id,
available=available_amount,
pending=0,
lifetime_earned=available_amount,
)
session.add(cb)
await session.commit()
await engine.dispose()
async def _make_admin(user_id: int):
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
user.is_admin = True
await session.commit()
await engine.dispose()
@pytest.mark.asyncio
async def test_payout_request_and_validation():
client = TestClient(app)
uid, headers = _register(client, "payout_creator_1")
_, other_headers = _register(client, "payout_other_user")
# Min threshold is 100 CR
resp = client.post(
"/api/payouts",
headers=headers,
json={"amount": 50},
)
assert resp.status_code == 422, resp.text
# Without balance, payout of 200 CR fails
resp = client.post(
"/api/payouts",
headers=headers,
json={"amount": 200},
)
assert resp.status_code == 400
assert "Insufficient available balance" in resp.text
# Seed 500 CR available creator balance
await _fund_creator_balance(uid, 500)
# Valid payout request of 200 CR
resp = client.post(
"/api/payouts",
headers=headers,
json={"amount": 200, "external_ref": "creator@paypal.com"},
)
assert resp.status_code == 201, resp.text
payout_data = resp.json()
assert payout_data["amount"] == 200
assert payout_data["status"] == "pending"
assert payout_data["external_ref"] == "creator@paypal.com"
payout_id = payout_data["id"]
# Check creator balance: 500 - 200 = 300 available, 200 pending
cb_resp = client.get("/api/marketplace/creator/balance", headers=headers)
assert cb_resp.status_code == 200
cb = cb_resp.json()
assert cb["available"] == 300
assert cb["pending"] == 200
# List payouts: creator sees own request
list_resp = client.get("/api/payouts", headers=headers)
assert list_resp.status_code == 200
my_payouts = list_resp.json()
assert len(my_payouts) == 1
assert my_payouts[0]["id"] == payout_id
# Other user sees empty list
other_list = client.get("/api/payouts", headers=other_headers)
assert other_list.status_code == 200
assert len(other_list.json()) == 0
# Other user cannot view detail -> 403
det_resp = client.get(f"/api/payouts/{payout_id}", headers=other_headers)
assert det_resp.status_code == 403
@pytest.mark.asyncio
async def test_payout_admin_approve_and_reject():
client = TestClient(app)
admin_id, admin_headers = _register(client, "admin_payout_reviewer")
creator_id, creator_headers = _register(client, "payout_creator_2")
await _fund_creator_balance(creator_id, 1000)
# 1. Request payout of 300 CR
p1_resp = client.post(
"/api/payouts",
headers=creator_headers,
json={"amount": 300, "external_ref": "iban_123"},
)
assert p1_resp.status_code == 201
p1_id = p1_resp.json()["id"]
# 2. Non-admin cannot approve -> 403
non_admin_appr = client.post(f"/api/payouts/{p1_id}/approve", headers=creator_headers)
assert non_admin_appr.status_code == 403
# 3. Admin approves payout 1
appr_resp = client.post(f"/api/payouts/{p1_id}/approve", headers=admin_headers)
assert appr_resp.status_code == 200, appr_resp.text
p1_data = appr_resp.json()
assert p1_data["status"] == "paid"
assert p1_data["reviewed_by"] == admin_id
assert p1_data["provider_ref"] is not None
# Balance check: available was 700, pending was 300 -> now pending = 0
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
assert cb_resp.json()["available"] == 700
assert cb_resp.json()["pending"] == 0
# 4. Request payout 2 of 200 CR
p2_resp = client.post(
"/api/payouts",
headers=creator_headers,
json={"amount": 200},
)
assert p2_resp.status_code == 201
p2_id = p2_resp.json()["id"]
# available was 700 - 200 = 500, pending = 200
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
assert cb_resp.json()["available"] == 500
assert cb_resp.json()["pending"] == 200
# 5. Admin rejects payout 2
rej_resp = client.post(
f"/api/payouts/{p2_id}/reject",
headers=admin_headers,
json={"reason": "Incorrect payment details"},
)
assert rej_resp.status_code == 200, rej_resp.text
p2_data = rej_resp.json()
assert p2_data["status"] == "rejected"
assert p2_data["reviewed_by"] == admin_id
# Creator balance refunded: 500 + 200 = 700 available, 0 pending
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
assert cb_resp.json()["available"] == 700
assert cb_resp.json()["pending"] == 0
+221
View File
@@ -0,0 +1,221 @@
"""Integration tests for the presence feature:
- POST /api/presence/heartbeat
- GET /api/presence/presence
- PATCH /api/presence/status
Hits the real app against the configured Postgres *and* a real Redis
(conftest.py brings up an ephemeral instance of each when neither is
already reachable) -- presence is fundamentally "does a TTL key exist in
Redis", which isn't worth faking with a mock.
Also covers the regression fixed in 47ab492 ("Fix StatusUpdate field
annotation and is_online batch-call mismatch"): the router used to call
``PresenceStore.is_online(ids)`` with a *list* of ids and then evaluate
``uid in online_ids`` against the returned bool, which raised a TypeError
on the first real multi-id batch call, and ``StatusUpdate.status_message``
used the invalid annotation ``str | Field(...)``. Both are exercised below.
"""
import asyncio
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.presence import PresenceStore
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"]}
class TestHeartbeat:
def test_heartbeat_sets_online(self):
client = TestClient(app)
user = _register(client, "presence_hb1")
resp = client.post("/api/presence/heartbeat", json={}, headers=user["headers"])
assert resp.status_code == 200, resp.text
assert resp.json() == {"status": "ok", "user_id": user["id"]}
presence = client.get(
"/api/presence/presence",
params={"user_ids": str(user["id"])},
headers=user["headers"],
)
assert presence.status_code == 200, presence.text
body = presence.json()["users"]
assert len(body) == 1
assert body[0]["user_id"] == user["id"]
assert body[0]["is_online"] is True
assert body[0]["last_seen_at"] is not None
def test_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/presence/heartbeat", json={})
assert resp.status_code == 401
def test_never_seen_user_is_offline_with_null_last_seen(self):
client = TestClient(app)
user = _register(client, "presence_never_hb")
presence = client.get(
"/api/presence/presence",
params={"user_ids": str(user["id"])},
headers=user["headers"],
)
assert presence.status_code == 200
body = presence.json()["users"][0]
assert body["is_online"] is False
assert body["last_seen_at"] is None
class TestBatchPresence:
"""Covers the batch-call path fixed in 47ab492 (is_online_batch via MGET)."""
def test_batch_query_mixes_online_offline_and_unknown_ids(self):
client = TestClient(app)
online_user = _register(client, "presence_batch_online")
offline_user = _register(client, "presence_batch_offline")
client.post("/api/presence/heartbeat", json={}, headers=online_user["headers"])
unknown_id = max(online_user["id"], offline_user["id"]) + 999_999
ids = f"{online_user['id']},{offline_user['id']},{unknown_id}"
resp = client.get(
"/api/presence/presence",
params={"user_ids": ids},
headers=online_user["headers"],
)
assert resp.status_code == 200, resp.text
by_id = {u["user_id"]: u for u in resp.json()["users"]}
# Unknown id is silently dropped (not in the DB), not a 500 or 404.
assert unknown_id not in by_id
assert by_id[online_user["id"]]["is_online"] is True
assert by_id[offline_user["id"]]["is_online"] is False
def test_empty_user_ids_is_a_400(self):
client = TestClient(app)
user = _register(client, "presence_empty_ids")
resp = client.get(
"/api/presence/presence",
params={"user_ids": ""},
headers=user["headers"],
)
assert resp.status_code == 400
class TestStatusMessage:
def test_update_and_read_back_status_message(self):
client = TestClient(app)
user = _register(client, "presence_status1")
resp = client.patch(
"/api/presence/status",
json={"status_message": " brb, making coffee "},
headers=user["headers"],
)
assert resp.status_code == 200, resp.text
# The router strips whitespace (StatusUpdate.strip_message).
assert resp.json()["status_message"] == "brb, making coffee"
presence = client.get(
"/api/presence/presence",
params={"user_ids": str(user["id"])},
headers=user["headers"],
)
assert presence.json()["users"][0]["status_message"] == "brb, making coffee"
def test_status_message_over_max_length_is_rejected(self):
client = TestClient(app)
user = _register(client, "presence_status_toolong")
resp = client.patch(
"/api/presence/status",
json={"status_message": "x" * 141},
headers=user["headers"],
)
# StatusUpdate.status_message: str | None = Field(..., max_length=140)
# This is exactly the annotation 47ab492 fixed (it used to be the
# invalid `str | Field(...)`, which breaks model construction
# entirely rather than validating length).
assert resp.status_code == 422
def test_status_message_at_max_length_is_accepted(self):
client = TestClient(app)
user = _register(client, "presence_status_maxlen")
resp = client.patch(
"/api/presence/status",
json={"status_message": "x" * 140},
headers=user["headers"],
)
assert resp.status_code == 200, resp.text
def test_null_status_message_clears_it(self):
client = TestClient(app)
user = _register(client, "presence_status_clear")
client.patch("/api/presence/status", json={"status_message": "hi"}, headers=user["headers"])
resp = client.patch("/api/presence/status", json={"status_message": None}, headers=user["headers"])
assert resp.status_code == 200
assert resp.json()["status_message"] is None
class TestPresenceStoreTTL:
"""Exercises the real Redis TTL semantics directly against the service,
with a short heartbeat_ttl so the test doesn't need to sleep for the
real 60s default used in production. This is the cheap, real proxy for
"TTL expiry means offline" the task asked for -- same code path
(real Redis SET ... EX / EXISTS / MGET), just a much shorter window.
"""
async def test_ttl_expiry_flips_online_to_offline(self):
store = PresenceStore(heartbeat_ttl=1)
try:
uid = 987654321 # doesn't need to exist in Postgres; Redis-only check
assert await store.is_online(uid) is False
await store.heartbeat(uid)
assert await store.is_online(uid) is True
await asyncio.sleep(1.5)
assert await store.is_online(uid) is False
finally:
await store.close()
async def test_is_online_batch_matches_is_online_semantics(self):
"""Regression coverage for 47ab492: is_online_batch must return a
set of ids usable with `in`, not a bool."""
store = PresenceStore(heartbeat_ttl=30)
try:
online_uid, offline_uid = 111222333, 444555666
await store.heartbeat(online_uid)
result = await store.is_online_batch([online_uid, offline_uid])
assert isinstance(result, set)
assert online_uid in result
assert offline_uid not in result
finally:
await store.close()
async def test_is_online_batch_empty_list(self):
store = PresenceStore()
try:
assert await store.is_online_batch([]) == set()
finally:
await store.close()
+406
View File
@@ -0,0 +1,406 @@
"""Integration tests for /api/profile/me and /api/profile/me/favorites.
Hits the real app against the configured Postgres (same contract as the
other integration tests -- migrations applied first).
"""
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.profile import DEFAULT_PRIVACY
def _auth_headers(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
def _me_id(client: TestClient, headers: dict) -> int:
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _make_world(client: TestClient, headers: dict, name: str = "W") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": name, "visibility": "public"})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_asset(client: TestClient, headers: dict, name: str = "A") -> int:
"""Insert an asset row directly via raw SQL.
Not going through POST /api/assets/, and not through the Asset ORM
model, deliberately: Asset.asset_type's ENUM column has no
values_callable, so SQLAlchemy sends the Python enum *name* (e.g.
"IMAGE") to Postgres instead of the lowercase value the DB enum
actually stores ("image"), and both the router and a plain ORM insert
500/fail on it. That's a pre-existing bug unrelated to this task; these
tests only need a real asset row to reference by id, so insert with raw
SQL to sidestep the buggy enum type entirely.
"""
owner_id = _me_id(client, headers)
async def _create() -> int:
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
async with engine.begin() as conn:
result = await conn.execute(
text(
"INSERT INTO assets (name, asset_type, file_path, owner_id, is_public) "
"VALUES (:name, 'image', '', :owner_id, false) RETURNING id"
),
{"name": name, "owner_id": owner_id},
)
asset_id = result.scalar_one()
await engine.dispose()
return asset_id
return asyncio.run(_create())
class TestGetProfile:
def test_new_user_gets_the_default(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_default")
resp = client.get("/api/profile/me", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert body["bio"] is None
assert body["avatar_asset_id"] is None
assert body["privacy"] == DEFAULT_PRIVACY
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me").status_code == 401
class TestPutProfile:
def test_save_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_save")
asset_id = _make_asset(client, headers)
put = client.put(
"/api/profile/me", headers=headers,
json={"bio": "hello world", "avatar_asset_id": asset_id,
"privacy": {"profile_visibility": "friends"}},
)
assert put.status_code == 200, put.text
body = put.json()
assert body["is_default"] is False
assert body["bio"] == "hello world"
assert body["avatar_asset_id"] == asset_id
assert body["privacy"]["profile_visibility"] == "friends"
# unspecified privacy keys keep their default
assert body["privacy"]["friends_list_visibility"] == "public"
got = client.get("/api/profile/me", headers=headers)
assert got.status_code == 200
assert got.json() == body
def test_partial_update_preserves_other_fields(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_partial")
client.put("/api/profile/me", headers=headers, json={"bio": "first bio"})
put2 = client.put("/api/profile/me", headers=headers, json={"privacy": {"favorites_visibility": "private"}})
assert put2.status_code == 200, put2.text
body = put2.json()
assert body["bio"] == "first bio"
assert body["privacy"]["favorites_visibility"] == "private"
def test_other_user_cannot_edit_my_profile(self):
"""There is no user_id-scoped edit route at all -- PUT /me always
acts on the caller's own JWT identity, so another user's token can
never touch this user's profile."""
client = TestClient(app)
headers_a = _auth_headers(client, "pf_owner")
headers_b = _auth_headers(client, "pf_other")
client.put("/api/profile/me", headers=headers_a, json={"bio": "owner's bio"})
client.put("/api/profile/me", headers=headers_b, json={"bio": "other's bio"})
got_a = client.get("/api/profile/me", headers=headers_a).json()
got_b = client.get("/api/profile/me", headers=headers_b).json()
assert got_a["bio"] == "owner's bio"
assert got_b["bio"] == "other's bio"
assert got_a["user_id"] != got_b["user_id"]
def test_rejects_invalid_avatar_asset_id(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badasset")
resp = client.put("/api/profile/me", headers=headers, json={"avatar_asset_id": 999999})
assert resp.status_code == 422
def test_rejects_over_length_bio(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_longbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 501})
assert resp.status_code == 422
def test_accepts_bio_at_max_length(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_maxbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 500})
assert resp.status_code == 200, resp.text
def test_rejects_unknown_privacy_key(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivkey")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"nonsense_key": "public"}})
assert resp.status_code == 422
def test_rejects_invalid_privacy_value(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivval")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"profile_visibility": "nope"}})
assert resp.status_code == 422
def test_requires_auth(self):
client = TestClient(app)
resp = client.put("/api/profile/me", json={"bio": "x"})
assert resp.status_code == 401
class TestFavorites:
def test_new_user_has_no_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_default")
resp = client.get("/api/profile/me/favorites", headers=headers)
assert resp.status_code == 200
assert resp.json() == {"world_ids": []}
def test_replace_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_save")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
put = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, w2]})
assert put.status_code == 200, put.text
assert set(put.json()["world_ids"]) == {w1, w2}
got = client.get("/api/profile/me/favorites", headers=headers)
assert set(got.json()["world_ids"]) == {w1, w2}
def test_replace_is_a_full_replace_not_append(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_replace")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
second = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w2]})
assert second.json()["world_ids"] == [w2]
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == [w2]
def test_replace_with_empty_list_clears_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_clear")
w1 = _make_world(client, headers, "W1")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
cleared = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": []})
assert cleared.status_code == 200
assert cleared.json() == {"world_ids": []}
def test_rejects_nonexistent_world_id(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_badworld")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [999999]})
assert resp.status_code == 422
def test_rejects_nonexistent_world_id_mixed_with_valid(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_mixed")
w1 = _make_world(client, headers, "W1")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, 999999]})
assert resp.status_code == 422
# nothing partially applied
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == []
def test_favorites_isolated_per_user(self):
client = TestClient(app)
headers_a = _auth_headers(client, "fav_owner")
headers_b = _auth_headers(client, "fav_other")
w1 = _make_world(client, headers_a, "W1")
client.put("/api/profile/me/favorites", headers=headers_a, json={"world_ids": [w1]})
got_b = client.get("/api/profile/me/favorites", headers=headers_b)
assert got_b.json()["world_ids"] == []
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me/favorites").status_code == 401
assert client.put("/api/profile/me/favorites", json={"world_ids": []}).status_code == 401
class TestPublicProfile:
def test_public_profile_full_view(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_alice")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_bob")
w1 = _make_world(client, h_alice, "AliceWorld")
asset_id = _make_asset(client, h_alice, "AliceAsset")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Hi, I am Alice", "avatar_asset_id": asset_id},
)
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["user_id"] == alice_id
assert data["username"] == "pub_alice"
assert data["bio"] == "Hi, I am Alice"
assert data["avatar_asset_id"] == asset_id
assert data["is_restricted"] is False
assert len(data["favorites"]) == 1
assert data["favorites"][0]["id"] == w1
assert data["favorites"][0]["name"] == "AliceWorld"
assert "email" not in data
assert "password" not in data
def test_public_profile_not_found(self):
client = TestClient(app)
h_bob = _auth_headers(client, "pub_nf_bob")
resp = client.get("/api/profile/999999", headers=h_bob)
assert resp.status_code == 404
def test_public_profile_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/1").status_code == 401
def test_public_profile_private_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_priv_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_priv_b")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Top secret bio", "privacy": {"profile_visibility": "private"}},
)
resp_bob = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_bob.status_code == 200
data_bob = resp_bob.json()
assert data_bob["is_restricted"] is True
assert data_bob["bio"] is None
assert data_bob["favorites"] == []
assert data_bob["friends"] == []
# Alice viewing herself is not restricted
resp_alice = client.get(f"/api/profile/{alice_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is False
assert resp_alice.json()["bio"] == "Top secret bio"
def test_public_profile_friends_only_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_fo_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_fo_b")
bob_id = _me_id(client, h_bob)
h_charlie = _auth_headers(client, "pub_fo_c")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Friends only bio", "privacy": {"profile_visibility": "friends"}},
)
# Charlie is not a friend -> restricted
resp_c = client.get(f"/api/profile/{alice_id}", headers=h_charlie)
assert resp_c.json()["is_restricted"] is True
assert resp_c.json()["bio"] is None
# Bob becomes friends with Alice
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
# Now Bob sees full profile and friends list contains Bob
resp_b = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_b.json()["is_restricted"] is False
assert resp_b.json()["bio"] == "Friends only bio"
assert any(f["id"] == bob_id for f in resp_b.json()["friends"])
def test_public_profile_blocked_user_gets_restricted(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_blk_a")
alice_id = _me_id(client, h_alice)
h_dan = _auth_headers(client, "pub_blk_d")
dan_id = _me_id(client, h_dan)
client.put("/api/profile/me", headers=h_alice, json={"bio": "Alice public bio"})
client.post(f"/api/friends/{dan_id}/block", headers=h_alice)
# Dan viewing Alice gets restricted view
resp_dan = client.get(f"/api/profile/{alice_id}", headers=h_dan)
assert resp_dan.status_code == 200
assert resp_dan.json()["is_restricted"] is True
assert resp_dan.json()["bio"] is None
# Alice viewing Dan gets restricted view
resp_alice = client.get(f"/api/profile/{dan_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is True
def test_public_profile_selective_favorites_and_friends_privacy(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_sec_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_sec_b")
bob_id = _me_id(client, h_bob)
w1 = _make_world(client, h_alice, "WorldSec")
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
client.put(
"/api/profile/me",
headers=h_alice,
json={
"bio": "Bio is public",
"privacy": {
"profile_visibility": "public",
"favorites_visibility": "private",
"friends_list_visibility": "private",
},
},
)
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200
data = resp.json()
assert data["bio"] == "Bio is public"
assert data["favorites"] == []
assert data["friends"] == []
+68
View File
@@ -1,6 +1,7 @@
"""The auth endpoints are rate-limited per client IP."""
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from app import rate_limit
@@ -54,3 +55,70 @@ def test_limiter_window_is_per_key():
with pytest.raises(Exception):
limiter.check("a")
limiter.check("b") # different key, unaffected
def _make_named_limit_app():
"""A tiny standalone app exercising two independent named limits, so
these tests don't depend on any sibling endpoint existing yet."""
limit_a = rate_limit.rate_limit("test_named_a", max_hits=2, window_seconds=60)
limit_b = rate_limit.rate_limit("test_named_b", max_hits=2, window_seconds=60)
app = FastAPI()
@app.post("/a", dependencies=[Depends(limit_a)])
def _a():
return {"ok": True}
@app.post("/b", dependencies=[Depends(limit_b)])
def _b():
return {"ok": True}
return TestClient(app)
def test_named_limits_do_not_share_counters():
client = _make_named_limit_app()
# Burn out limit "a" entirely.
assert client.post("/a").status_code == 200
assert client.post("/a").status_code == 200
r = client.post("/a")
assert r.status_code == 429
assert "Retry-After" in r.headers
# Limit "b" is untouched -- same process, same client IP, different name.
assert client.post("/b").status_code == 200
assert client.post("/b").status_code == 200
assert client.post("/b").status_code == 429
def test_named_limit_returns_429_with_retry_after_once_exhausted():
client = _make_named_limit_app()
client.post("/a")
client.post("/a")
r = client.post("/a")
assert r.status_code == 429
retry_after = r.headers["Retry-After"]
assert int(retry_after) >= 1
def test_named_limit_is_a_noop_when_rate_limiting_disabled():
prev = settings.RATE_LIMIT_ENABLED
settings.RATE_LIMIT_ENABLED = False
try:
client = _make_named_limit_app()
codes = [client.post("/a").status_code for _ in range(10)]
assert codes == [200] * 10
finally:
settings.RATE_LIMIT_ENABLED = prev
def test_reset_all_clears_named_limiters_too():
client = _make_named_limit_app()
client.post("/a")
client.post("/a")
assert client.post("/a").status_code == 429
rate_limit.reset_all()
assert client.post("/a").status_code == 200
+317
View File
@@ -0,0 +1,317 @@
"""Integration tests for the friends/follow/block API:
- POST /api/friends/requests
- GET /api/friends/requests
- POST /api/friends/requests/{id}/accept
- POST /api/friends/requests/{id}/decline
- DELETE /api/friends/{user_id}
- POST /api/friends/{user_id}/follow
- DELETE /api/friends/{user_id}/follow
- POST /api/friends/{user_id}/block
- DELETE /api/friends/{user_id}/block
- GET /api/friends
- GET /api/friends/following
- GET /api/friends/blocked
app/routers/social_friends.py, built on the merged models in
app/models/social.py (Friendship/Follow/Block).
"""
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"]}
class TestFriendRequestFlow:
def test_send_list_accept_makes_mutual_friendship(self):
client = TestClient(app)
a = _register(client, "fr_flow_a")
b = _register(client, "fr_flow_b")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert resp.status_code == 201, resp.text
request_id = resp.json()["id"]
assert resp.json()["status"] == "pending"
incoming = client.get("/api/friends/requests", headers=b["headers"])
assert incoming.status_code == 200
ids = [r["id"] for r in incoming.json()["requests"]]
assert request_id in ids
# Requester doesn't see it in their own incoming list.
a_incoming = client.get("/api/friends/requests", headers=a["headers"])
assert a_incoming.json()["requests"] == []
accept = client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
assert accept.status_code == 200, accept.text
assert accept.json()["status"] == "accepted"
# Mutual: shows up in both users' friend lists.
a_friends = client.get("/api/friends", headers=a["headers"]).json()["friends"]
b_friends = client.get("/api/friends", headers=b["headers"]).json()["friends"]
assert any(u["id"] == b["id"] for u in a_friends)
assert any(u["id"] == a["id"] for u in b_friends)
def test_decline_removes_request_and_allows_re_request(self):
client = TestClient(app)
a = _register(client, "fr_decl_a")
b = _register(client, "fr_decl_b")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
request_id = resp.json()["id"]
decline = client.post(f"/api/friends/requests/{request_id}/decline", headers=b["headers"])
assert decline.status_code == 200, decline.text
assert client.get("/api/friends/requests", headers=b["headers"]).json()["requests"] == []
# A can send a new request now that the old one is gone.
resp2 = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert resp2.status_code == 201, resp2.text
def test_accept_or_decline_of_others_request_is_404_not_403(self):
client = TestClient(app)
a = _register(client, "fr_enum_a")
b = _register(client, "fr_enum_b")
c = _register(client, "fr_enum_c")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
request_id = resp.json()["id"]
# c is neither requester nor addressee -- must not be able to act on it,
# and must not be able to distinguish "not yours" from "doesn't exist".
accept = client.post(f"/api/friends/requests/{request_id}/accept", headers=c["headers"])
assert accept.status_code == 404
decline = client.post(f"/api/friends/requests/{request_id}/decline", headers=c["headers"])
assert decline.status_code == 404
nonexistent = client.post("/api/friends/requests/999999999/accept", headers=c["headers"])
assert nonexistent.status_code == 404
def test_unfriend_removes_mutual_friendship(self):
client = TestClient(app)
a = _register(client, "fr_unfr_a")
b = _register(client, "fr_unfr_b")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
unfriend = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert unfriend.status_code == 200, unfriend.text
assert client.get("/api/friends", headers=a["headers"]).json()["friends"] == []
assert client.get("/api/friends", headers=b["headers"]).json()["friends"] == []
# Idempotency / no dangling state: unfriending again is a clean 404, not 500.
again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert again.status_code == 404
class TestSelfAndDuplicateRejection:
def test_self_request_rejected(self):
client = TestClient(app)
a = _register(client, "self_req_a")
resp = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=a["headers"])
assert resp.status_code == 400
def test_self_follow_rejected(self):
client = TestClient(app)
a = _register(client, "self_follow_a")
resp = client.post(f"/api/friends/{a['id']}/follow", headers=a["headers"])
assert resp.status_code == 400
def test_self_block_rejected(self):
client = TestClient(app)
a = _register(client, "self_block_a")
resp = client.post(f"/api/friends/{a['id']}/block", headers=a["headers"])
assert resp.status_code == 400
def test_duplicate_friend_request_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_req_a")
b = _register(client, "dup_req_b")
assert client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]).status_code == 201
dup = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert dup.status_code == 409
def test_reverse_duplicate_friend_request_is_conflict(self):
"""b requesting a while a's request to b is still pending is treated
as a duplicate of the same pair, not a second independent request."""
client = TestClient(app)
a = _register(client, "dup_rev_a")
b = _register(client, "dup_rev_b")
assert client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]).status_code == 201
reverse = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=b["headers"])
assert reverse.status_code == 409
def test_duplicate_follow_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_follow_a")
b = _register(client, "dup_follow_b")
assert client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"]).status_code == 201
dup = client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert dup.status_code == 409
def test_duplicate_block_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_block_a")
b = _register(client, "dup_block_b")
assert client.post(f"/api/friends/{b['id']}/block", headers=a["headers"]).status_code == 201
dup = client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert dup.status_code == 409
def test_friend_request_to_nonexistent_user_is_404(self):
client = TestClient(app)
a = _register(client, "req_missing_a")
resp = client.post("/api/friends/requests", json={"user_id": 999999999}, headers=a["headers"])
assert resp.status_code == 404
class TestFollow:
def test_follow_unfollow_and_list(self):
client = TestClient(app)
a = _register(client, "follow_flow_a")
b = _register(client, "follow_flow_b")
follow = client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert follow.status_code == 201, follow.text
following = client.get("/api/friends/following", headers=a["headers"]).json()["following"]
assert any(u["id"] == b["id"] for u in following)
# One-way: b does not follow a.
b_following = client.get("/api/friends/following", headers=b["headers"]).json()["following"]
assert b_following == []
unfollow = client.delete(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert unfollow.status_code == 200, unfollow.text
following_after = client.get("/api/friends/following", headers=a["headers"]).json()["following"]
assert following_after == []
# Unfollowing again is a clean 404, not 500.
again = client.delete(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert again.status_code == 404
def test_follow_requires_auth(self):
client = TestClient(app)
b = _register(client, "follow_noauth_b")
resp = client.post(f"/api/friends/{b['id']}/follow", json={})
assert resp.status_code == 401
class TestBlock:
def test_block_clears_existing_friendship_and_follow(self):
client = TestClient(app)
a = _register(client, "block_clear_a")
b = _register(client, "block_clear_b")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"])
assert any(
u["id"] == b["id"] for u in client.get("/api/friends", headers=a["headers"]).json()["friends"]
)
assert any(
u["id"] == a["id"]
for u in client.get("/api/friends/following", headers=b["headers"]).json()["following"]
)
block = client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert block.status_code == 201, block.text
assert client.get("/api/friends", headers=a["headers"]).json()["friends"] == []
assert client.get("/api/friends", headers=b["headers"]).json()["friends"] == []
assert client.get("/api/friends/following", headers=b["headers"]).json()["following"] == []
blocked_list = client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"]
assert any(u["id"] == b["id"] for u in blocked_list)
def test_block_prevents_new_friend_request_and_follow(self):
client = TestClient(app)
a = _register(client, "block_prevent_a")
b = _register(client, "block_prevent_b")
client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
req = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=b["headers"])
assert req.status_code == 403
follow = client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"])
assert follow.status_code == 403
def test_unblock_removes_block(self):
client = TestClient(app)
a = _register(client, "unblock_a")
b = _register(client, "unblock_b")
client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
unblock = client.delete(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert unblock.status_code == 200, unblock.text
assert client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"] == []
# Idempotency: unblocking again is a clean 404, not 500.
again = client.delete(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert again.status_code == 404
# After unblock, a fresh follow/request works again.
assert client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"]).status_code == 201
class TestDataIsolation:
def test_list_endpoints_are_scoped_to_caller(self):
"""A third party's friends/following/blocked lists must never leak
into another user's response."""
client = TestClient(app)
a = _register(client, "iso_a")
b = _register(client, "iso_b")
c = _register(client, "iso_c")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
client.post(f"/api/friends/{b['id']}/block", headers=c["headers"])
c_friends = client.get("/api/friends", headers=c["headers"]).json()["friends"]
c_following = client.get("/api/friends/following", headers=c["headers"]).json()["following"]
assert c_friends == []
assert c_following == []
# c's block of b must not appear in a's or b's blocked list.
a_blocked = client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"]
assert a_blocked == []
def test_requires_auth_on_all_endpoints(self):
client = TestClient(app)
assert client.get("/api/friends").status_code == 401
assert client.get("/api/friends/requests").status_code == 401
assert client.get("/api/friends/following").status_code == 401
assert client.get("/api/friends/blocked").status_code == 401
assert client.post("/api/friends/requests", json={"user_id": 1}).status_code == 401
+240
View File
@@ -0,0 +1,240 @@
"""Model-level tests for the social graph: friendships, follows, blocks.
app/models/social.py / alembic/versions/0007_add_social_graph_tables.py
(docs/planning/social.md US-1). This is the models+migration task only --
there are no API endpoints yet (a sibling task owns the router), so these
tests exercise the ORM models and the real Postgres constraints directly
rather than going through HTTP.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
class TestFriendshipRequestAccept:
async def test_request_defaults_to_pending(self):
client = TestClient(app)
a = _register(client, "sg_fr_a1")
b = _register(client, "sg_fr_b1")
engine, factory = _session_factory()
try:
async with factory() as session:
fr = Friendship(requester_id=a, addressee_id=b)
session.add(fr)
await session.commit()
await session.refresh(fr)
assert fr.status == FriendshipStatus.PENDING
assert fr.requester_id == a
assert fr.addressee_id == b
finally:
await engine.dispose()
async def test_accept_updates_status(self):
client = TestClient(app)
a = _register(client, "sg_fr_a2")
b = _register(client, "sg_fr_b2")
engine, factory = _session_factory()
try:
async with factory() as session:
fr = Friendship(requester_id=a, addressee_id=b)
session.add(fr)
await session.commit()
fr_id = fr.id
async with factory() as session:
fr = await session.get(Friendship, fr_id)
fr.status = FriendshipStatus.ACCEPTED
await session.commit()
async with factory() as session:
fr = await session.get(Friendship, fr_id)
assert fr.status == FriendshipStatus.ACCEPTED
finally:
await engine.dispose()
class TestUniquePairConstraints:
async def test_duplicate_friendship_pair_rejected(self):
client = TestClient(app)
a = _register(client, "sg_uq_fr_a")
b = _register(client, "sg_uq_fr_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Friendship(requester_id=a, addressee_id=b))
await session.commit()
async with factory() as session:
session.add(Friendship(requester_id=a, addressee_id=b))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
async def test_duplicate_follow_pair_rejected(self):
client = TestClient(app)
a = _register(client, "sg_uq_fw_a")
b = _register(client, "sg_uq_fw_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Follow(follower_id=a, followed_id=b))
await session.commit()
async with factory() as session:
session.add(Follow(follower_id=a, followed_id=b))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
async def test_duplicate_block_pair_rejected(self):
client = TestClient(app)
a = _register(client, "sg_uq_bl_a")
b = _register(client, "sg_uq_bl_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Block(blocker_id=a, blocked_id=b))
await session.commit()
async with factory() as session:
session.add(Block(blocker_id=a, blocked_id=b))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestFollow:
async def test_create_follow(self):
client = TestClient(app)
a = _register(client, "sg_follow_a")
b = _register(client, "sg_follow_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Follow(follower_id=a, followed_id=b))
await session.commit()
async with factory() as session:
rows = (await session.execute(
select(Follow).where(Follow.follower_id == a, Follow.followed_id == b)
)).scalars().all()
assert len(rows) == 1
finally:
await engine.dispose()
class TestBlock:
async def test_create_block(self):
client = TestClient(app)
a = _register(client, "sg_block_a")
b = _register(client, "sg_block_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Block(blocker_id=a, blocked_id=b))
await session.commit()
async with factory() as session:
rows = (await session.execute(
select(Block).where(Block.blocker_id == a, Block.blocked_id == b)
)).scalars().all()
assert len(rows) == 1
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_user_removes_rows_in_all_three_tables(self):
"""Deleting a user removes friendships/follows/blocks referencing them
in *either* FK column, via the real DB ON DELETE CASCADE (deleted with
a Core statement, bypassing any ORM-level cascade, to prove the
database constraint itself does the job)."""
client = TestClient(app)
a = _register(client, "sg_cascade_a")
b = _register(client, "sg_cascade_b")
c = _register(client, "sg_cascade_c")
engine, factory = _session_factory()
try:
async with factory() as session:
# a as requester/follower/blocker, and also as addressee (c -> a)
session.add(Friendship(requester_id=a, addressee_id=b))
session.add(Friendship(requester_id=c, addressee_id=a))
session.add(Follow(follower_id=a, followed_id=b))
session.add(Follow(follower_id=c, followed_id=a))
session.add(Block(blocker_id=a, blocked_id=b))
session.add(Block(blocker_id=c, blocked_id=a))
await session.commit()
async with factory() as session:
await session.execute(delete(User).where(User.id == a))
await session.commit()
async with factory() as session:
friendships = (await session.execute(
select(Friendship).where(
(Friendship.requester_id == a) | (Friendship.addressee_id == a)
)
)).scalars().all()
follows = (await session.execute(
select(Follow).where(
(Follow.follower_id == a) | (Follow.followed_id == a)
)
)).scalars().all()
blocks = (await session.execute(
select(Block).where(
(Block.blocker_id == a) | (Block.blocked_id == a)
)
)).scalars().all()
assert friendships == []
assert follows == []
assert blocks == []
# Unrelated rows (b, c only) survive.
remaining_users = (await session.execute(
select(User.id).where(User.id.in_([b, c]))
)).scalars().all()
assert set(remaining_users) == {b, c}
finally:
await engine.dispose()
+194
View File
@@ -0,0 +1,194 @@
"""Unit tests for app.services.text_filter (docs/planning/social.md §4/§5 US-4).
Pure, side-effect-free service -- no DB/Redis fixtures needed.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.services.text_filter import (
DEFAULT_TERMS_PATH,
FilterAction,
FilterMode,
FilterResult,
ListBasedTextFilter,
TextFilter,
default_text_filter,
load_terms,
)
@pytest.fixture
def redact_filter() -> ListBasedTextFilter:
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REDACT)
@pytest.fixture
def reject_filter() -> ListBasedTextFilter:
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REJECT)
class TestAllowPath:
def test_clean_text_is_allowed_unchanged(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("hello there, how are you today?")
assert result.action is FilterAction.ALLOW
assert result.filtered_text == "hello there, how are you today?"
assert result.matched_terms == ()
def test_message_with_no_matches(self, reject_filter: ListBasedTextFilter) -> None:
# Distinct from the clean-text case above: exercises the reject-mode
# filter's no-match path specifically (mode should not matter when
# nothing matches).
text = "let's meet at the plaza around noon"
result = reject_filter.check(text)
assert result.action is FilterAction.ALLOW
assert result.filtered_text == text
assert result.matched_terms == ()
class TestRedactPath:
def test_exact_match_is_redacted(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("don't be a badword about it")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
assert "badword" not in result.filtered_text
assert result.filtered_text == "don't be a ******* about it"
def test_multiple_distinct_matches(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("badword, don't shoot!")
assert result.action is FilterAction.REDACT
assert set(result.matched_terms) == {"badword", "shoot"}
assert "badword" not in result.filtered_text
assert "shoot" not in result.filtered_text
# Length is preserved (asterisks replace matched spans 1:1).
assert len(result.filtered_text) == len("badword, don't shoot!")
def test_redaction_char_is_configurable(self) -> None:
f = ListBasedTextFilter(terms=["badword"], mode=FilterMode.REDACT, redaction_char="#")
result = f.check("that's a badword thing to say")
assert result.filtered_text == "that's a ####### thing to say"
class TestRejectPath:
def test_match_is_rejected_and_text_left_intact(self, reject_filter: ListBasedTextFilter) -> None:
text = "stop, badword!"
result = reject_filter.check(text)
assert result.action is FilterAction.REJECT
assert result.matched_terms == ("badword",)
# Reject mode does not mutate the text -- callers are expected to
# not store/deliver it at all, based on `action`.
assert result.filtered_text == text
class TestNormalization:
def test_leetspeak_variant_is_caught(self, redact_filter: ListBasedTextFilter) -> None:
# "b4dw0rd" -> normalizes to "badword" via the leet map (4->a, 0->o).
result = redact_filter.check("you're being a b4dw0rd right now")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_separators_between_letters_are_stripped(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("that is b.a.d.w.o.r.d behavior")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_spacing_between_letters_is_stripped(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("b a d w o r d, seriously")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_case_folding(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("BADWORD in all caps")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_symbol_lookalikes(self) -> None:
# @ -> a, $ -> s: "@ss" should normalize to "ass".
f = ListBasedTextFilter(terms=["ass"], mode=FilterMode.REDACT)
result = f.check("what an @$$ move")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("ass",)
class TestEmptyAndWhitespace:
def test_empty_string(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("")
assert result.action is FilterAction.ALLOW
assert result.filtered_text == ""
assert result.matched_terms == ()
def test_whitespace_only_string(self, redact_filter: ListBasedTextFilter) -> None:
text = " \t\n "
result = redact_filter.check(text)
assert result.action is FilterAction.ALLOW
assert result.filtered_text == text
assert result.matched_terms == ()
def test_punctuation_only_string_has_nothing_to_normalize(self, redact_filter: ListBasedTextFilter) -> None:
# No alnum characters survive normalization at all -- must not
# crash on an empty normalized string / empty index_map.
result = redact_filter.check("...!!!???")
assert result.action is FilterAction.ALLOW
assert result.matched_terms == ()
class TestConfigurability:
def test_custom_term_list_is_used_instead_of_default(self) -> None:
f = ListBasedTextFilter(terms=["zzzcustomzzz"], mode=FilterMode.REDACT)
result = f.check("this contains zzzcustomzzz right here")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("zzzcustomzzz",)
# Default list terms should NOT match since we overrode the list.
result2 = f.check("this is fucking unrelated")
assert result2.action is FilterAction.ALLOW
def test_duplicate_and_blank_terms_are_deduped(self) -> None:
f = ListBasedTextFilter(terms=["badword", "badword", " ", ""], mode=FilterMode.REDACT)
assert f._terms == ("badword",)
def test_default_terms_file_loads_and_is_nonempty(self) -> None:
terms = load_terms()
assert isinstance(terms, list)
assert len(terms) > 0
assert all(isinstance(t, str) and t for t in terms)
def test_default_terms_path_exists(self) -> None:
assert DEFAULT_TERMS_PATH.exists()
assert DEFAULT_TERMS_PATH.suffix == ".json"
def test_load_terms_from_explicit_path(self, tmp_path: Path) -> None:
custom = tmp_path / "custom_terms.json"
custom.write_text('{"terms": ["foo", "bar"]}', encoding="utf-8")
assert load_terms(custom) == ["foo", "bar"]
def test_default_text_filter_factory_matches_default_list(self) -> None:
f = default_text_filter(mode=FilterMode.REJECT)
result = f.check("that's fucking ridiculous")
assert result.action is FilterAction.REJECT
assert "fuck" in result.matched_terms
class TestProtocol:
def test_list_based_filter_satisfies_text_filter_protocol(self, redact_filter: ListBasedTextFilter) -> None:
assert isinstance(redact_filter, TextFilter)
def test_check_returns_filter_result(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("hi")
assert isinstance(result, FilterResult)
def test_a_third_party_style_implementation_satisfies_the_protocol(self) -> None:
# Demonstrates the extension point: any object with a matching
# `check` method satisfies TextFilter without inheriting from
# anything -- this is how a future third-party provider would plug
# in without touching this module.
class AlwaysAllow:
def check(self, text: str) -> FilterResult:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
provider: TextFilter = AlwaysAllow()
assert isinstance(provider, TextFilter)
result = provider.check("anything at all, even fuck")
assert result.action is FilterAction.ALLOW
+135
View File
@@ -0,0 +1,135 @@
"""Integration tests for Wallet service and API endpoints.
docs/planning/economy.md US-1 (task 909e3e9a59af).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.wallet import TransactionType
from app.services.wallet import InsufficientFundsError, InvalidAmountError, wallet_service
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
def test_wallet_unauthorized():
client = TestClient(app)
resp = client.get("/api/wallet")
assert resp.status_code == 401
resp = client.get("/api/wallet/transactions")
assert resp.status_code == 401
def test_get_wallet_lazy_creation():
client = TestClient(app)
uid, headers = _register(client, "wallet_api_user_1")
resp = client.get("/api/wallet", headers=headers)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["user_id"] == uid
assert data["balance"] == 0
assert data["id"] is not None
@pytest.mark.asyncio
async def test_wallet_service_credit_and_debit_flow():
client = TestClient(app)
uid, headers = _register(client, "wallet_api_user_2")
engine, sf = _session_factory()
try:
async with sf() as session:
# 1. Credit
wallet, tx = await wallet_service.credit(
session,
user_id=uid,
amount=1000,
tx_type=TransactionType.credit_purchase,
reference_type="test_ref",
reference_id="ref_1",
idempotency_key="credit_idemp_1",
)
assert wallet.balance == 1000
assert tx.amount == 1000
assert tx.balance_after == 1000
# 2. Idempotent re-credit does not increase balance
wallet2, tx2 = await wallet_service.credit(
session,
user_id=uid,
amount=1000,
tx_type=TransactionType.credit_purchase,
idempotency_key="credit_idemp_1",
)
assert wallet2.balance == 1000
assert tx2.id == tx.id
# 3. Debit
wallet3, tx3 = await wallet_service.debit(
session,
user_id=uid,
amount=400,
tx_type=TransactionType.marketplace_purchase,
reference_type="purchase",
reference_id="item_42",
idempotency_key="debit_idemp_1",
)
assert wallet3.balance == 600
assert tx3.amount == -400
assert tx3.balance_after == 600
# 4. Over-debit raises InsufficientFundsError
with pytest.raises(InsufficientFundsError):
await wallet_service.debit(
session,
user_id=uid,
amount=1000,
tx_type=TransactionType.marketplace_purchase,
)
# Balance remains 600
w_check = await wallet_service.get_or_create_wallet(session, uid)
assert w_check.balance == 600
finally:
await engine.dispose()
# Verify via API
resp = client.get("/api/wallet", headers=headers)
assert resp.status_code == 200
assert resp.json()["balance"] == 600
resp_tx = client.get("/api/wallet/transactions", headers=headers)
assert resp_tx.status_code == 200
tx_data = resp_tx.json()
assert tx_data["total"] == 2
assert len(tx_data["transactions"]) == 2
# Newest first: debit (-400) first, then credit (+1000)
assert tx_data["transactions"][0]["amount"] == -400
assert tx_data["transactions"][1]["amount"] == 1000
+141
View File
@@ -0,0 +1,141 @@
"""Model-level integration tests for Wallets and Transaction ledger.
docs/planning/economy.md US-1 (task 8343bc0a730f).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.wallet import Wallet, Transaction, TransactionType
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_wallet_creation_and_defaults():
client = TestClient(app)
uid = _register(client, "wallet_user_1")
engine, sf = _session_factory()
try:
async with sf() as session:
wallet = Wallet(user_id=uid)
session.add(wallet)
await session.commit()
await session.refresh(wallet)
assert wallet.id is not None
assert wallet.user_id == uid
assert wallet.balance == 0
assert wallet.created_at is not None
assert wallet.updated_at is not None
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_wallet_unique_user_id():
client = TestClient(app)
uid = _register(client, "wallet_user_2")
engine, sf = _session_factory()
try:
async with sf() as session:
w1 = Wallet(user_id=uid)
session.add(w1)
await session.commit()
async with sf() as session:
w2 = Wallet(user_id=uid)
session.add(w2)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_wallet_non_negative_balance_constraint():
client = TestClient(app)
uid = _register(client, "wallet_user_3")
engine, sf = _session_factory()
try:
async with sf() as session:
w = Wallet(user_id=uid, balance=-50)
session.add(w)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_transaction_ledger_and_idempotency():
client = TestClient(app)
uid = _register(client, "wallet_user_4")
engine, sf = _session_factory()
try:
async with sf() as session:
wallet = Wallet(user_id=uid, balance=500)
session.add(wallet)
await session.commit()
await session.refresh(wallet)
tx1 = Transaction(
wallet_id=wallet.id,
type=TransactionType.credit_purchase,
amount=500,
balance_after=500,
reference_type="purchase_session",
reference_id="sess_123",
idempotency_key="idemp_key_001",
metadata_json={"payment_method": "simulated"},
)
session.add(tx1)
await session.commit()
await session.refresh(tx1)
assert tx1.id is not None
assert tx1.type == TransactionType.credit_purchase
assert tx1.amount == 500
assert tx1.balance_after == 500
# Duplicate idempotency_key must raise IntegrityError
async with sf() as session:
tx2 = Transaction(
wallet_id=wallet.id,
type=TransactionType.credit_purchase,
amount=500,
balance_after=1000,
idempotency_key="idemp_key_001",
)
session.add(tx2)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
+490
View File
@@ -0,0 +1,490 @@
"""Integration tests for the world content API: rooms + chunks.
Covers docs/architecture/world_persistence_impl.md §8.1 (public router,
app/routers/world_content.py) and §8.3 (internal router,
app/routers/internal.py), plus the create_world -> auto "main" room
behaviour (§4.1).
Runs against the real ephemeral Postgres the hermetic conftest.py stands
up -- these are full HTTP round trips through TestClient, not mocked.
"""
import base64
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services import chunk_codec as cc
from app.services.game_servers import supervisor
GRID_32 = (32, 32, 1)
def _auth(client: TestClient, name: str) -> tuple[dict, int]:
r = client.post("/api/auth/register", json={
"username": name, "email": f"{name}@e.com", "password": "secret123",
"date_of_birth": "1990-01-01",
})
assert r.status_code == 201, r.text
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
return headers, me.json()["id"]
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility})
assert r.status_code == 201, r.text
return r.json()["id"]
def _build_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, *,
grid=GRID_32, revision: int = 1, extra_tag: str = "") -> bytes:
"""A small but structurally-real chunk payload for `world_id`/`slug`/coord,
sized to match the room's default 32x32x1 grid."""
w, h, d = grid
layer = cc.TileLayer(
name="ground", layer_id=0, width=w, height=h, depth=d,
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
tiles=[0] * (w * h * d),
)
obj = cc.WorldObject(
id=f"obj_{extra_tag or '1'}", type="prop", asset_id="props/rock",
pos=(1.0, 1.0, 0.0), properties={"tag": extra_tag},
)
payload = cc.ChunkPayload(
world_id=str(world_id), room_id=slug, coord=(cx, cy, cz),
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(float(w), float(h), float(d))),
tile_layers=[layer], objects=[obj], revision=revision, author="pytest",
created_at=1_700_000_000, updated_at=1_700_000_000,
)
return cc.encode_binary(payload)
class TestCreateWorldMainRoom:
def test_create_world_auto_creates_main_room(self):
client = TestClient(app)
headers, _ = _auth(client, "cw_main")
wid = _make_world(client, headers)
r = client.get(f"/api/worlds/{wid}/rooms", headers=headers)
assert r.status_code == 200, r.text
rooms = r.json()
assert len(rooms) == 1
assert rooms[0]["slug"] == "main"
assert rooms[0]["name"] == "Main"
assert rooms[0]["chunk_count"] == 0
assert rooms[0]["grid"]["size"] == [32, 32, 1]
class TestRoomAuth:
def test_create_room_requires_owner(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "room_owner")
wid = _make_world(client, owner_headers)
other_headers, _ = _auth(client, "room_other")
r = client.post(f"/api/worlds/{wid}/rooms", headers=other_headers,
json={"slug": "annex", "name": "Annex"})
assert r.status_code == 403
def test_list_rooms_of_private_world_is_404_for_non_owner(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "room_priv_owner")
wid = _make_world(client, owner_headers, "private")
other_headers, _ = _auth(client, "room_priv_other")
r = client.get(f"/api/worlds/{wid}/rooms", headers=other_headers)
assert r.status_code == 404
class TestChunkPutGetRoundTrip:
def test_binary_put_then_binary_get_round_trips(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_rt")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 0, 0, 0)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["revision"] == 1
assert body["payload_len"] == len(payload)
assert body["checksum"] == cc.checksum(payload[cc.HEADER_SIZE:])
assert body["live_session"] is False
g = client.get(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Accept": "application/octet-stream"},
)
assert g.status_code == 200
assert g.content == payload
assert g.headers["ETag"] == f'"{body["checksum"]}"'
assert g.headers["X-Chunk-Revision"] == "1"
def test_json_accept_returns_decoded_shape(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_json")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 1, 1, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
g = client.get(
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
headers={**headers, "Accept": "application/json"},
)
assert g.status_code == 200
data = g.json()
assert data["coord"] == {"cx": 1, "cy": 1, "cz": 0}
assert data["world_id"] == str(wid)
assert data["room_id"] == "main"
assert len(data["objects"]) == 1
def test_json_put_with_base64_payload(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_json_put")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 2, 2, 0)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/2/2/0", headers=headers,
json={"base_revision": 0, "payload_b64": base64.b64encode(payload).decode()},
)
assert r.status_code == 200, r.text
assert r.json()["revision"] == 1
def test_if_none_match_returns_304(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_304")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 3, 3, 0)
put = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
checksum = put.json()["checksum"]
g = client.get(
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
headers={**headers, "If-None-Match": f'"{checksum}"'},
)
assert g.status_code == 304
def test_unwritten_coordinate_is_404(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_404")
wid = _make_world(client, headers)
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/9/9/0", headers=headers)
assert g.status_code == 404
class TestRevisionConflict:
def test_create_conflict_when_chunk_already_exists(self):
client = TestClient(app)
headers, _ = _auth(client, "conflict_create")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 4, 4, 0)
first = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert first.status_code == 200
second = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert second.status_code == 409
body = second.json()
assert body["detail"] == "revision conflict"
assert body["current_revision"] == 1
def test_stale_base_revision_conflict_reports_current_state(self):
client = TestClient(app)
headers, _ = _auth(client, "conflict_stale")
wid = _make_world(client, headers)
payload_v1 = _build_chunk(wid, "main", 5, 5, 0, revision=1)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload_v1,
)
payload_v2 = _build_chunk(wid, "main", 5, 5, 0, revision=2, extra_tag="v2")
r2 = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
content=payload_v2,
)
assert r2.status_code == 200
assert r2.json()["revision"] == 2
# Now retry with the now-stale base_revision=1 again.
payload_v3 = _build_chunk(wid, "main", 5, 5, 0, revision=3, extra_tag="v3")
stale = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
content=payload_v3,
)
assert stale.status_code == 409
body = stale.json()
assert body["current_revision"] == 2
assert body["current_checksum"] == r2.json()["checksum"]
class TestBatchWrite:
def test_batch_put_all_succeed(self):
client = TestClient(app)
headers, _ = _auth(client, "batch_ok")
wid = _make_world(client, headers)
chunks = [
{"cx": cx, "cy": 0, "cz": 0, "base_revision": 0,
"payload_b64": base64.b64encode(_build_chunk(wid, "main", cx, 0, 0)).decode()}
for cx in (10, 11)
]
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
assert r.status_code == 200, r.text
body = r.json()
assert len(body["chunks"]) == 2
assert all(c["revision"] == 1 for c in body["chunks"])
idx = client.get(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers)
keys = {c["key"] for c in idx.json()["chunks"]}
assert f"{wid}:main:10:0:0" in keys
assert f"{wid}:main:11:0:0" in keys
def test_batch_put_is_all_or_nothing_on_conflict(self):
client = TestClient(app)
headers, _ = _auth(client, "batch_conflict")
wid = _make_world(client, headers)
# Pre-create one of the two coordinates so the batch's base_revision=0
# for it will conflict.
pre = _build_chunk(wid, "main", 20, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/20/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=pre,
)
chunks = [
{"cx": 21, "cy": 0, "cz": 0, "base_revision": 0,
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 21, 0, 0)).decode()},
{"cx": 20, "cy": 0, "cz": 0, "base_revision": 0, # conflicts: already exists at revision 1
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 20, 0, 0, extra_tag="v2")).decode()},
]
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
assert r.status_code == 409
# Nothing from this batch should have been persisted -- (21,0,0)
# must NOT exist even though it was valid and came first.
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/21/0/0", headers=headers)
assert g.status_code == 404
class TestDelete:
def test_delete_then_get_is_404(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_delete")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 30, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/30/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
d = client.delete(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
assert d.status_code == 204
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
assert g.status_code == 404
class TestRevisionsAndRollback:
def test_revisions_then_rollback(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_rollback")
wid = _make_world(client, headers)
v1 = _build_chunk(wid, "main", 40, 0, 0, revision=1)
r1 = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=v1,
)
v2 = _build_chunk(wid, "main", 40, 0, 0, revision=2, extra_tag="v2")
r2 = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
content=v2,
)
assert r2.status_code == 200
revs = client.get(f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/revisions", headers=headers)
assert revs.status_code == 200
rev_numbers = [row["revision"] for row in revs.json()]
assert 1 in rev_numbers and 2 in rev_numbers
rb = client.post(
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/rollback",
headers=headers, json={"revision": 1},
)
assert rb.status_code == 200, rb.text
assert rb.json()["revision"] == 3 # a new revision, restoring v1's content
assert rb.json()["checksum"] == r1.json()["checksum"]
def test_revisions_requires_owner(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "revisions_owner")
wid = _make_world(client, owner_headers)
payload = _build_chunk(wid, "main", 41, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/41/0/0",
headers={**owner_headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
other_headers, _ = _auth(client, "revisions_other")
r = client.get(f"/api/worlds/{wid}/rooms/main/chunks/41/0/0/revisions", headers=other_headers)
assert r.status_code == 403
class TestValidationErrors:
def test_malformed_payload_is_422(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_malformed")
wid = _make_world(client, headers)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=b"not a real chunk",
)
assert r.status_code == 422
assert r.json()["reason"] == "malformed"
def test_wrong_grid_size_is_422_layer_dims(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_layerdims")
wid = _make_world(client, headers)
# Room's default grid is 32x32x1; build a chunk sized for 4x4x1.
payload = _build_chunk(wid, "main", 0, 0, 0, grid=(4, 4, 1))
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert r.status_code == 422
assert r.json()["reason"] == "layer_dims"
def test_oversized_payload_is_413(self, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "WORLD_CHUNK_MAX_PAYLOAD_BYTES", 10)
client = TestClient(app)
headers, _ = _auth(client, "chunk_413")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 0, 0, 0)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert r.status_code == 413
class TestInternalApi:
"""docs/architecture/world_persistence_impl.md §7/§8.3. No real Godot
process is spawned in this test env (GAME_SERVERS_ENABLED=False); we
register a fake in-memory server entry with a known token to exercise
require_game_server without needing a live process."""
@pytest.fixture
def fake_server_token(self):
# NB: a class body can't read a same-named local from the
# enclosing function via `token = token` (class bodies use LOAD_NAME,
# which skips enclosing function scopes) -- use a literal instead.
world_id_str = "internal-test-world"
class _FakeSrv:
def alive(self_inner):
return True
token = "test-internal-token-12345"
supervisor._servers[world_id_str] = _FakeSrv()
yield _FakeSrv.token
supervisor._servers.pop(world_id_str, None)
def test_missing_token_is_401(self):
client = TestClient(app)
r = client.get("/api/internal/worlds/1/manifest")
assert r.status_code == 401
def test_wrong_token_is_403(self):
client = TestClient(app)
r = client.get(
"/api/internal/worlds/1/manifest",
headers={"Authorization": "Bearer not-the-real-token"},
)
assert r.status_code == 403
def test_valid_token_but_wrong_world_id_is_403(self, fake_server_token):
# The fake token is registered under world_id "internal-test-world";
# a request for a different world_id must not authenticate.
client = TestClient(app)
r = client.get(
"/api/internal/worlds/999999/manifest",
headers={"Authorization": f"Bearer {fake_server_token}"},
)
assert r.status_code == 403
def test_valid_token_reaches_manifest_for_real_world(self):
client = TestClient(app)
headers, _ = _auth(client, "internal_manifest")
wid = _make_world(client, headers)
class _FakeSrv:
def alive(self_inner):
return True
token = "another-real-token"
supervisor._servers[str(wid)] = _FakeSrv()
try:
r = client.get(
f"/api/internal/worlds/{wid}/manifest",
headers={"Authorization": "Bearer another-real-token"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["world_id"] == wid
assert len(body["rooms"]) == 1
assert body["rooms"][0]["slug"] == "main"
finally:
supervisor._servers.pop(str(wid), None)
def test_internal_chunk_fetch_returns_binary(self):
client = TestClient(app)
headers, _ = _auth(client, "internal_chunk")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 0, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
class _FakeSrv:
def alive(self_inner):
return True
token = "internal-chunk-token"
supervisor._servers[str(wid)] = _FakeSrv()
try:
r = client.get(
f"/api/internal/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={"Authorization": "Bearer internal-chunk-token"},
)
assert r.status_code == 200
assert r.content == payload
finally:
supervisor._servers.pop(str(wid), None)
+222
View File
@@ -0,0 +1,222 @@
"""Integration tests for real-time WebSocket gateway:
- Authentication (query param + first auth frame)
- Rejection of unauthenticated clients
- Presence on connect / disconnect
- Subscriptions to world chat channels and DM conversations
- Real-time event delivery from REST endpoints
- Reconnect and ping/pong
"""
import asyncio
import time
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.main import app
from app.services.presence import presence_store
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "token": token, "id": me.json()["id"], "username": username}
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": "WSWorld", "visibility": visibility})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_friends(client: TestClient, user_a: dict, user_b: dict) -> None:
req = client.post("/api/friends/requests", json={"user_id": user_b["id"]}, headers=user_a["headers"])
assert req.status_code == 201, req.text
req_id = req.json()["id"]
acc = client.post(f"/api/friends/requests/{req_id}/accept", headers=user_b["headers"])
assert acc.status_code == 200, acc.text
class TestWebSocketAuth:
def test_unauthenticated_connection_rejected(self):
client = TestClient(app)
# Invalid query param token rejected
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/api/ws?token=invalid_token"):
pass
assert exc.value.code == 1008
# First frame with invalid token rejected
with pytest.raises(WebSocketDisconnect) as exc2:
with client.websocket_connect("/api/ws") as ws:
ws.send_json({"type": "auth", "token": "invalid_token"})
ws.receive_json()
ws.receive_json()
assert exc2.value.code == 1008
def test_auth_via_query_param(self):
client = TestClient(app)
alice = _register(client, "ws_auth_alice")
with client.websocket_connect(f"/api/ws?token={alice['token']}") as ws:
msg = ws.receive_json()
assert msg["type"] == "auth_ok"
assert msg["user_id"] == alice["id"]
assert msg["username"] == "ws_auth_alice"
def test_auth_via_first_message(self):
client = TestClient(app)
bob = _register(client, "ws_auth_bob")
with client.websocket_connect("/api/ws") as ws:
ws.send_json({"type": "auth", "token": bob["token"]})
msg = ws.receive_json()
assert msg["type"] == "auth_ok"
assert msg["user_id"] == bob["id"]
assert msg["username"] == "ws_auth_bob"
class TestWebSocketPresence:
def test_presence_on_connect_and_disconnect(self):
import threading
client = TestClient(app)
alice = _register(client, "ws_pres_alice")
bob = _register(client, "ws_pres_bob")
_make_friends(client, alice, bob)
# Initially both offline
assert not asyncio.run(presence_store.is_online(alice["id"]))
# Bob connects and listens for presence events
bob_events = []
def run_alice():
time.sleep(0.2)
c_alice = TestClient(app)
with c_alice.websocket_connect(f"/api/ws?token={alice['token']}") as alice_ws:
assert alice_ws.receive_json()["type"] == "auth_ok"
time.sleep(0.3)
alice_ws.close()
time.sleep(0.3)
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
assert bob_ws.receive_json()["type"] == "auth_ok"
alice_thread = threading.Thread(target=run_alice)
alice_thread.start()
# Bob receives Alice's online event
online_event = bob_ws.receive_json()
assert online_event["type"] == "presence"
assert online_event["user_id"] == alice["id"]
assert online_event["is_online"] is True
alice_thread.join()
# Bob receives Alice's offline event
offline_event = bob_ws.receive_json()
assert offline_event["type"] == "presence"
assert offline_event["user_id"] == alice["id"]
assert offline_event["is_online"] is False
assert not asyncio.run(presence_store.is_online(alice["id"]))
class TestWebSocketChatDelivery:
def test_world_chat_subscription_and_delivery(self):
client = TestClient(app)
alice = _register(client, "ws_chat_alice")
bob = _register(client, "ws_chat_bob")
world_id = _make_world(client, alice["headers"])
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
assert bob_ws.receive_json()["type"] == "auth_ok"
# Bob subscribes to world
bob_ws.send_json({"type": "subscribe_world", "world_id": world_id})
sub_ack = bob_ws.receive_json()
assert sub_ack["type"] == "subscribed"
assert sub_ack["channel"] == "world"
assert sub_ack["id"] == world_id
# Alice posts message to world via REST
rest_resp = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": "Hello real-time world!"},
)
assert rest_resp.status_code == 201, rest_resp.text
msg_id = rest_resp.json()["id"]
# Bob receives message in real time over WebSocket
ws_msg = bob_ws.receive_json()
assert ws_msg["type"] == "world_chat"
assert ws_msg["world_id"] == world_id
assert ws_msg["message"]["id"] == msg_id
assert ws_msg["message"]["body"] == "Hello real-time world!"
assert ws_msg["message"]["author"]["username"] == "ws_chat_alice"
def test_dm_chat_subscription_and_delivery(self):
client = TestClient(app)
alice = _register(client, "ws_dm_alice")
bob = _register(client, "ws_dm_bob")
# Initial DM to create conversation
init_dm = client.post(
f"/api/chat/dm/{bob['id']}/messages",
headers=alice["headers"],
json={"body": "First DM to initiate"},
)
assert init_dm.status_code == 201, init_dm.text
conv_id = init_dm.json()["conversation_id"]
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
assert bob_ws.receive_json()["type"] == "auth_ok"
# Bob subscribes to this conversation
bob_ws.send_json({"type": "subscribe_dm", "conversation_id": conv_id})
sub_ack = bob_ws.receive_json()
assert sub_ack["type"] == "subscribed"
assert sub_ack["channel"] == "dm"
assert sub_ack["id"] == conv_id
# Alice sends another DM via REST
rest_dm = client.post(
f"/api/chat/dm/{bob['id']}/messages",
headers=alice["headers"],
json={"body": "Real-time DM ping!"},
)
assert rest_dm.status_code == 201, rest_dm.text
# Bob receives real-time DM over WebSocket
ws_dm = bob_ws.receive_json()
assert ws_dm["type"] == "dm_chat"
assert ws_dm["conversation_id"] == conv_id
assert ws_dm["message"]["body"] == "Real-time DM ping!"
class TestWebSocketPingAndHeartbeat:
def test_ping_pong_and_heartbeat(self):
client = TestClient(app)
alice = _register(client, "ws_ping_alice")
with client.websocket_connect(f"/api/ws?token={alice['token']}") as ws:
assert ws.receive_json()["type"] == "auth_ok"
ws.send_json({"type": "ping"})
assert ws.receive_json()["type"] == "pong"
ws.send_json({"type": "heartbeat"})
assert ws.receive_json()["type"] == "heartbeat_ack"
+5
View File
@@ -4,6 +4,11 @@
> **Owner:** Platform team
> **Related:** World chunk data format & persistence, server authority + client
> prediction, content moderation & age verification, avatar component system
> **Note:** the recommendation here stands, but edits do **not** ride the
> `request_chunk_edit` ENet RPC (§4.2, §7.4). Per
> [`world_persistence_impl.md`](world_persistence_impl.md) the editor saves
> chunks over HTTP to the FastAPI backend, which is the sole writer; the
> validation/moderation boundary moves with it.
> **Godot version:** 4.6.2 stable
## 1. Overview
+5 -1
View File
@@ -1,6 +1,10 @@
# World Chunk Data Format & Persistence Architecture
> **Status:** Proposed
> **Status:** Proposed. The chunk format (§2, §3) is normative and implemented
> on the client. **§4 (SQLite schema) and §5 (write ownership / write-behind)
> are superseded by [`world_persistence_impl.md`](world_persistence_impl.md)**,
> which stores chunks in the existing Postgres backend and makes the FastAPI
> process — not the per-world Godot server — the sole writer.
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
+655
View File
@@ -0,0 +1,655 @@
# World Persistence: Server-Side Implementation Architecture
> **Status:** Accepted (design). Implementation is split across two follow-on
> tasks (backend endpoints; editor wiring).
> **Owner:** Platform team
> **Supersedes:** the *storage engine* and *write ownership* parts of
> [`world.md`](world.md) §4 and §5. Everything else in `world.md` — the chunk
> logical model, the JSON and binary encodings, checksums, revisions, content
> flags — stands unchanged and is normative.
> **Related:** [`world.md`](world.md) (chunk format), [`editor.md`](editor.md)
> (authoring surface), [`networking.md`](networking.md) (ENet sim transport),
> [`content_moderation.md`](content_moderation.md) (flags & gating).
## 1. Why this document exists
`world.md` was written during the client-side chunk-streaming work (M2). It
assumes **one** server that owns **one** embedded SQLite file and is both the
authoritative simulation and the authoritative store. That is not the system we
built. What actually exists today is:
| Process | Lifetime | Owns | Code |
|---|---|---|---|
| FastAPI backend | always on, single worker | Postgres (users, worlds, assets, avatar loadouts), game-server orchestration | `backend/app/` |
| Godot dedicated server, one per world | **spawned on join, reaped ~5 min after the last join when empty** | nothing durable; a 30 Hz in-memory player sim | `scripts/server.gd`, spawned by `backend/app/services/game_servers.py` |
| Godot client | player session | local chunk streaming, the in-game editor (planned) | `scripts/chunk_stream_manager.gd`, `scripts/chunk_data_decoder.gd` |
Nothing server-side has ever stored or served a real chunk. `server.gd`
`preload()`s `res://scenes/worlds/test_level.tscn` for **every** world and uses
`world_id` only for logging and the status file. `ChunkStreamManager` has a
`set_server_rpc()` seam that no code calls. The backend's `World` model is
metadata only (`id`, `name`, `description`, `owner_id`, `visibility`).
This document decides where chunk content lives, who may write it, how the
dedicated server gets it, and the exact API surface — so the two implementation
tasks that follow have no architectural decisions left to make.
---
## 2. Decisions at a glance
| # | Question | Decision |
|---|---|---|
| D1 | Where does chunk data live at rest? | **Postgres**, in the existing backend database. Not a per-world SQLite file. |
| D2 | Who writes it? | **The FastAPI backend, exclusively.** The editor writes through it over HTTP with optimistic concurrency. The dedicated Godot server never writes world content. |
| D3 | How does the dedicated server get content? | **HTTP pull from the backend at startup**, over a loopback-only `/api/internal` route authenticated by a per-spawn token the supervisor mints. Lazy fetch as players roam. |
| D4 | Edit while a live session exists? | **Out of scope this milestone, with a defined seam.** The editor is a non-joined client mode; writes always succeed and the response flags `live_session` so the UI can say "players see this when the session restarts". |
| D5 | Edit granularity on the wire | **Full-chunk PUT with `base_revision`**, not RFC-6902 deltas. |
| D6 | Client chunk transport | **HTTP from the backend**, not ENet from the dedicated server. The `set_server_rpc()` seam is satisfied by an HTTP-backed adapter. |
---
## 3. D1 — Chunk data lives in Postgres
### 3.1 Decision
World content (rooms, chunk payloads, chunk revisions) is stored in the
**existing Postgres database**, alongside `worlds`, in new tables created by an
Alembic migration. `world.md` §4's `world.db` SQLite file is **not** built.
The chunk **payload stays exactly what `world.md` §3.6 specifies**: the 48-byte
header + sectioned binary body, checksummed with SHA-256. It moves from a
SQLite `BLOB` to a Postgres `BYTEA`. Nothing about the format changes, so
`scripts/chunk_data_decoder.gd` remains correct and the format spec stays the
single contract.
### 3.2 Reasoning
1. **SQLite's premise does not hold here.** `world.md` §4 justifies SQLite as
"ideal for a server that owns the world store". No process in this system
owns a world store: the only process that could plausibly hold the file (the
dedicated Godot server) is *ephemeral* — reaped when idle, `SIGTERM`'d on
backend shutdown, `SIGKILL`'d 10 s later if it doesn't exit, and orphaned
entirely if the backend dies. Meanwhile the editor must write when no such
process exists. So the file would have to be shared by two processes with
independent lifetimes, which is precisely the workload SQLite's
single-writer/file-lock model handles worst.
2. **Postgres is already a hard dependency; SQLite would be a second one.** The
backend cannot start without Postgres. Adding a per-world SQLite file adds a
second storage engine, a second migration system (`world.md` §4.8 specifies a
hand-rolled `meta.schema_version` runner *next to* the Alembic we already
have), a second backup story, and a second consistency story.
3. **Referential integrity with metadata we already have.** `worlds` rows carry
`owner_id` and `visibility`, and every authorization rule for content derives
from them. In one database this is a join and a foreign key with
`ON DELETE CASCADE`; across two engines, deleting a world silently orphans a
file on disk and every ownership check becomes a cross-store read.
4. **Concurrency is the normal case, not the edge case.** Two creators editing
one world, or an editor writing while the backend serves chunks to a running
dedicated server, are ordinary. Postgres gives row-level MVCC and real
transactions for the read-modify-write in §5.4; SQLite gives a database-level
write lock and `SQLITE_BUSY`.
5. **The repo already has the precedent and the plumbing.** `avatar_loadouts`
(migration `0004`) stores a client-shaped document in Postgres JSONB rather
than inventing a side store, and `backend/app/database.py` already provides
the async session factory this code will use.
### 3.3 What we give up, and why it's acceptable
- **Zero-config embedding.** Irrelevant — the backend already requires a
configured Postgres.
- **`world.md` §5.5 write-behind + the `dirty` column.** Deliberately dropped
(§4.4). It existed to amortize disk I/O for a process that owned the file. Our
writes are human-rate editor saves, not sim-rate; each one is a single
transaction and is durable when the HTTP response returns. Dropping it also
deletes the entire "unclean shutdown recovery" scan in §5.5 — there is no
window in which committed content can be lost.
- **Sharding by `world_id` (`world.md` §8).** Postgres partitioning by
`world_id` remains available on the same table shape if a single table ever
becomes a bottleneck. At ~2–8 KB per chunk, 10 000 chunks is under 100 MB.
---
## 4. Schema (Alembic migration `0005_add_world_content_tables`)
New model module: `backend/app/models/world_content.py`, registered in
`backend/app/models/__init__.py`. `backend/app/models/world.py` is left alone
except for one added relationship, so nothing PC1/PC2 depends on moves.
Conventions follow the existing models: `Column(...)` declarative style,
`Integer` surrogate PKs, `DateTime(timezone=True)` with `server_default=func.now()`,
JSONB for document-shaped fields (as `avatar_loadout.py` does).
### 4.1 `world_rooms`
```python
class WorldRoom(Base):
__tablename__ = "world_rooms"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True)
slug = Column(String(64), nullable=False) # url-safe; "main" by default
name = Column(String(255), nullable=False)
# Chunk grid config. world.md §2.2 puts this on the world; it lives on the
# room so a world can later hold rooms with different grids, and so this
# migration is purely additive (the existing `worlds` table is untouched).
grid = Column(JSONB, nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}')
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(), nullable=False)
__table_args__ = (UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),)
```
`World` gains
`rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")`.
Every world gets a `main` room: created in `create_world`, and lazily created on
first room read/write for worlds that predate the migration. `grid` defaults
match `Chunk.CHUNK_SIZE_*` in `scripts/chunk.gd`, which is the client's source
of truth for the same constants.
### 4.2 `world_chunks`
```python
class WorldChunk(Base):
__tablename__ = "world_chunks"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True) # denormalized: world-scoped queries
room_id = Column(Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=False, index=True)
chunk_key = Column(String(160), nullable=False, unique=True) # see §4.5
cx = Column(Integer, nullable=False)
cy = Column(Integer, nullable=False)
cz = Column(Integer, nullable=False, server_default="0")
format_version = Column(Integer, nullable=False, server_default="1")
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes, BYTEA
payload_len = Column(Integer, nullable=False)
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
revision = Column(Integer, nullable=False, server_default="1")
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
is_mature = Column(Boolean, nullable=False, server_default="false")
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
)
```
Deltas from `world.md` §4.4:
- **Surrogate `id` PK + unique coordinate constraint**, with `chunk_key` kept as
a unique denormalized column. The key string is the client-facing contract
(`Chunk.build_key`) and is embedded in the payload, so storing it gives a
single-index lookup by key; the coordinate constraint is what actually
enforces one chunk per cell.
- **`dirty` removed** — see §3.3.
- **Content flags folded into the chunk row as JSONB + an indexed `is_mature`
boolean**, instead of `world.md` §4.6's separate 11-column `content_flags`
table. The flags are a document the client already produces in exactly this
shape (`ChunkDataDecoder.ContentFlags.to_dict()`), the only query we anticipate
is "is this mature", and folding them in removes a second write per save and
the `object_id = ''` sentinel hack that §4.6 needed to work around SQLite's
NULL-in-primary-key behaviour (Postgres has the same NULL semantics, so the
hack would have carried over).
- **`world.md` §4.5 `chunk_objects` is deferred, not dropped.** It is a derived
index that exists to answer queries ("which chunks contain a door?") that no
code asks yet, and maintaining it means a delete-and-reinsert of every object
row on every save. It is fully rebuildable from `payload`, so it can be added
in its own migration the day a query needs it, with a backfill. Do **not**
build it in `0005`.
### 4.3 `world_chunk_revisions`
```python
class WorldChunkRevision(Base):
__tablename__ = "world_chunk_revisions"
id = Column(Integer, primary_key=True, index=True)
chunk_key = Column(String(160), nullable=False, index=True) # deliberately NOT a FK
world_id = Column(Integer, nullable=False, index=True) # also not a FK, same reason
revision = Column(Integer, nullable=False)
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
checksum = Column(String(64), nullable=False)
payload = Column(LargeBinary, nullable=True) # full payload at this revision
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
```
Kept from `world.md` §4.7 including its two design points: the full payload is
stored (rollback needs it) and `chunk_key`/`world_id` are **not** foreign keys,
so deleting a chunk or a world preserves the audit trail.
**Retention (new, required):** this table grows without bound — a creator
dragging a brush can produce hundreds of revisions per minute. The
implementation must ship a retention policy from day one: keep the newest
`WORLD_CHUNK_REVISION_KEEP` (default 20) payloads per `chunk_key` and null out
`payload` on older rows (keeping the metadata row for the audit trail), pruned
in the same transaction as the write. A `created_at` index supports a later
time-based prune.
### 4.4 Config additions (`backend/app/config.py`)
```
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB; a dense 32×32 uint16 layer is 2 KiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
```
### 4.5 Chunk key format
`world.md` §2.3 specifies `"{world_id}:{room_id}:{cx}:{cy}:{cz}"` and illustrates
it with `w_0001:r_0001`. Those ids were illustrative. The real key uses the
backend's identifiers:
```
"{world.id}:{room.slug}:{cx}:{cy}:{cz}" e.g. "12:main:3:-1:0"
```
The room **slug** rather than its numeric id, so a key is human-readable in logs
and stable if rooms are ever re-imported. `Chunk.build_key()` in
`scripts/chunk.gd` already produces this shape unmodified.
---
## 5. D2 — The FastAPI backend is the only writer
### 5.1 Decision
The in-game editor (`editor.md` §7.2) submits edits over **HTTP to the FastAPI
backend**, which validates, versions, and persists them. The dedicated Godot
server process **never writes world content** and does not sit in the edit path.
This preserves `world.md`'s and `editor.md`'s actual invariant — *clients never
touch storage; they submit edits to a server-authoritative tier that validates
them* — while identifying that tier as the always-on backend rather than the
ephemeral sim process. `editor.md` §4.2's claim that edits ride the same
`request_chunk_edit` RPC as gameplay is the one part of that doc this design
changes; the moderation and validation properties it derives from that claim are
unaffected, because the validation boundary moves with the writer.
### 5.2 Reasoning
1. **Nothing that can vanish in 300 seconds may own the write path.** The reaper
in `game_servers.py` `stop()`s an idle server with `SIGTERM` and escalates to
`SIGKILL` after 10 s; `supervisor.shutdown()` does the same to every server on
backend shutdown; and if the backend dies uncleanly the servers are orphaned
with no one to flush them. Because the sim process holds no unsaved world
content in this design, **no reap, crash, or kill can ever lose world data** —
that is the single strongest practical reason for this split.
2. **Editing must work with no dedicated server running.** A creator opening
their world to build should not have to spin up a 30 Hz sim process, burn a
port from the 100-port range, and hold it for 5 minutes past their last save.
3. **`server.gd` has no database access, no credentials, and no HTTP client.**
Routing writes through it means either shipping Postgres credentials into a
process spawned per join, or building an ENet→HTTP proxy inside the sim loop.
Both are strictly worse than the editor calling the backend directly.
4. **Authorization already lives in the backend.** `CurrentUser`, the
`owner_id` check, and `WorldVisibility` are all there. The dedicated server
has no user identities at all — it knows peer ids, not accounts.
### 5.3 Authorization
- **Read** a chunk: the same rule as `GET /api/worlds/{id}` — the world is
`PUBLIC`, or the caller owns it. Otherwise 404 (not 403), matching the
existing routers' deliberate non-disclosure.
- **Write** a chunk: caller must be `world.owner_id`, matching `update_world`;
403 otherwise. Collaborator/role-based editing is future work and slots in at
this one check.
- **Mature gating hook:** `is_mature` chunks are where `content_moderation.md`'s
age gate attaches on the read path. Specify the column and the index now; the
gate itself lands with that doc's work.
### 5.4 Concurrency
Optimistic concurrency exactly as `world.md` §5.4, expressed over HTTP:
1. Reader gets `revision` (header `X-Chunk-Revision`, or the JSON field).
2. Writer sends `base_revision`.
3. The service does `SELECT ... FOR UPDATE` on the chunk row, compares
`base_revision` to `revision`, and either commits at `revision + 1` or returns
**409** with `current_revision` and `current_checksum` so the editor can
re-fetch and re-apply.
4. `base_revision: 0` means "create; fail with 409 if it already exists".
### 5.5 Validation (the backend must not trust the client)
The write path decodes the submitted payload server-side before storing it. This
requires a **Python port of the `world.md` §3.6 codec** —
`backend/app/services/chunk_codec.py`, mirroring `scripts/chunk_data_decoder.gd`.
It checks: magic `AGPC`, `format_version <= 1`, declared vs. actual
`payload_len`, **recomputed** SHA-256 (the header's checksum is advisory), tile
layer dims equal the room's `grid.size`, tile indices in palette range, object
count ≤ `WORLD_CHUNK_MAX_OBJECTS`, object positions inside the chunk's bounds,
`chunk_key` in the payload matching the URL, and string-field length limits
(`world.md` §7). `content_flags` and `is_mature` are **recomputed** from the
payload's flag sections, never taken from the request body.
**Drift control (required):** the two codecs must be pinned by golden files —
fixture chunks under `tests/fixtures/chunks/` encoded by one implementation and
decoded byte-identically by the other, asserted in both the pytest suite and the
Godot headless suite. Without this the format has two implementations and no
contract test, which is exactly the failure `world.md` §5.2 was trying to avoid
by having one shared implementation.
---
## 6. D4 — Editing versus a live session (scope boundary)
**In scope for this milestone:**
- The editor is entered as **"Edit world" from the world browser**: a non-joined
client mode. It does **not** call `POST /api/worlds/{id}/join`, does not open
an ENet connection, and does not spawn a dedicated server. It loads chunks over
HTTP, edits them locally, and saves them over HTTP.
- Writes always succeed regardless of whether a session is live. The write
response carries `live_session: bool` (from `supervisor.debug_state()`), so the
editor can surface *"this world has players in it right now — they'll see your
changes when the session restarts."*
- A dedicated server started **after** a write sees the new content, because it
loads from the store at startup (§7).
**Explicitly out of scope, deferred:**
- Build-in-place while joined to a live session.
- `chunk_invalidated` push to connected clients (`world.md` §5.3) and hot-reload
of the dedicated server's collision world.
- Live collaborative editing / cursors (`editor.md` §9).
**The seam that makes the deferred work cheap:** the manifest endpoint (§7.2)
returns a monotonic `content_revision` for the world, bumped on every chunk
write. Hot reload is then: the dedicated server polls the manifest on a timer
(or the backend pushes to it over the same internal channel), re-fetches the
chunks whose revision changed, and rebuilds those collision bodies. Nothing in
this design has to change to add it.
**Why the editor must not join:** if it joined, the sim process would enforce
collision against content it loaded at spawn while the creator edits a diverging
local copy — players would walk through new walls and bounce off deleted ones,
with no error anywhere. Making build mode non-joined turns a silent correctness
bug into an explicit product boundary.
---
## 7. D3 — What the dedicated server does at startup
### 7.1 Decision
`server.gd` pulls its world's content from the backend over **HTTP** at startup,
authenticated by a **per-spawn token** the supervisor mints, and **does not bind
the ENet port until content is loaded**.
Rejected alternative — *supervisor writes a whole-world snapshot file at spawn
and passes `--world-file`*: attractive (no new auth surface, no HTTP client in
the sim process), but it makes spawn latency proportional to world size, gives
the server no way to fetch a chunk lazily as players roam beyond the preloaded
set, and still needs a separate channel for the hot-reload work above. Rejected —
*give the Godot process a Postgres connection*: there is no PG driver in
GDScript, and it would put database credentials in a process spawned per join.
### 7.2 Startup sequence
1. `_parse_args()` gains `--api-url` and `--server-token` (§7.3).
2. `GET /api/internal/worlds/{world_id}/manifest` → rooms, per-room `grid`,
`bounds`, `spawn`, `chunk_count`, and the world's `content_revision`.
3. `GET /api/internal/worlds/{world_id}/rooms/{slug}/chunks?` bounds query for
the spawn neighbourhood (radius from the spawn point, default 3 chunks), then
fetch each payload; decode with the existing `ChunkDataDecoder` and build
static collision from the tile layers and objects.
4. **Then** `network_peer.create_server(port)`, then write the status file.
5. **Fallback:** if the backend is unreachable, the world has no rooms, or the
room has zero chunks, load `test_level.tscn` as today and record
`"content": "fallback"` in the status file. A content outage must never make
a world un-joinable, and this keeps every PC3 flow working before any world
has authored content.
During the session the server may lazily fetch chunks as players approach
unloaded coordinates, through the same internal route. It **never writes**.
Dynamic entity runtime state (positions, health) stays in memory and out of
chunks, exactly as `world.md` §3.3 already specifies. Shutdown flushes nothing.
### 7.3 Internal authentication
`GameServerSupervisor._spawn()` generates `token = secrets.token_urlsafe(32)`,
stores it on the `_WorldServer` dataclass, and passes it on the command line
alongside the existing `--port/--world/--status-file`. A FastAPI dependency
`require_game_server` resolves `Authorization: Bearer <token>` against
`supervisor.validate_token(world_id, token)` — a dict lookup, since the
supervisor is an in-process singleton. Properties this gives us:
- A token grants access to **one** world and **dies with its process** (the
reaper's `del self._servers[wid]` invalidates it).
- No long-lived shared secret in config or on disk.
- It reuses the **already documented** single-worker constraint in
`game_servers.py`; it does not add a new one.
Rejected alternative — *let the dedicated server use the public routes with a
service user account*: the public routes enforce per-user visibility and
ownership, which a service identity would have to be exempted from, and it would
pollute `updated_by_id`/audit rows with a fake user. A separate `/api/internal`
prefix keeps the two auth models apart and can be blocked at the reverse proxy.
### 7.4 Lifecycle changes this requires in `game_servers.py`
These are real, load-bearing findings from the current implementation:
- **`_await_port()` is the wrong readiness signal now.** It returns as soon as
the UDP port is bound, and the port is bound before content exists today. With
content loading it must await a `"ready": true` field that `server.gd` writes
to the status file *after* content load + `create_server`. Otherwise the
backend hands a client an address for a server that is still fetching chunks.
- **`_await_port()` fails open.** After its 8 s deadline it returns normally,
with no error — `ensure_server` then reports success for a server that may
never have started. Content loading makes a slow start more likely, so the
readiness wait needs a real timeout error (503 to the joining client) and a
longer, configurable deadline.
- **The status file is the only server→backend channel**, and `_spawn` currently
passes no credentials at all — hence §7.3.
- **`player_count()` returns 0 when the status file is missing or unparseable**,
so a live server whose status write fails can be reaped after the idle timeout.
Harmless for data (the sim owns nothing durable — the point of D2) but worth a
comment where the fallback lives.
- **No crash restart** (documented in the module docstring): if a server dies,
the next join respawns it and it reloads content from the store. With D2 this
is now genuinely safe rather than merely tolerated.
---
## 8. API surface
All paths are under the existing `worlds` router prefix (`/api/worlds`) unless
noted. New router module: `backend/app/routers/world_content.py`, mounted at
`/api/worlds`; internal routes in `backend/app/routers/internal.py` at
`/api/internal`.
### 8.1 Public (editor & player client; JWT `CurrentUser`)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/worlds/{world_id}/rooms` | List rooms + grid config |
| `POST` | `/api/worlds/{world_id}/rooms` | Create a room (owner) |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Chunk **index** over a bounds query |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Fetch one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Write one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Write up to `WORLD_CHUNK_MAX_BATCH` chunks atomically |
| `DELETE` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Delete one chunk |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions` | Revision history (owner) |
| `POST` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback` | Restore a revision (owner) |
**Room list** — `GET .../rooms` → `200`
```json
[{ "id": 7, "slug": "main", "name": "Main",
"grid": { "size": [32, 32, 1], "origin": [0, 0, 0] },
"bounds": null,
"spawn": { "pos": [0, 1.5, 0], "yaw": 0.0 },
"chunk_count": 41 }]
```
**Chunk index** — `GET .../chunks?cx_min=&cx_max=&cy_min=&cy_max=&cz=0` → `200`.
Payloads are *not* included; this is `world.md` §5.1's `list_chunks(bounds)` and
lets a streamer decide what to fetch and skip anything already cached by
checksum.
```json
{ "room": "main", "content_revision": 118,
"chunks": [{ "key": "12:main:0:0:0", "cx": 0, "cy": 0, "cz": 0,
"revision": 4, "checksum": "9f86…", "payload_len": 2344,
"is_mature": false, "updated_at": "2026-09-14T10:02:11Z" }] }
```
Bounds are capped (default 32×32 chunks per query) and return `422` beyond that.
**Chunk fetch** — `GET .../chunks/{cx}/{cy}/{cz}`, content-negotiated:
- `Accept: application/octet-stream` (default for the game client) → `200` with
the raw `world.md` §3.6 bytes, `ETag: "<checksum>"`,
`X-Chunk-Revision: <n>`, `X-Chunk-Key: <key>`.
- `Accept: application/json` (tooling, tests, the editor's debug view) → the
§3.5 JSON encoding, decoded server-side from the payload. JSON is derived, never
stored, so there is no dual-write drift (`world.md` §4.4).
- `If-None-Match: "<checksum>"` → `304`. This is what makes the client's
`user://chunk_cache/` cache cheap and is why the checksum is the ETag.
- `404` if the chunk does not exist (an unwritten coordinate is not an error to
the streamer — it renders empty).
**Chunk write** — `PUT .../chunks/{cx}/{cy}/{cz}`
```
Content-Type: application/octet-stream
X-Base-Revision: 4 # 0 = create
<binary payload>
```
or, for clients that prefer JSON:
`{"base_revision": 4, "payload_b64": "…"}`.
Responses:
| Code | Body / meaning |
|---|---|
| `200` | `{"key", "revision", "checksum", "payload_len", "updated_at", "is_mature", "live_session"}` |
| `409` | `{"detail": "revision conflict", "current_revision": 6, "current_checksum": "…"}` |
| `403` | not the world owner |
| `404` | world or room not found / not visible |
| `413` | payload over `WORLD_CHUNK_MAX_PAYLOAD_BYTES` |
| `422` | decode/validation failure (`{"detail", "reason"}`, e.g. `checksum_mismatch`, `layer_dims`, `object_out_of_bounds`) |
**Batch write** — `PUT .../chunks` with
`{"chunks": [{"cx","cy","cz","base_revision","payload_b64"}, …]}`. Applied in
**one transaction, all-or-nothing**: if any entry conflicts, nothing is written
and the response is `409` naming the offending coordinate. Justification: an
editor brush stroke or an object drag routinely straddles a chunk boundary, and
without a batch the editor either issues N round trips or leaves half of a user
action persisted. Capped at `WORLD_CHUNK_MAX_BATCH` chunks and 8 MB.
### 8.2 D5 — Full-chunk PUT, not JSON-Patch deltas
`world.md` §5.4 specifies RFC-6902 deltas against `base_revision`. This design
sends **whole chunks** instead.
- A 32×32×1 chunk is ~2 KB of dense tile indices plus its objects — a few KB.
The bandwidth a patch saves is negligible at editor rates.
- The stored form is an opaque binary blob. Patching it means the backend must
decode → apply a JSON-Patch → re-encode → re-checksum, i.e. implement and test
a *second* mutation format on top of the codec, with its own failure modes, and
keep it in sync with the client's patch generator. That is the same dual-path
drift risk `world.md` §5.2 exists to prevent.
- Lost-update protection comes from `base_revision`, not from the patch, so
nothing is given up on correctness.
Delta encoding stays available as a pure bandwidth optimization later —
`world.md` §8 already files it as future work.
### 8.3 Internal (dedicated server; `require_game_server` token)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/internal/worlds/{world_id}/manifest` | Rooms, grids, spawn, `content_revision`, chunk counts |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks` | Bounds index (same shape as §8.1) |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Binary payload |
Read-only by construction — there is no internal write route, and adding one
would violate D2. The token is world-scoped, so a path whose `world_id` differs
from the token's world is `403`. These routes skip the per-user visibility check
(the sim process has no user) and apply no mature gating (gating is a client/user
concern; the server still needs collision).
### 8.4 D6 — The client streams chunks over HTTP, not over ENet
`world.md` §5.3 routes `request_chunk` / `chunk_data` over the game's ENet
connection. This design has the playing client fetch chunks from the **backend
over HTTP**, the same routes the editor uses.
- The dedicated server has no content the backend doesn't have; proxying bytes
through it adds a hop and puts multi-megabyte reliable transfers in contention
with the 30 Hz snapshot broadcast on the same ENet peer.
- HTTP gives caching for free — `ETag`/`If-None-Match` on the checksum, and a
local `user://chunk_cache/<checksum>` that survives sessions.
- `ApiClient` (`scripts/api_client.gd`) already carries the JWT and the result
contract; no new transport code.
**This does not require rewriting `ChunkStreamManager`.** It already calls
through a `set_server_rpc()` seam that only needs an object with
`request_chunk(key)`. The editor-wiring task supplies an `HttpChunkSource` that
implements that method against `ApiClient` and feeds
`_on_chunk_data_received(key, bytes)` — which also finally gives that dormant
seam a caller. Note the existing `_load_chunk()` builds its key with empty
world/room ids (`Chunk.build_key("", "", coord)`); the adapter must supply the
real world id and room slug.
---
## 9. What each follow-on task owns
**Backend task**
- `backend/app/models/world_content.py` + `0005_add_world_content_tables`
- `backend/app/services/chunk_codec.py` (Python port of `world.md` §3.6) and the
golden-file fixtures shared with the Godot suite
- `backend/app/services/world_store.py` (the `world.md` §5.1 service interface,
minus `flush_dirty`/`rebuild_index`, over Postgres)
- `backend/app/routers/world_content.py`, `backend/app/routers/internal.py`
- `game_servers.py`: per-spawn token, `--api-url`/`--server-token` args,
readiness-from-status-file instead of `_await_port` (§7.4)
- `create_world` creates the `main` room
**Client/editor task**
- `HttpChunkSource` adapter → `ChunkStreamManager.set_server_rpc()`
- Editor save path (single + batch PUT), 409 conflict re-fetch-and-reapply,
`live_session` warning UI
- "Edit world" entry from the world browser that does **not** join
- `server.gd`: arg parsing, manifest + chunk fetch before `create_server`,
collision build from decoded chunks, `test_level.tscn` fallback,
`"ready"`/`"content"` fields in the status file
---
## 10. Open questions (deliberately not decided here)
- **Hot reload / build-in-place** while a session is live (§6) — the
`content_revision` seam is in place for it.
- **Collaborator permissions** beyond `owner_id` — one check in §5.3.
- **Mature-content gating** on the read path — column and index exist; the
policy belongs to `content_moderation.md`.
- **`world_chunk_objects`** derived index — spec'd in `world.md` §4.5, build it
when a query needs it (§4.2).
- **Payload compression** — the §3.6 header reserves a flags bitmask; Postgres
TOAST already compresses `BYTEA`, so measure before adding a second layer.
- **Chunk streaming for very large worlds** — the bounds-query cap in §8.1 is a
guardrail, not a scaling plan.
+5
View File
@@ -50,6 +50,11 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
toggle_build_mode={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194333,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics]
+202
View File
@@ -0,0 +1,202 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/chat_ui.gd" id="1_cu"]
[node name="ChatUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_cu")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Chat & Messaging"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="WorldChat" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="ControlsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="WorldPrefix" type="Label" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
layout_mode = 2
text = "World ID:"
[node name="WorldIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
custom_minimum_size = Vector2(120, 32)
layout_mode = 2
text = "1"
[node name="JoinChatButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
custom_minimum_size = Vector2(110, 32)
layout_mode = 2
text = "Switch Channel"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/WorldChat"]
layout_mode = 2
size_flags_vertical = 3
[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/WorldChat/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Type a message in world chat..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Send"
[node name="DirectMessages" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="StartHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TargetUserEdit" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to start conversation..."
[node name="StartDmButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"]
custom_minimum_size = Vector2(140, 32)
layout_mode = 2
text = "Start Direct Chat"
[node name="HSplit" type="HSplitContainer" parent="VBoxContainer/TabContainer/DirectMessages"]
layout_mode = 2
size_flags_vertical = 3
split_offset = 260
[node name="ConversationsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 0.35
theme_override_constants/separation = 6
[node name="ConvHeader" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Conversations"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="ConvList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="ChatVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6
[node name="DmHeaderLabel" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Select a conversation"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Type a direct message..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Send"
[node name="ReportDialog" type="ConfirmationDialog" parent="."]
title = "Report Inappropriate Message"
size = Vector2i(420, 200)
[node name="VBox" type="VBoxContainer" parent="ReportDialog"]
offset_left = 8.0
offset_top = 8.0
offset_right = 412.0
offset_bottom = 151.0
theme_override_constants/separation = 8
[node name="DetailsLabel" type="Label" parent="ReportDialog/VBox"]
layout_mode = 2
autowrap_mode = 2
[node name="ReasonPrompt" type="Label" parent="ReportDialog/VBox"]
layout_mode = 2
text = "Reason for report:"
[node name="ReasonEdit" type="LineEdit" parent="ReportDialog/VBox"]
layout_mode = 2
text = "Inappropriate content"
+174
View File
@@ -0,0 +1,174 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/friends_presence_ui.gd" id="1_fp"]
[node name="FriendsPresenceUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_fp")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Friends & Social"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="MyStatusHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="StatusPrefix" type="Label" parent="VBoxContainer/MyStatusHBox"]
layout_mode = 2
text = "My Status:"
[node name="StatusLineEdit" type="LineEdit" parent="VBoxContainer/MyStatusHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "What are you doing? (e.g. Exploring worlds...)"
[node name="SetStatusButton" type="Button" parent="VBoxContainer/MyStatusHBox"]
custom_minimum_size = Vector2(110, 32)
layout_mode = 2
text = "Set Status"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="Friends" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Friends"]
layout_mode = 2
size_flags_vertical = 3
[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Friends/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Requests" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Requests"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to add..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
custom_minimum_size = Vector2(120, 32)
layout_mode = 2
text = "Send Request"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Requests"]
layout_mode = 2
size_flags_vertical = 3
[node name="RequestsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Requests/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Following" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 2
[node name="FollowHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Following"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to follow..."
[node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "Follow"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Following"]
layout_mode = 2
size_flags_vertical = 3
[node name="FollowingList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Following/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Blocked" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 3
[node name="BlockHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Blocked"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to block..."
[node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "Block"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Blocked"]
layout_mode = 2
size_flags_vertical = 3
[node name="BlockedList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Blocked/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
+179
View File
@@ -0,0 +1,179 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/groups_ui.gd" id="1_gu"]
[node name="GroupsUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_gu")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Groups & Communities"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="Browse" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="FilterHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Browse"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MyGroupsCheck" type="CheckBox" parent="VBoxContainer/TabContainer/Browse/FilterHBox"]
layout_mode = 2
text = "Show only my groups"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Browse"]
layout_mode = 2
size_flags_vertical = 3
[node name="GroupsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Browse/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Create" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="FormVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/Create"]
custom_minimum_size = Vector2(480, 0)
layout_mode = 2
size_flags_horizontal = 4
theme_override_constants/separation = 12
[node name="NamePrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
text = "Group Name:"
[node name="NameEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
placeholder_text = "Enter unique group name..."
[node name="DescPrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
text = "Group Description:"
[node name="DescEdit" type="TextEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
placeholder_text = "Describe the goals and community of your group..."
[node name="CreateButton" type="Button" parent="VBoxContainer/TabContainer/Create/FormVBox"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
size_flags_horizontal = 4
text = "Create Group"
[node name="GroupDetails" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 2
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/GroupDetails"]
layout_mode = 2
size_flags_vertical = 3
[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="GroupNameLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24
text = "Select a Group"
[node name="MemberCountLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "0 Members"
[node name="GroupDescLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
autowrap_mode = 2
text = "No description."
[node name="ActionsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="JoinButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Join Group"
[node name="LeaveButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Leave Group"
[node name="DeleteButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(110, 36)
layout_mode = 2
text = "Delete Group"
[node name="MembersVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MembersHeader" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Members & Roles"
[node name="MembersList" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
layout_mode = 2
theme_override_constants/separation = 6
+96
View File
@@ -0,0 +1,96 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/marketplace_ui.gd" id="1_mpu"]
[node name="MarketplaceUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_mpu")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBox/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24
text = "Marketplace"
horizontal_alignment = 1
[node name="BalanceLabel" type="Label" parent="VBox/HeaderHBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(1, 0.84, 0, 1)
text = "Wallet: 0 CR"
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="FilterBar" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="SearchEdit" type="LineEdit" parent="VBox/FilterBar"]
custom_minimum_size = Vector2(200, 36)
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Search items by name or description..."
[node name="TypeOption" type="OptionButton" parent="VBox/FilterBar"]
custom_minimum_size = Vector2(140, 36)
layout_mode = 2
[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
placeholder_text = "Min CR"
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
placeholder_text = "Max CR"
[node name="SearchBtn" type="Button" parent="VBox/FilterBar"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
text = "Search"
[node name="StatusLabel" type="Label" parent="VBox"]
layout_mode = 2
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="ListingsGrid" type="GridContainer" parent="VBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 16
theme_override_constants/v_separation = 16
columns = 4
+215
View File
@@ -0,0 +1,215 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/profile_ui.gd" id="1_pu"]
[node name="ProfileUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_pu")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Profile"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="LookupHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="LookupPrefix" type="Label" parent="VBoxContainer/LookupHBox"]
layout_mode = 2
text = "Lookup User ID:"
[node name="UserIdEdit" type="LineEdit" parent="VBoxContainer/LookupHBox"]
custom_minimum_size = Vector2(120, 32)
layout_mode = 2
placeholder_text = "e.g. 2"
[node name="ViewUserButton" type="Button" parent="VBoxContainer/LookupHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "View"
[node name="Spacer" type="Control" parent="VBoxContainer/LookupHBox"]
layout_mode = 2
size_flags_horizontal = 3
[node name="ViewMyProfileButton" type="Button" parent="VBoxContainer/LookupHBox"]
custom_minimum_size = Vector2(140, 32)
layout_mode = 2
text = "My Own Profile"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="ContentVBox" type="VBoxContainer" parent="VBoxContainer/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="CardPanel" type="PanelContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
[node name="Margin" type="MarginContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 16
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 16
[node name="HBox" type="HBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="AvatarBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"]
custom_minimum_size = Vector2(120, 120)
layout_mode = 2
alignment = 1
[node name="AvatarLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/AvatarBox"]
layout_mode = 2
text = "Avatar"
horizontal_alignment = 1
[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6
[node name="UsernameLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Username"
[node name="UserIdLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
layout_mode = 2
text = "User ID: --"
[node name="BioPrompt" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
layout_mode = 2
text = "Bio:"
[node name="BioEdit" type="TextEdit" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
custom_minimum_size = Vector2(0, 80)
layout_mode = 2
placeholder_text = "Tell the world about yourself..."
[node name="SaveBioButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
size_flags_horizontal = 0
text = "Save Bio"
[node name="PrivacyVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="PrivacyHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Privacy Settings"
[node name="Grid" type="GridContainer" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
layout_mode = 2
columns = 2
theme_override_constants/h_separation = 16
theme_override_constants/v_separation = 8
[node name="ProfileVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
text = "Profile Visibility:"
[node name="ProfileVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
[node name="FriendsVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
text = "Friends List Visibility:"
[node name="FriendsVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
[node name="FavoritesVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
text = "Favorites Visibility:"
[node name="FavoritesVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
[node name="SavePrivacyButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
custom_minimum_size = Vector2(140, 32)
layout_mode = 2
size_flags_horizontal = 0
text = "Save Privacy"
[node name="FavoritesVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="FavHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Favorite Experiences"
[node name="FavoritesStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
layout_mode = 2
text = "No favorite experiences yet."
[node name="FavoritesList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="FriendsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="FriendsHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Friends"
[node name="FriendsStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
layout_mode = 2
text = "No friends listed."
[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
layout_mode = 2
theme_override_constants/separation = 4
+198
View File
@@ -0,0 +1,198 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/selling_payout_ui.gd" id="1_spu"]
[node name="SellingPayoutUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_spu")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBox/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24
text = "Creator Studio — Selling & Payouts"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusLabel" type="Label" parent="VBox"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="Selling" type="MarginContainer" parent="VBox/TabContainer"]
layout_mode = 2
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 12
[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Selling"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="CreateListingBox" type="HBoxContainer" parent="VBox/TabContainer/Selling/VBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="NameInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
placeholder_text = "Item Name..."
[node name="TypeOption" type="OptionButton" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
custom_minimum_size = Vector2(130, 36)
layout_mode = 2
[node name="DescInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
custom_minimum_size = Vector2(180, 36)
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Description..."
[node name="PriceInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
placeholder_text = "Price (CR)"
[node name="CreateBtn" type="Button" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
custom_minimum_size = Vector2(110, 36)
layout_mode = 2
text = "List for Sale"
[node name="ListingsTitle" type="Label" parent="VBox/TabContainer/Selling/VBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "Active Listings"
[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Selling/VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="ListingsList" type="VBoxContainer" parent="VBox/TabContainer/Selling/VBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6
[node name="Payouts" type="MarginContainer" parent="VBox/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 12
[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Payouts"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="BalanceCard" type="PanelContainer" parent="VBox/TabContainer/Payouts/VBox"]
layout_mode = 2
[node name="Margin" type="MarginContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12
[node name="HBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin"]
layout_mode = 2
theme_override_constants/separation = 24
[node name="AvailTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
text = "Available to Payout:"
[node name="AvailableLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.2, 0.9, 0.2, 1)
text = "0 CR"
[node name="PendTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
text = "In Escrow:"
[node name="PendingLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(1, 0.8, 0.2, 1)
text = "0 CR"
[node name="LifeTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
text = "Lifetime Earned:"
[node name="LifetimeLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.4, 0.7, 1, 1)
text = "0 CR"
[node name="RequestBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="AmountInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
custom_minimum_size = Vector2(130, 36)
layout_mode = 2
placeholder_text = "Amount (min 100)"
[node name="DestInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
custom_minimum_size = Vector2(220, 36)
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Payout Destination (e.g. PayPal / IBAN)"
[node name="SubmitBtn" type="Button" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
custom_minimum_size = Vector2(140, 36)
layout_mode = 2
text = "Request Payout"
[node name="PayoutsTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "Payout Requests History"
[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Payouts/VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="PayoutsList" type="VBoxContainer" parent="VBox/TabContainer/Payouts/VBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6

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