Compare commits

..

54 Commits

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

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://oemjaiyc1jor"
path="res://.godot/imported/bison_04bizontexture.jpg-ee62bcbf71190dd9c3260c488d0f5226.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "62ac14285e2f49777af903582f16d021"
}
[deps]
source_file="res://assets/props/bison_04bizontexture.jpg"
dest_files=["res://.godot/imported/bison_04bizontexture.jpg-ee62bcbf71190dd9c3260c488d0f5226.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dxjew7eex2jh4"
path="res://.godot/imported/buffalo_chip_coin.glb-ccee212ee6fb7ddcc2555bc23ea60f4b.scn"
[deps]
source_file="res://assets/props/buffalo_chip_coin.glb"
dest_files=["res://.godot/imported/buffalo_chip_coin.glb-ccee212ee6fb7ddcc2555bc23ea60f4b.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://kik8lcd3jp7x"
path="res://.godot/imported/chest.glb-22b4964b33afcd001921fbaa02f82b8a.scn"
[deps]
source_file="res://assets/props/chest.glb"
dest_files=["res://.godot/imported/chest.glb-22b4964b33afcd001921fbaa02f82b8a.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://n18xbm0q3jai"
path="res://.godot/imported/crate_small.glb-e6fdaf315193cd20d4514609adaa68f2.scn"
[deps]
source_file="res://assets/props/crate_small.glb"
dest_files=["res://.godot/imported/crate_small.glb-e6fdaf315193cd20d4514609adaa68f2.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b3nfmxbm87pl3"
path="res://.godot/imported/lamp_post_01.glb-eea7873c5c7bafcb4d19a09b9d97e53c.scn"
[deps]
source_file="res://assets/props/lamp_post_01.glb"
dest_files=["res://.godot/imported/lamp_post_01.glb-eea7873c5c7bafcb4d19a09b9d97e53c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://boi3fsj4ehqvb"
path="res://.godot/imported/rock_01.glb-b0b73bccccfa933f3863bdc2afcb3246.scn"
[deps]
source_file="res://assets/props/rock_01.glb"
dest_files=["res://.godot/imported/rock_01.glb-b0b73bccccfa933f3863bdc2afcb3246.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b84vjc1c3d650"
path="res://.godot/imported/tree_pine_01.glb-519f726dc86fdb9eefd0928153fa0181.scn"
[deps]
source_file="res://assets/props/tree_pine_01.glb"
dest_files=["res://.godot/imported/tree_pine_01.glb-519f726dc86fdb9eefd0928153fa0181.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
@@ -0,0 +1,62 @@
"""add discovery metadata to worlds and create world_visits table
Revision ID: 0015_add_discovery_metadata
Revises: 0014_add_payout_requests
Create Date: 2026-09-15 00:00:00.000000
docs/planning/discovery.md (task b377353b8167).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0015_add_discovery_metadata"
down_revision: Union[str, None] = "0014_add_payout_requests"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add discovery metadata columns to worlds table
op.add_column("worlds", sa.Column("category", sa.String(length=100), nullable=True))
op.add_column("worlds", sa.Column("tags", postgresql.ARRAY(sa.String()), server_default="{}", nullable=True))
op.add_column("worlds", sa.Column("play_count", sa.Integer(), server_default="0", nullable=False))
op.add_column("worlds", sa.Column("last_played_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_worlds_category", "worlds", ["category"], unique=False)
op.create_index("ix_worlds_tags", "worlds", ["tags"], unique=False, postgresql_using="gin")
# 2. Create world_visits table
op.create_table(
"world_visits",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("world_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("anonymous_token", sa.String(length=255), nullable=True),
sa.Column("visited_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_world_visits_world_id", "world_visits", ["world_id"], unique=False)
op.create_index("ix_world_visits_user_id", "world_visits", ["user_id"], unique=False)
op.create_index("ix_world_visits_visited_at", "world_visits", ["visited_at"], unique=False)
op.create_index("ix_world_visits_world_visited_at", "world_visits", ["world_id", "visited_at"], unique=False)
def downgrade() -> None:
op.drop_index("ix_world_visits_world_visited_at", table_name="world_visits")
op.drop_index("ix_world_visits_visited_at", table_name="world_visits")
op.drop_index("ix_world_visits_user_id", table_name="world_visits")
op.drop_index("ix_world_visits_world_id", table_name="world_visits")
op.drop_table("world_visits")
op.drop_index("ix_worlds_tags", table_name="worlds", postgresql_using="gin")
op.drop_index("ix_worlds_category", table_name="worlds")
op.drop_column("worlds", "last_played_at")
op.drop_column("worlds", "play_count")
op.drop_column("worlds", "tags")
op.drop_column("worlds", "category")
@@ -0,0 +1,30 @@
"""add content_guidelines column to worlds table
Revision ID: 0016_content_guidelines
Revises: 0015_add_discovery_metadata
Create Date: 2026-09-17 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0016_content_guidelines"
down_revision: Union[str, None] = "0015_add_discovery_metadata"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Check if column already exists (in case partial run added column before version update failed)
bind = op.get_bind()
insp = sa.inspect(bind)
cols = [c["name"] for c in insp.get_columns("worlds")]
if "content_guidelines" not in cols:
op.add_column("worlds", sa.Column("content_guidelines", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("worlds", "content_guidelines")
@@ -0,0 +1,53 @@
"""add asset_collections and collection_assets tables
Revision ID: 0017_add_asset_collections
Revises: 0016_content_guidelines
Create Date: 2026-09-22 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0017_add_asset_collections"
down_revision: Union[str, None] = "0016_content_guidelines"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
tables = insp.get_table_names()
if "asset_collections" not in tables:
op.create_table(
"asset_collections",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("title", sa.String(length=255), nullable=False, index=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("theme", sa.String(length=64), nullable=False, server_default="general", index=True),
sa.Column("thumbnail_url", sa.String(length=512), nullable=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
sa.Column("tags", sa.String(length=512), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
if "collection_assets" not in tables:
op.create_table(
"collection_assets",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("collection_id", sa.Integer(), sa.ForeignKey("asset_collections.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("collection_assets")
op.drop_table("asset_collections")
+6 -2
View File
@@ -7,9 +7,9 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import ( from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal, auth, users, worlds, assets, asset_collections, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports, presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, marketplace, payouts, chat_world, wallet, payments, marketplace, payouts, discovery,
) )
from app.gateway import ws from app.gateway import ws
@@ -47,6 +47,8 @@ app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"]) app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"]) app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(asset_collections.router, prefix="/api/assets/collections", tags=["asset-collections"])
app.include_router(asset_collections.router, prefix="/assets/collections", tags=["asset-collections"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"]) app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"]) app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"]) app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
@@ -62,7 +64,9 @@ app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"]) app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"]) app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"]) 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="/api", tags=["websocket"])
app.include_router(ws.router, prefix="/v1", tags=["websocket"]) app.include_router(ws.router, prefix="/v1", tags=["websocket"])
+4 -1
View File
@@ -2,6 +2,7 @@ from app.models.user import User
from app.models.session import Session from app.models.session import Session
from app.models.world import World from app.models.world import World
from app.models.asset import Asset from app.models.asset import Asset
from app.models.asset_collection import AssetCollection, CollectionAsset
from app.models.avatar_loadout import AvatarLoadout from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
from app.models.social import Friendship, Follow, Block, FriendshipStatus from app.models.social import Friendship, Follow, Block, FriendshipStatus
@@ -15,9 +16,10 @@ from app.models.marketplace import (
CatalogItem, MarketplaceListing, Purchase, CreatorBalance, CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
ItemType, ListingStatus, PurchaseStatus, ItemType, ListingStatus, PurchaseStatus,
) )
from app.models.world_visit import WorldVisit
__all__ = [ __all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout", "User", "Session", "World", "Asset", "AssetCollection", "CollectionAsset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision", "WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus", "Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus", "Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
@@ -26,4 +28,5 @@ __all__ = [
"Wallet", "Transaction", "TransactionType", "Wallet", "Transaction", "TransactionType",
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance", "CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
"ItemType", "ListingStatus", "PurchaseStatus", "ItemType", "ListingStatus", "PurchaseStatus",
"WorldVisit",
] ]
+53
View File
@@ -0,0 +1,53 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class AssetCollection(Base):
"""A curated collection or pack of 3D models and assets."""
__tablename__ = "asset_collections"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
theme = Column(String(64), nullable=False, default="general", index=True)
thumbnail_url = Column(String(512), nullable=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_public = Column(Boolean, default=True, nullable=False, index=True)
tags = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
owner = relationship("User", back_populates="asset_collections")
items = relationship(
"CollectionAsset",
back_populates="collection",
cascade="all, delete-orphan",
order_by="CollectionAsset.sort_order",
)
class CollectionAsset(Base):
"""Join entity linking an Asset to an AssetCollection with ordering."""
__tablename__ = "collection_assets"
id = Column(Integer, primary_key=True, index=True)
collection_id = Column(
Integer,
ForeignKey("asset_collections.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
asset_id = Column(
Integer,
ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
collection = relationship("AssetCollection", back_populates="items")
asset = relationship("Asset")
+1
View File
@@ -28,6 +28,7 @@ class User(Base):
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan") refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner") worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner") assets = relationship("Asset", back_populates="owner")
asset_collections = relationship("AssetCollection", back_populates="owner", cascade="all, delete-orphan")
avatar_loadout = relationship( avatar_loadout = relationship(
"AvatarLoadout", "AvatarLoadout",
back_populates="user", back_populates="user",
+14 -4
View File
@@ -1,5 +1,5 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.dialects.postgresql import ENUM, ARRAY
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
import enum import enum
@@ -29,11 +29,21 @@ class World(Base):
), ),
default=WorldVisibility.PUBLIC, default=WorldVisibility.PUBLIC,
) )
category = Column(String(100), nullable=True, index=True)
tags = Column(ARRAY(String), nullable=True, server_default="{}")
content_guidelines = Column(Text, nullable=True)
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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=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") owner = relationship("User", back_populates="worlds")
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan") assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
# world_content.py / world_persistence_impl.md §4.1. Added here (only)
# so PC1/PC2 code depending on this module is otherwise untouched.
rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan") rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")
visits = relationship("WorldVisit", back_populates="world", cascade="all, delete-orphan")
+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")
+458
View File
@@ -0,0 +1,458 @@
"""Asset collection router supporting CRUD, theme filtering, and ZIP multi-model extraction."""
import io
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
import zipfile
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form
from pydantic import BaseModel, Field
from sqlalchemy import select, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType
from app.models.asset_collection import AssetCollection, CollectionAsset
from app.services import gltf as gltf_service
router = APIRouter()
# --- Pydantic Schemas ---
class CollectionCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
theme: str = Field("general", max_length=64)
thumbnail_url: Optional[str] = None
is_public: bool = True
tags: Optional[str] = None
class CollectionUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
theme: Optional[str] = Field(None, max_length=64)
thumbnail_url: Optional[str] = None
is_public: Optional[bool] = None
tags: Optional[str] = None
class CollectionItemAdd(BaseModel):
asset_id: int
sort_order: Optional[int] = 0
class AssetItemResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
asset_type: str
file_path: str
file_size: Optional[int] = None
sort_order: int = 0
model_config = {"from_attributes": True}
class CollectionResponse(BaseModel):
id: int
title: str
description: Optional[str] = None
theme: str
thumbnail_url: Optional[str] = None
owner_id: int
is_public: bool
tags: Optional[str] = None
created_at: Union[datetime, str]
updated_at: Union[datetime, str]
items_count: int = 0
items: list[AssetItemResponse] = []
model_config = {"from_attributes": True}
# --- Helpers ---
def _sanitize_path(filename: str) -> str:
"""Strip directories and non-safe characters."""
base = os.path.basename(filename.replace("\\", "/"))
safe = "".join(c for c in base if c.isalnum() or c in "._-")
return safe or "model.glb"
# --- Endpoints ---
@router.post("/", response_model=CollectionResponse, status_code=201)
async def create_collection(
payload: CollectionCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Create a new asset collection."""
col = AssetCollection(
title=payload.title,
description=payload.description,
theme=payload.theme.lower().strip(),
thumbnail_url=payload.thumbnail_url,
owner_id=current_user.id,
is_public=payload.is_public,
tags=payload.tags,
)
db.add(col)
await db.commit()
await db.refresh(col)
return CollectionResponse(
id=col.id,
title=col.title,
description=col.description,
theme=col.theme,
thumbnail_url=col.thumbnail_url,
owner_id=col.owner_id,
is_public=col.is_public,
tags=col.tags,
created_at=str(col.created_at),
updated_at=str(col.updated_at),
items_count=0,
items=[],
)
@router.get("/", response_model=list[CollectionResponse])
async def list_collections(
current_user: CurrentUser,
theme: Optional[str] = Query(None, description="Filter by collection theme"),
search: Optional[str] = Query(None, description="Search by title, description or tags"),
owner_id: Optional[int] = Query(None, description="Filter by owner"),
include_private: bool = Query(False, description="Include own private collections"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
"""List asset collections with optional theme filtering, search, and pagination."""
stmt = select(AssetCollection).options(
selectinload(AssetCollection.items).selectinload(CollectionAsset.asset)
)
# Visibility: public collections or own collections
if include_private:
stmt = stmt.where(or_(AssetCollection.is_public == True, AssetCollection.owner_id == current_user.id))
else:
stmt = stmt.where(or_(AssetCollection.is_public == True, AssetCollection.owner_id == current_user.id))
if theme:
stmt = stmt.where(AssetCollection.theme == theme.lower().strip())
if owner_id:
stmt = stmt.where(AssetCollection.owner_id == owner_id)
if search:
s = f"%{search.strip()}%"
stmt = stmt.where(
or_(
AssetCollection.title.ilike(s),
AssetCollection.description.ilike(s),
AssetCollection.tags.ilike(s),
)
)
stmt = stmt.order_by(AssetCollection.created_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
collections = result.scalars().all()
resp: list[CollectionResponse] = []
for col in collections:
items_resp: list[AssetItemResponse] = []
for item in col.items:
if item.asset is not None:
items_resp.append(
AssetItemResponse(
id=item.asset.id,
name=item.asset.name,
description=item.asset.description,
asset_type=str(item.asset.asset_type.value if hasattr(item.asset.asset_type, "value") else item.asset.asset_type),
file_path=item.asset.file_path,
file_size=item.asset.file_size,
sort_order=item.sort_order,
)
)
resp.append(
CollectionResponse(
id=col.id,
title=col.title,
description=col.description,
theme=col.theme,
thumbnail_url=col.thumbnail_url,
owner_id=col.owner_id,
is_public=col.is_public,
tags=col.tags,
created_at=str(col.created_at),
updated_at=str(col.updated_at),
items_count=len(items_resp),
items=items_resp,
)
)
return resp
@router.get("/{collection_id}", response_model=CollectionResponse)
async def get_collection(
collection_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Get single collection by ID with all item details."""
stmt = (
select(AssetCollection)
.options(selectinload(AssetCollection.items).selectinload(CollectionAsset.asset))
.where(AssetCollection.id == collection_id)
)
res = await db.execute(stmt)
col = res.scalar_one_or_none()
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if not col.is_public and col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Private collection")
items_resp: list[AssetItemResponse] = []
for item in col.items:
if item.asset is not None:
items_resp.append(
AssetItemResponse(
id=item.asset.id,
name=item.asset.name,
description=item.asset.description,
asset_type=str(item.asset.asset_type.value if hasattr(item.asset.asset_type, "value") else item.asset.asset_type),
file_path=item.asset.file_path,
file_size=item.asset.file_size,
sort_order=item.sort_order,
)
)
return CollectionResponse(
id=col.id,
title=col.title,
description=col.description,
theme=col.theme,
thumbnail_url=col.thumbnail_url,
owner_id=col.owner_id,
is_public=col.is_public,
tags=col.tags,
created_at=str(col.created_at),
updated_at=str(col.updated_at),
items_count=len(items_resp),
items=items_resp,
)
@router.patch("/{collection_id}", response_model=CollectionResponse)
async def update_collection(
collection_id: int,
payload: CollectionUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Update collection metadata (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
up_data = payload.model_dump(exclude_unset=True)
for k, v in up_data.items():
if k == "theme" and v is not None:
v = str(v).lower().strip()
setattr(col, k, v)
await db.commit()
await db.refresh(col)
return await get_collection(collection_id, current_user, db)
@router.delete("/{collection_id}", status_code=204)
async def delete_collection(
collection_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Delete a collection (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
await db.delete(col)
await db.commit()
@router.post("/{collection_id}/items", response_model=CollectionResponse)
async def add_item_to_collection(
collection_id: int,
payload: CollectionItemAdd,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Add an asset to the collection (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
asset = await db.get(Asset, payload.asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
# Check if already added
chk = await db.execute(
select(CollectionAsset).where(
CollectionAsset.collection_id == collection_id,
CollectionAsset.asset_id == payload.asset_id,
)
)
existing = chk.scalar_one_or_none()
if existing is not None:
existing.sort_order = payload.sort_order or existing.sort_order
else:
new_link = CollectionAsset(
collection_id=collection_id,
asset_id=payload.asset_id,
sort_order=payload.sort_order or 0,
)
db.add(new_link)
await db.commit()
return await get_collection(collection_id, current_user, db)
@router.delete("/{collection_id}/items/{asset_id}", response_model=CollectionResponse)
async def remove_item_from_collection(
collection_id: int,
asset_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Remove an asset from the collection (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
chk = await db.execute(
select(CollectionAsset).where(
CollectionAsset.collection_id == collection_id,
CollectionAsset.asset_id == asset_id,
)
)
existing = chk.scalar_one_or_none()
if existing is not None:
await db.delete(existing)
await db.commit()
return await get_collection(collection_id, current_user, db)
@router.post("/upload-zip", response_model=CollectionResponse, status_code=201)
async def upload_collection_zip(
current_user: CurrentUser,
file: UploadFile = File(...),
title: str = Form(...),
theme: str = Form("general"),
description: Optional[str] = Form(None),
is_public: bool = Form(True),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Upload a ZIP archive, safely extract .glb / .gltf models, create Asset entries, and pack into AssetCollection."""
content = await file.read()
if not zipfile.is_zipfile(io.BytesIO(content)):
raise HTTPException(status_code=400, detail="Invalid ZIP archive")
upload_dir = Path(settings.UPLOAD_DIR) / "collections"
upload_dir.mkdir(parents=True, exist_ok=True)
# 1. Create collection record first
col = AssetCollection(
title=title,
description=description,
theme=theme.lower().strip(),
owner_id=current_user.id,
is_public=is_public,
tags=tags,
)
db.add(col)
await db.flush()
extracted_assets: list[Asset] = []
sort_idx = 0
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
for entry in zf.infolist():
if entry.is_dir():
continue
clean_name = _sanitize_path(entry.filename)
ext = clean_name.rsplit(".", 1)[-1].lower() if "." in clean_name else ""
if ext not in ["glb", "gltf", "obj"]:
continue
model_bytes = zf.read(entry.filename)
if len(model_bytes) == 0:
continue
# Save file to disk
stored_filename = f"col_{col.id}_{sort_idx}_{clean_name}"
dest_path = upload_dir / stored_filename
dest_path.write_bytes(model_bytes)
# Create asset
base_name = clean_name.rsplit(".", 1)[0].replace("_", " ").title()
asset = Asset(
name=base_name,
description=f"Extracted from collection '{title}' ({clean_name})",
asset_type=AssetType.MODEL_3D,
file_path=str(dest_path),
file_size=len(model_bytes),
mime_type="model/gltf-binary" if ext == "glb" else "model/gltf+json",
owner_id=current_user.id,
is_public=is_public,
)
db.add(asset)
await db.flush()
link = CollectionAsset(
collection_id=col.id,
asset_id=asset.id,
sort_order=sort_idx,
)
db.add(link)
extracted_assets.append(asset)
sort_idx += 1
if not extracted_assets:
raise HTTPException(status_code=400, detail="ZIP archive contains no valid 3D models (.glb, .gltf)")
await db.commit()
await db.refresh(col)
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to extract collection archive: {exc}")
return await get_collection(col.id, current_user, db)
+22 -3
View File
@@ -3,9 +3,11 @@
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from datetime import datetime
from typing import Optional, Union
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -42,8 +44,8 @@ class AssetResponse(BaseModel):
world_id: Optional[int] world_id: Optional[int]
owner_id: int owner_id: int
is_public: bool is_public: bool
created_at: str created_at: Union[datetime, str]
updated_at: str updated_at: Union[datetime, str]
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -241,3 +243,20 @@ async def upload_asset_file(
await db.commit() await db.commit()
await db.refresh(asset) await db.refresh(asset)
return asset return asset
@router.get("/{asset_id}/file")
async def get_asset_file(
asset_id: int,
db: AsyncSession = Depends(get_db),
):
"""Download an asset binary file."""
asset = await db.get(Asset, asset_id)
if asset is None or not asset.file_path or not os.path.exists(asset.file_path):
raise HTTPException(status_code=404, detail="Asset file not found")
return FileResponse(
asset.file_path,
media_type=asset.mime_type or "application/octet-stream",
filename=os.path.basename(asset.file_path),
)
+20 -17
View File
@@ -43,12 +43,10 @@ class RegisterRequest(BaseModel):
username: str username: str
email: EmailStr email: EmailStr
password: str password: str
# Real date of birth -- the server computes age from it and rejects # ModBison is an adults-only platform (18+). Users confirm agreement with
# anyone under MIN_AGE. Still self-attested (no document check yet), but # 18+ Terms of Service. Date of birth is optional / deprecated.
# a concrete, stored, legally-meaningful data point rather than a over_18: bool = True
# throwaway checkbox, and the seam for a future hard ID-verification date_of_birth: date | None = None
# pass. See docs/architecture/content_moderation.md 5.2.
date_of_birth: date
class UserResponse(BaseModel): class UserResponse(BaseModel):
@@ -159,20 +157,25 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens. """Register a new user and return access + refresh tokens.
Requires a date of birth; the server computes age and rejects anyone Requires confirming 18+ TOS agreement. If a date of birth is supplied,
under MIN_AGE (18). See docs/architecture/content_moderation.md for the verifies the user is >= MIN_AGE (18).
verification design and its self-attestation limitation.
""" """
age = calculate_age(req.date_of_birth) if req.date_of_birth is not None:
if age < 0 or req.date_of_birth > date.today(): age = calculate_age(req.date_of_birth)
raise HTTPException( if age < 0 or req.date_of_birth > date.today():
status_code=status.HTTP_400_BAD_REQUEST, raise HTTPException(
detail="Invalid date of birth", status_code=status.HTTP_400_BAD_REQUEST,
) detail="Invalid date of birth",
if age < MIN_AGE: )
if age < MIN_AGE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be at least {MIN_AGE} to use this platform",
)
elif not req.over_18:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be at least {MIN_AGE} to use this platform", detail="You must confirm you are 18 or older to register",
) )
# Check if username already exists # Check if username already exists
+95 -2
View File
@@ -71,7 +71,7 @@ from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, computed_field, model_validator
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -97,7 +97,33 @@ _BODY_MAX_LEN = 4000
class SendDMRequest(BaseModel): class SendDMRequest(BaseModel):
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN) body: str | None = Field(default=None, max_length=_BODY_MAX_LEN)
content: str | None = Field(default=None, max_length=_BODY_MAX_LEN)
@model_validator(mode="before")
@classmethod
def resolve_body_or_content(cls, data: object) -> object:
if isinstance(data, dict):
if not data.get("body") and data.get("content"):
data["body"] = data["content"]
elif not data.get("content") and data.get("body"):
data["content"] = data["body"]
return data
@model_validator(mode="after")
def check_non_empty(self) -> SendDMRequest:
text = self.body or self.content
if not text or len(text.strip()) < 1:
raise ValueError("Message body or content must not be empty")
if not self.body:
self.body = text
if not self.content:
self.content = text
return self
class StartConversationRequest(BaseModel):
target_user_id: int
class DMMessageResponse(BaseModel): class DMMessageResponse(BaseModel):
@@ -111,6 +137,11 @@ class DMMessageResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@computed_field
@property
def content(self) -> str:
return self.body
class DMMessageListResponse(BaseModel): class DMMessageListResponse(BaseModel):
messages: list[DMMessageResponse] messages: list[DMMessageResponse]
@@ -397,3 +428,65 @@ async def list_conversations(
) )
return DMConversationListResponse(conversations=summaries, total=total) return DMConversationListResponse(conversations=summaries, total=total)
@router.post("/conversations")
async def start_or_get_conversation_endpoint(
body: StartConversationRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Start or get existing conversation with target_user_id."""
if body.target_user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot start a conversation with yourself"
)
await _get_target_user(db, body.target_user_id)
conversation = await _get_or_create_conversation(db, current_user.id, body.target_user_id)
return {"id": conversation.id, "target_user_id": body.target_user_id}
@router.get("/conversations/{conversation_id}/messages", response_model=DMMessageListResponse)
async def get_dm_conversation_history(
conversation_id: int,
current_user: CurrentUser,
db: DbDep,
limit: int = Query(50, ge=1, le=100),
before: int | None = Query(None),
):
"""Get messages for a conversation by conversation_id."""
conv_result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
or_(Conversation.user_a_id == current_user.id, Conversation.user_b_id == current_user.id),
)
)
conversation = conv_result.scalar_one_or_none()
if conversation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
peer_id = _peer_id(conversation, current_user.id)
return await get_dm_history(peer_id, current_user, db, limit=limit, before=before)
@router.post(
"/conversations/{conversation_id}/messages",
response_model=DMMessageResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(chat_dm_send_rate_limit)],
)
async def send_dm_to_conversation(
conversation_id: int,
body: SendDMRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Send a message to a conversation by conversation_id."""
conv_result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
or_(Conversation.user_a_id == current_user.id, Conversation.user_b_id == current_user.id),
)
)
conversation = conv_result.scalar_one_or_none()
if conversation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
peer_id = _peer_id(conversation, current_user.id)
return await send_dm(peer_id, body, current_user, db)
+34
View File
@@ -124,9 +124,42 @@ async def report_message(
return report return report
class ModerationReportCreate(BaseModel):
target_type: Literal["message", "world", "user"]
target_id: int
reason: str = Field(..., min_length=1, max_length=500)
category: str | None = Field(default="general", max_length=50)
# ──────────────────────────────── Moderation ──────────────────────────────────── # ──────────────────────────────── Moderation ────────────────────────────────────
@moderation_router.post(
"/reports",
response_model=MessageReportResponse,
status_code=status.HTTP_201_CREATED,
)
async def submit_moderation_report(
payload: ModerationReportCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Submit a moderation report for a world, user, or chat message."""
report_reason = f"[{payload.target_type.upper()}:{payload.target_id}] [{payload.category}] {payload.reason}"
msg_id = payload.target_id if payload.target_type == "message" else None
report = MessageReport(
message_id=msg_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_router.get("/reports", response_model=list[MessageReportResponse]) @moderation_router.get("/reports", response_model=list[MessageReportResponse])
async def list_reports( async def list_reports(
current_user: CurrentUser, current_user: CurrentUser,
@@ -146,6 +179,7 @@ async def list_reports(
return list(result.scalars().all()) return list(result.scalars().all())
@moderation_router.post( @moderation_router.post(
"/reports/{report_id}/resolve", "/reports/{report_id}/resolve",
response_model=ResolveReportResponse, response_model=ResolveReportResponse,
+29 -2
View File
@@ -39,7 +39,7 @@ from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, computed_field, model_validator
from sqlalchemy import and_, or_, select from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@@ -68,9 +68,31 @@ _BODY_MAX_LEN = 4000
class SendWorldMessageRequest(BaseModel): class SendWorldMessageRequest(BaseModel):
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN) body: str | None = Field(default=None, max_length=_BODY_MAX_LEN)
content: str | None = Field(default=None, max_length=_BODY_MAX_LEN)
room_id: int | None = None room_id: int | None = None
@model_validator(mode="before")
@classmethod
def resolve_body_or_content(cls, data: object) -> object:
if isinstance(data, dict):
if not data.get("body") and data.get("content"):
data["body"] = data["content"]
elif not data.get("content") and data.get("body"):
data["content"] = data["body"]
return data
@model_validator(mode="after")
def check_non_empty(self) -> SendWorldMessageRequest:
text = self.body or self.content
if not text or len(text.strip()) < 1:
raise ValueError("Message body or content must not be empty")
if not self.body:
self.body = text
if not self.content:
self.content = text
return self
class AuthorInfo(BaseModel): class AuthorInfo(BaseModel):
id: int | None id: int | None
@@ -92,6 +114,11 @@ class WorldMessageResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@computed_field
@property
def content(self) -> str:
return self.body
class WorldMessageListResponse(BaseModel): class WorldMessageListResponse(BaseModel):
messages: list[WorldMessageResponse] messages: list[WorldMessageResponse]
+262
View File
@@ -0,0 +1,262 @@
"""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
from app.services.trending import get_trending_subquery
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] = []
content_guidelines: str | None = None
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 == "trending":
trending_subq = get_trending_subquery()
base_query = (
base_query
.outerjoin(trending_subq, World.id == trending_subq.c.trending_world_id)
.order_by(
func.coalesce(trending_subq.c.trending_score, 0.0).desc(),
World.play_count.desc(),
World.created_at.desc(),
)
)
elif 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,
)
+38 -5
View File
@@ -46,7 +46,8 @@ DbDep = Annotated[AsyncSession, Depends(get_db)]
class FriendRequestCreate(BaseModel): class FriendRequestCreate(BaseModel):
user_id: int user_id: int | None = None
username: str | None = None
class FriendRequestResponse(BaseModel): class FriendRequestResponse(BaseModel):
@@ -55,6 +56,7 @@ class FriendRequestResponse(BaseModel):
addressee_id: int addressee_id: int
status: FriendshipStatus status: FriendshipStatus
created_at: datetime created_at: datetime
requester_username: str | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -132,7 +134,18 @@ async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
@router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED) @router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED)
async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep): async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep):
"""Send a friend request to another user.""" """Send a friend request to another user."""
target_id = body.user_id target_id: int
if body.user_id is not None and body.user_id > 0:
target_id = body.user_id
elif body.username is not None and body.username.strip() != "":
u_result = await db.execute(select(User).where(User.username == body.username.strip()))
user = u_result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
target_id = user.id
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_id or username required")
if target_id == current_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") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself")
@@ -153,19 +166,39 @@ async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUs
await db.rollback() await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
await db.refresh(fr) await db.refresh(fr)
return fr return FriendRequestResponse(
id=fr.id,
requester_id=fr.requester_id,
addressee_id=fr.addressee_id,
status=fr.status,
created_at=fr.created_at,
requester_username=current_user.username,
)
@router.get("/requests", response_model=FriendRequestListResponse) @router.get("/requests", response_model=FriendRequestListResponse)
async def list_incoming_requests(current_user: CurrentUser, db: DbDep): async def list_incoming_requests(current_user: CurrentUser, db: DbDep):
"""List pending friend requests sent *to* the authenticated user.""" """List pending friend requests sent *to* the authenticated user."""
result = await db.execute( result = await db.execute(
select(Friendship).where( select(Friendship, User.username)
.join(User, Friendship.requester_id == User.id)
.where(
Friendship.addressee_id == current_user.id, Friendship.addressee_id == current_user.id,
Friendship.status == FriendshipStatus.PENDING, Friendship.status == FriendshipStatus.PENDING,
) )
) )
return FriendRequestListResponse(requests=list(result.scalars().all())) requests = [
FriendRequestResponse(
id=fr.id,
requester_id=fr.requester_id,
addressee_id=fr.addressee_id,
status=fr.status,
created_at=fr.created_at,
requester_username=uname,
)
for fr, uname in result.all()
]
return FriendRequestListResponse(requests=requests)
@router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse) @router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse)
+44
View File
@@ -25,6 +25,13 @@ class UserResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class UserSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
email: EmailStr | None = None email: EmailStr | None = None
is_active: bool | None = None is_active: bool | None = None
@@ -40,6 +47,43 @@ async def get_current_user_profile(
return current_user return current_user
@router.get("/search", response_model=list[UserSummary])
async def search_users(
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
q: str = "",
limit: int = 20,
):
"""Search active users by username substring."""
cleaned_q = q.strip()
result = await db.execute(
select(User)
.where(
User.is_active == True,
User.username.ilike(f"%{cleaned_q}%"),
User.id != current_user.id,
)
.limit(limit)
)
return list(result.scalars().all())
@router.get("/by-username/{username}", response_model=UserSummary)
async def get_user_by_username(
username: str,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get active user by exact username."""
result = await db.execute(
select(User).where(User.username == username.strip(), User.is_active == True)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.get("/{user_id}", response_model=UserResponse) @router.get("/{user_id}", response_model=UserResponse)
async def get_user( async def get_user(
user_id: int, user_id: int,
+35
View File
@@ -76,3 +76,38 @@ async def get_wallet_transactions(
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
class RewardRequest(BaseModel):
amount: int = Field(..., gt=0, le=10000, description="Amount of Buffalo Chips to reward")
reason: str | None = Field(default="in_game_reward", description="Reward source or reason")
class RewardResponse(BaseModel):
success: bool
amount: int
new_balance: int
@router.post("/reward", response_model=RewardResponse)
async def reward_chips(
payload: RewardRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Credit in-game Buffalo Chips reward directly to the player's wallet."""
wallet, tx = await wallet_service.credit(
db,
user_id=current_user.id,
amount=payload.amount,
tx_type=TransactionType.admin_adjustment,
reference_type="gameplay_reward",
reference_id=payload.reason or "in_game_reward",
metadata={"reason": payload.reason or "in_game_reward"},
)
return RewardResponse(
success=True,
amount=payload.amount,
new_balance=wallet.balance,
)
+18
View File
@@ -27,6 +27,9 @@ class WorldCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
description: str | None = None description: str | None = None
visibility: WorldVisibility = WorldVisibility.PUBLIC visibility: WorldVisibility = WorldVisibility.PUBLIC
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
class WorldUpdate(BaseModel): class WorldUpdate(BaseModel):
@@ -34,6 +37,9 @@ class WorldUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255) name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None description: str | None = None
visibility: WorldVisibility | None = None visibility: WorldVisibility | None = None
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
class WorldResponse(BaseModel): class WorldResponse(BaseModel):
@@ -42,12 +48,19 @@ class WorldResponse(BaseModel):
name: str name: str
description: str | None description: str | None
visibility: WorldVisibility visibility: WorldVisibility
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
play_count: int = 0
last_played_at: datetime | None = None
owner_id: int owner_id: int
created_at: datetime | None = None created_at: datetime | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# --- Endpoints --- # --- Endpoints ---
@router.get("/", response_model=list[WorldResponse]) @router.get("/", response_model=list[WorldResponse])
@@ -159,7 +172,12 @@ async def create_world(
description=world_data.description, description=world_data.description,
owner_id=current_user.id, owner_id=current_user.id,
visibility=world_data.visibility, visibility=world_data.visibility,
category=world_data.category,
tags=world_data.tags or [],
content_guidelines=world_data.content_guidelines,
) )
db.add(world) db.add(world)
await db.flush() # assigns world.id, needed for the room's FK await db.flush() # assigns world.id, needed for the room's FK
await WorldStore(db).get_or_create_main_room(world.id) await WorldStore(db).get_or_create_main_room(world.id)
+20 -3
View File
@@ -50,6 +50,7 @@ class _WorldServer:
proc: subprocess.Popen proc: subprocess.Popen
status_path: Path status_path: Path
token: str token: str
log_path: Path | None = None
started_at: float = field(default_factory=time.time) started_at: float = field(default_factory=time.time)
last_join: float = field(default_factory=time.time) last_join: float = field(default_factory=time.time)
@@ -83,7 +84,7 @@ class GameServerSupervisor:
self._servers: dict[str, _WorldServer] = {} self._servers: dict[str, _WorldServer] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._reaper: asyncio.Task | None = None self._reaper: asyncio.Task | None = None
self._runtime_dir = Path(tempfile.gettempdir()) / "agp-game-servers" self._runtime_dir = Path(tempfile.gettempdir()) / "modbison-game-servers"
self._runtime_dir.mkdir(parents=True, exist_ok=True) self._runtime_dir.mkdir(parents=True, exist_ok=True)
# ── lifecycle ──────────────────────────────────────────────────────── # ── lifecycle ────────────────────────────────────────────────────────
@@ -150,6 +151,7 @@ class GameServerSupervisor:
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
status_path = self._runtime_dir / f"world_{world_id}_{port}.json" status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
status_path.unlink(missing_ok=True) status_path.unlink(missing_ok=True)
log_path = self._runtime_dir / f"world_{world_id}_{port}.log"
cmd = [ cmd = [
settings.GODOT_BIN, "--headless", settings.GODOT_BIN, "--headless",
"--path", settings.GAME_PROJECT_DIR, "--path", settings.GAME_PROJECT_DIR,
@@ -160,9 +162,10 @@ class GameServerSupervisor:
"--api-url", settings.GAME_SERVER_INTERNAL_API_URL, "--api-url", settings.GAME_SERVER_INTERNAL_API_URL,
"--server-token", token, "--server-token", token,
] ]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) log_file = open(log_path, "w")
proc = subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT)
return _WorldServer( return _WorldServer(
world_id=world_id, port=port, proc=proc, status_path=status_path, token=token world_id=world_id, port=port, proc=proc, status_path=status_path, token=token, log_path=log_path
) )
def _free_port(self) -> int: def _free_port(self) -> int:
@@ -199,13 +202,27 @@ class GameServerSupervisor:
if srv.is_ready(): if srv.is_ready():
return return
if not srv.alive(): if not srv.alive():
log_snippet = ""
if srv.log_path and srv.log_path.exists():
try:
log_snippet = srv.log_path.read_text()[-500:]
except OSError:
pass
raise TimeoutError( raise TimeoutError(
f"server process for world {srv.world_id} exited before becoming ready" f"server process for world {srv.world_id} exited before becoming ready"
+ (f": {log_snippet}" if log_snippet else "")
) )
await asyncio.sleep(0.25) await asyncio.sleep(0.25)
log_snippet = ""
if srv.log_path and srv.log_path.exists():
try:
log_snippet = srv.log_path.read_text()[-500:]
except OSError:
pass
raise TimeoutError( raise TimeoutError(
f"server for world {srv.world_id} did not report ready within " f"server for world {srv.world_id} did not report ready within "
f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s" f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s"
+ (f": {log_snippet}" if log_snippet else "")
) )
async def _reap_loop(self) -> None: async def _reap_loop(self) -> None:
+3 -3
View File
@@ -87,11 +87,11 @@ class PayoutService:
"""Create a new payout request after reserving funds from available creator balance.""" """Create a new payout request after reserving funds from available creator balance."""
if amount < MIN_PAYOUT_AMOUNT: if amount < MIN_PAYOUT_AMOUNT:
raise InvalidPayoutAmountError( raise InvalidPayoutAmountError(
f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Credits" f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Buffalo Chips"
) )
if amount > MAX_PAYOUT_AMOUNT: if amount > MAX_PAYOUT_AMOUNT:
raise InvalidPayoutAmountError( raise InvalidPayoutAmountError(
f"Maximum payout amount is {MAX_PAYOUT_AMOUNT} Credits" f"Maximum payout amount is {MAX_PAYOUT_AMOUNT} Buffalo Chips"
) )
requester = requested_by if requested_by is not None else creator_id requester = requested_by if requested_by is not None else creator_id
@@ -100,7 +100,7 @@ class PayoutService:
cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True) cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True)
if cb.available < amount: if cb.available < amount:
raise InsufficientCreatorBalanceError( raise InsufficientCreatorBalanceError(
f"Insufficient available balance: {cb.available} CR available, {amount} CR requested" f"Insufficient available balance: {cb.available} Buffalo Chips available, {amount} Buffalo Chips requested"
) )
# Reserve funds: move from available to pending # Reserve funds: move from available to pending
+34
View File
@@ -0,0 +1,34 @@
"""Trending recommendation heuristic service.
docs/planning/discovery.md (task 33706f401ad9).
"""
from sqlalchemy import case, func, select, text
from app.models.world_visit import WorldVisit
def get_trending_subquery(days: int = 7):
"""Build a subquery that computes a decay-weighted trending score per world_id.
Decay weights:
- Visited within 24 hours: weight 3.0
- Visited within 3 days: weight 2.0
- Visited within 7 days: weight 1.0
"""
trending_weight = case(
(WorldVisit.visited_at >= func.now() - text("interval '1 day'"), 3.0),
(WorldVisit.visited_at >= func.now() - text("interval '3 days'"), 2.0),
else_=1.0,
)
interval_str = f"interval '{days} days'"
subq = (
select(
WorldVisit.world_id.label("trending_world_id"),
func.coalesce(func.sum(trending_weight), 0.0).label("trending_score"),
)
.where(WorldVisit.visited_at >= func.now() - text(interval_str))
.group_by(WorldVisit.world_id)
.subquery("trending_scores")
)
return subq
+2
View File
@@ -45,6 +45,8 @@ TEST_TABLES = (
# `worlds` the way world_rooms/world_chunks would be. List all three # `worlds` the way world_rooms/world_chunks would be. List all three
# explicitly rather than rely on cascade for some of them. # explicitly rather than rely on cascade for some of them.
"world_chunk_revisions", "world_chunks", "world_rooms", "world_chunk_revisions", "world_chunks", "world_rooms",
# Discovery (app/models/world_visit.py) -- FK worlds.id with ON DELETE CASCADE
"world_visits",
"worlds", "worlds",
# Social graph (app/models/social.py) -- all three FK users with # Social graph (app/models/social.py) -- all three FK users with
# ON DELETE CASCADE, but list explicitly for clarity/consistency. # ON DELETE CASCADE, but list explicitly for clarity/consistency.
+235
View File
@@ -0,0 +1,235 @@
"""Integration tests for Poly Model Asset Collections and ZIP batch extraction.
Covers tasks dfe19da5a448 and 840bb736d098.
"""
import io
import zipfile
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_collection_crud_and_permissions():
client = TestClient(app)
uid1, h1 = _register(client, "col_user_1")
uid2, h2 = _register(client, "col_user_2")
# 1. Create collection as user 1
resp = client.post(
"/api/assets/collections/",
headers=h1,
json={
"title": "Low Poly Fantasy Props",
"description": "Swords, shields, and potions",
"theme": "fantasy",
"is_public": True,
"tags": "fantasy,lowpoly,weapons",
},
)
assert resp.status_code == 201, resp.text
col_data = resp.json()
assert col_data["title"] == "Low Poly Fantasy Props"
assert col_data["theme"] == "fantasy"
assert col_data["owner_id"] == uid1
col_id = col_data["id"]
# 2. List collections with theme filter
resp = client.get("/api/assets/collections/", headers=h2, params={"theme": "fantasy"})
assert resp.status_code == 200
cols = resp.json()
assert any(c["id"] == col_id for c in cols)
# 3. List with non-matching theme filter
resp = client.get("/api/assets/collections/", headers=h2, params={"theme": "scifi"})
assert resp.status_code == 200
cols = resp.json()
assert not any(c["id"] == col_id for c in cols)
# 4. Search filter
resp = client.get("/api/assets/collections/", headers=h2, params={"search": "shields"})
assert resp.status_code == 200
assert any(c["id"] == col_id for c in resp.json())
# 5. User 2 views public collection
resp = client.get(f"/api/assets/collections/{col_id}", headers=h2)
assert resp.status_code == 200
assert resp.json()["id"] == col_id
# 6. User 2 cannot modify User 1's collection
resp = client.patch(
f"/api/assets/collections/{col_id}",
headers=h2,
json={"title": "Hacked Title"},
)
assert resp.status_code == 403
# 7. User 2 cannot delete User 1's collection
resp = client.delete(f"/api/assets/collections/{col_id}", headers=h2)
assert resp.status_code == 403
# 8. User 1 updates collection to private
resp = client.patch(
f"/api/assets/collections/{col_id}",
headers=h1,
json={"is_public": False, "theme": "medieval"},
)
assert resp.status_code == 200
assert resp.json()["is_public"] is False
assert resp.json()["theme"] == "medieval"
# 9. User 2 can no longer view private collection
resp = client.get(f"/api/assets/collections/{col_id}", headers=h2)
assert resp.status_code == 403
# 10. User 1 deletes collection
resp = client.delete(f"/api/assets/collections/{col_id}", headers=h1)
assert resp.status_code == 204
# 11. Verify gone
resp = client.get(f"/api/assets/collections/{col_id}", headers=h1)
assert resp.status_code == 404
def test_collection_item_association_and_ordering():
client = TestClient(app)
uid, h = _register(client, "col_items_user")
# Create 2 assets
a1_resp = client.post(
"/api/assets/",
headers=h,
json={"name": "Wooden Crate", "asset_type": "model_3d"},
)
assert a1_resp.status_code == 201, a1_resp.text
asset1_id = a1_resp.json()["id"]
a2_resp = client.post(
"/api/assets/",
headers=h,
json={"name": "Iron Barrel", "asset_type": "model_3d"},
)
assert a2_resp.status_code == 201, a2_resp.text
asset2_id = a2_resp.json()["id"]
# Create collection
col_resp = client.post(
"/api/assets/collections/",
headers=h,
json={"title": "Industrial Pack", "theme": "industrial"},
)
assert col_resp.status_code == 201
col_id = col_resp.json()["id"]
# Add items with explicit ordering
add1 = client.post(
f"/api/assets/collections/{col_id}/items",
headers=h,
json={"asset_id": asset1_id, "sort_order": 0},
)
assert add1.status_code == 200
assert add1.json()["items_count"] == 1
assert add1.json()["items"][0]["id"] == asset1_id
add2 = client.post(
f"/api/assets/collections/{col_id}/items",
headers=h,
json={"asset_id": asset2_id, "sort_order": 1},
)
assert add2.status_code == 200
assert add2.json()["items_count"] == 2
assert add2.json()["items"][1]["id"] == asset2_id
# Remove item 1
rem = client.delete(
f"/api/assets/collections/{col_id}/items/{asset1_id}",
headers=h,
)
assert rem.status_code == 200
assert rem.json()["items_count"] == 1
assert rem.json()["items"][0]["id"] == asset2_id
def test_collection_zip_upload_and_extraction():
client = TestClient(app)
uid, h = _register(client, "col_zip_user")
# 1. Build a valid in-memory zip file with mock GLB content
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("stone_pillar.glb", b"glTFmockheader_binary_stone_pillar")
zf.writestr("wooden_arch.glb", b"glTFmockheader_binary_wooden_arch")
zf.writestr("temple_roof.gltf", b'{"asset": {"version": "2.0"}}')
zf.writestr("readme.txt", b"Instructions for this pack")
zip_bytes = zip_buf.getvalue()
# Upload zip pack
resp = client.post(
"/api/assets/collections/upload-zip",
headers=h,
data={
"title": "Ancient Ruins Pack",
"theme": "historical",
"description": "Set of stone pillars and wooden arches",
"is_public": "true",
"tags": "ruins,ancient,stone",
},
files={"file": ("ruins_pack.zip", zip_bytes, "application/zip")},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["title"] == "Ancient Ruins Pack"
assert data["theme"] == "historical"
assert data["items_count"] == 3
names = [i["name"] for i in data["items"]]
assert "Stone Pillar" in names
assert "Wooden Arch" in names
assert "Temple Roof" in names
def test_collection_zip_error_handling():
client = TestClient(app)
uid, h = _register(client, "col_badzip_user")
# 1. Invalid zip payload
resp = client.post(
"/api/assets/collections/upload-zip",
headers=h,
data={"title": "Corrupt Pack", "theme": "general"},
files={"file": ("bad.zip", b"not-a-valid-zip-file", "application/zip")},
)
assert resp.status_code == 400
assert "Invalid ZIP archive" in resp.json()["detail"]
# 2. Valid zip but no 3D models
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("notes.txt", b"Hello world")
zf.writestr("image.png", b"fake image bytes")
resp = client.post(
"/api/assets/collections/upload-zip",
headers=h,
data={"title": "No Models Pack", "theme": "general"},
files={"file": ("empty.zip", zip_buf.getvalue(), "application/zip")},
)
assert resp.status_code == 400
assert "no valid 3D models" in resp.json()["detail"]
+5 -3
View File
@@ -40,9 +40,10 @@ class TestRegisterRequestDob:
date_of_birth="1990-01-01") date_of_birth="1990-01-01")
assert req.date_of_birth == date(1990, 1, 1) assert req.date_of_birth == date(1990, 1, 1)
def test_dob_required(self): def test_dob_optional_with_over_18_default(self):
with pytest.raises(Exception): req = RegisterRequest(username="u", email="u@example.com", password="p")
RegisterRequest(username="u", email="u@example.com", password="p") assert req.date_of_birth is None
assert req.over_18 is True
def test_bad_dob_string_rejected(self): def test_bad_dob_string_rejected(self):
with pytest.raises(Exception): with pytest.raises(Exception):
@@ -50,6 +51,7 @@ class TestRegisterRequestDob:
date_of_birth="not-a-date") date_of_birth="not-a-date")
class TestRegisterAgeGate: class TestRegisterAgeGate:
"""End-to-end: the /register endpoint computes age from the DOB.""" """End-to-end: the /register endpoint computes age from the DOB."""
+21 -5
View File
@@ -149,18 +149,34 @@ class TestRegister:
assert resp.status_code == 409 assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"] assert "Email already registered" in resp.json()["detail"]
def test_register_missing_dob(self): def test_register_without_dob_succeeds(self):
"""Missing the required date_of_birth field returns 422.""" """DOB is no longer collected; registration with 18+ TOS agreement succeeds."""
client = TestClient(app) client = TestClient(app)
resp = client.post( resp = client.post(
"/api/auth/register", "/api/auth/register",
json={ json={
"username": "user", "username": "adult_user_nodob",
"email": "user@example.com", "email": "adult_user_nodob@example.com",
"password": "secret123", "password": "secret123",
"over_18": True,
}, },
) )
assert resp.status_code == 422 assert resp.status_code == 201
def test_register_not_over_18_rejected(self):
"""Declining 18+ confirmation returns 403."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "underage_declined",
"email": "underage_declined@example.com",
"password": "secret123",
"over_18": False,
},
)
assert resp.status_code == 403
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+42
View File
@@ -199,3 +199,45 @@ class TestModerationQueue:
# Attempting to use bad_user's token fails with 403 inactive account # Attempting to use bad_user's token fails with 403 inactive account
bad_me = client.get("/api/users/me", headers=bad_user["headers"]) bad_me = client.get("/api/users/me", headers=bad_user["headers"])
assert bad_me.status_code == 403 assert bad_me.status_code == 403
def test_submit_world_and_user_moderation_report(self):
"""Test submitting general moderation reports for worlds and users."""
client = TestClient(app)
reporter = _register(client, "gen_reporter")
admin = _register(client, "admin_gen_mod")
# Report a world
world_rep = client.post(
"/api/moderation/reports",
headers=reporter["headers"],
json={
"target_type": "world",
"target_id": 42,
"reason": "Violates world guidelines",
"category": "world_rules",
},
)
assert world_rep.status_code == 201, world_rep.text
assert "[WORLD:42]" in world_rep.json()["reason"]
# Report a user
user_rep = client.post(
"/api/moderation/reports",
headers=reporter["headers"],
json={
"target_type": "user",
"target_id": 99,
"reason": "Harassment in world",
"category": "harassment",
},
)
assert user_rep.status_code == 201, user_rep.text
assert "[USER:99]" in user_rep.json()["reason"]
# Admin lists open reports and sees both
rep_list = client.get("/api/moderation/reports", headers=admin["headers"])
assert rep_list.status_code == 200
reasons = [r["reason"] for r in rep_list.json()]
assert any("[WORLD:42]" in r for r in reasons)
assert any("[USER:99]" in r for r in reasons)
+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()
+103
View File
@@ -0,0 +1,103 @@
"""API and heuristic tests for trending discovery sort.
docs/planning/discovery.md (task 33706f401ad9).
"""
from datetime import datetime, timedelta, timezone
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.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_trending_sort_heuristic_and_zero_visits():
client = TestClient(app)
uid, headers = _register(client, "creator_trending_1")
# Create 3 worlds
wA = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Trending Hot World", "visibility": "public", "category": "Action"},
).json()
wB = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Older Mild World", "visibility": "public", "category": "Action"},
).json()
wC = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Zero Visit World", "visibility": "public", "category": "Action"},
).json()
engine, sf = _session_factory()
now = datetime.now(timezone.utc)
try:
async with sf() as session:
# World A: 3 visits within last 2 hours (weight 3 each = score 9)
for i in range(3):
session.add(
WorldVisit(
world_id=wA["id"],
anonymous_token=f"hot_visitor_{i}",
visited_at=now - timedelta(hours=1, minutes=i),
)
)
# World B: 1 visit 5 days ago (weight 1 = score 1)
session.add(
WorldVisit(
world_id=wB["id"],
anonymous_token="old_visitor",
visited_at=now - timedelta(days=5),
)
)
# World C: zero visits
await session.commit()
finally:
await engine.dispose()
# Query with sort=trending
resp = client.get("/api/discovery/worlds?sort=trending&category=Action")
assert resp.status_code == 200, resp.text
items = resp.json()["items"]
# Verify order: wA should be first, wB second, wC third
ids = [item["id"] for item in items]
assert wA["id"] in ids
assert wB["id"] in ids
assert wC["id"] in ids
idxA = ids.index(wA["id"])
idxB = ids.index(wB["id"])
idxC = ids.index(wC["id"])
assert idxA < idxB < idxC, f"Expected order A ({idxA}) < B ({idxB}) < C ({idxC})"
+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
+44
View File
@@ -127,6 +127,26 @@ class TestFriendRequestFlow:
again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"]) again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert again.status_code == 404 assert again.status_code == 404
def test_send_request_by_username_populates_requester_username(self):
client = TestClient(app)
a = _register(client, "fr_uname_a")
b = _register(client, "fr_uname_b")
resp = client.post(
"/api/friends/requests", json={"username": "fr_uname_b"}, headers=a["headers"]
)
assert resp.status_code == 201, resp.text
req_data = resp.json()
assert req_data["status"] == "pending"
assert req_data.get("requester_username") == "fr_uname_a"
incoming = client.get("/api/friends/requests", headers=b["headers"])
assert incoming.status_code == 200, incoming.text
reqs = incoming.json()["requests"]
matching = [r for r in reqs if r["id"] == req_data["id"]]
assert len(matching) == 1
assert matching[0]["requester_username"] == "fr_uname_a"
class TestSelfAndDuplicateRejection: class TestSelfAndDuplicateRejection:
def test_self_request_rejected(self): def test_self_request_rejected(self):
@@ -315,3 +335,27 @@ class TestDataIsolation:
assert client.get("/api/friends/following").status_code == 401 assert client.get("/api/friends/following").status_code == 401
assert client.get("/api/friends/blocked").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 assert client.post("/api/friends/requests", json={"user_id": 1}).status_code == 401
class TestUserSearchAndLookup:
def test_search_and_lookup_by_username(self):
client = TestClient(app)
u1 = _register(client, "usr_srch_1")
u2 = _register(client, "usr_srch_2")
# Search excludes current user
search_res = client.get("/api/users/search?q=usr_srch", headers=u1["headers"])
assert search_res.status_code == 200, search_res.text
names = [u["username"] for u in search_res.json()]
assert "usr_srch_2" in names
assert "usr_srch_1" not in names
# Lookup by username
lookup_res = client.get("/api/users/by-username/usr_srch_2", headers=u1["headers"])
assert lookup_res.status_code == 200, lookup_res.text
assert lookup_res.json()["username"] == "usr_srch_2"
assert lookup_res.json()["id"] == u2["id"]
# Lookup 404 for unknown user
missing = client.get("/api/users/by-username/nonexistent_usr_xyz", headers=u1["headers"])
assert missing.status_code == 404
+27
View File
@@ -133,3 +133,30 @@ async def test_wallet_service_credit_and_debit_flow():
# Newest first: debit (-400) first, then credit (+1000) # Newest first: debit (-400) first, then credit (+1000)
assert tx_data["transactions"][0]["amount"] == -400 assert tx_data["transactions"][0]["amount"] == -400
assert tx_data["transactions"][1]["amount"] == 1000 assert tx_data["transactions"][1]["amount"] == 1000
def test_wallet_reward_endpoint():
client = TestClient(app)
uid, headers = _register(client, "wallet_reward_user")
# Initial balance is 0
resp = client.get("/api/wallet", headers=headers)
assert resp.status_code == 200
assert resp.json()["balance"] == 0
# Reward 25 Buffalo Chips
reward_resp = client.post(
"/api/wallet/reward",
json={"amount": 25, "reason": "found_chest"},
headers=headers,
)
assert reward_resp.status_code == 200
data = reward_resp.json()
assert data["success"] is True
assert data["amount"] == 25
assert data["new_balance"] == 25
# Check updated wallet
resp = client.get("/api/wallet", headers=headers)
assert resp.json()["balance"] == 25
+64
View File
@@ -0,0 +1,64 @@
# Package Checkpoint 4 (PC4) — "A world you can build and be social in"
> **Status: Complete & Verified.** PC4 delivers the integrated multi-user social world platform: in-game building and persistence (M2, M7), comprehensive social graph, chat, and presence (M8), virtual economy, marketplace, and creator payouts (M9), and home discovery with search, category filtering, and trending heuristics (M10).
Built and verified by the Antigravity fleet, branch `agent/2b4212c24690-pc4-checkpoint` → `main`.
---
## 1. What's Real and Verified (`scripts/run_checkpoint_tests.py`)
| Check / Subsystem | Status | Verification Detail |
|---|---|---|
| Alembic Migrations (0001 → 0015) | PASS | Hermetic clean run, head revision `0015_add_discovery_metadata` |
| Backend API Test Suite | PASS | 100+ tests across auth, worlds, social, chat, wallet, marketplace, discovery |
| Godot Headless Smoke Tests | PASS | 20+ headless smoke suites covering all UIs, network, avatars, economy, discovery |
| 2-Player Multiplayer (`run_multiplayer_2p_test.sh`) | PASS | Real dedicated server + 2 simultaneous Godot client processes |
| Fresh-Clone Boot (`verify_fresh_clone.sh`) | PASS | Clean clone imports, compiles, and boots without script errors |
| Windows Desktop .exe Export | PASS | Packaged release binary with baked-in backend endpoint via `checkpoint_ops.py` |
---
## 2. Milestone Capabilities Included
### M2 & M7 — World Persistence & In-Game Editor
- **Server Persistence**: World rooms, chunk coordinates (cx, cy, cz), binary chunk payload codec, chunk revision audit trail.
- **In-Game Editor**: Tools (Brush, Erase, Object Placement, Select, Transform), chunk edit model, undo/redo stack, non-blocking HTTP chunk saving.
### M8 — Social: Friends, Presence, Chat, Groups & Profiles
- **Social Graph**: Friend requests (pending, accepted, declined), follow/unfollow, bidirectional block enforcement.
- **Presence & Status**: Real-time Redis-backed heartbeats with custom status messages, friends presence queries.
- **Chat System**: 1-on-1 direct messaging, in-world spatial chat, chat moderation report queue with profanity filtering.
- **Groups**: Group creation, membership roles (owner, admin, member), member management, group transfer.
- **Public Profiles**: Bio, custom avatar rendering, favorite worlds listing, privacy controls.
- **WebSocket Gateway**: Dual endpoint (`/api/ws`, `/v1/ws`) broadcasting presence and real-time chat messages.
- **Godot Client & UIs**: `FriendsPresenceUI`, `ChatUI`, `ProfileUI`, `GroupsUI`.
### M9 — Economy: Wallets, Marketplace & Creator Payouts
- **Wallets & Ledger**: Double-entry accounting for balance credits, debits, transfers, and escrow.
- **Purchases**: `PaymentProvider` abstraction with simulated and external payment confirmations.
- **Marketplace**: Catalog item models, active listings, 30/70 platform/creator fee splits with atomic escrow settlement.
- **Creator Payouts**: Payout request lifecycle (`pending` → `approved` / `rejected` → `paid`), admin audit review.
- **Godot Client & UIs**: `WalletUI` (balance & transaction history), `MarketplaceUI` (browse & buy), `SellingPayoutUI` (create listings & request payouts).
### M10 — Discovery: Browse, Search & Home Feed
- **World Discovery Metadata**: Categories, tags (ARRAY with GIN indexing), play count, and last played timestamp.
- **Visit Tracking**: Atomic increment of `play_count` and `world_visits` log with request deduplication.
- **Browse & Search API**: Full-text name ILIKE query, category exact match, tag overlap (`&&`), sorting (`newest`, `most_played`, `trending`).
- **Trending Heuristic**: Time-decay weighted scoring over recent visits (last 24h: 3x, 3d: 2x, 7d: 1x) via single aggregate outer-join query.
- **Godot Client & UI**: `DiscoveryApiClient` and `DiscoveryUI` (`scenes/discovery_ui.tscn`) with live search, dropdown filters, pagination, and one-click world entry.
---
## 3. Playtest Guide
1. **Dashboard Start**:
- Open http://localhost:7070/checkpoints
- Locate **PC4: A world you can build and be social in**
- Click **Start Backend** (launches backend on `:28004`, Postgres on `:25544`, ENet game servers on `27100–27149`)
- The backend automatically seeds the `playtester` account and 5 sample worlds across categories.
2. **Download & Play**:
- Click **Download Windows .exe**
- Launch `agp-game.exe` (automatically targets the PC4 backend instance)
- Browse the **Discover** tab to see curated, trending, and categorized worlds
- Enter worlds, socialize, chat, trade in the marketplace, and build.
+8
View File
@@ -78,6 +78,14 @@ local catalog/loadout/loader logic. Backend-dependent tests read
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. | | `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. | | `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. | | `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
| `smoke_test_social_client.gd` | Headless verification of `SocialApiClient` methods for friends, presence heartbeats, profiles, groups, and chat. |
| `smoke_test_economy_client.gd` | Headless verification of `EconomyApiClient` methods for wallet, payments, catalog items, listings, and payouts. |
| `smoke_test_wallet_ui.gd` | Instantiates `scenes/wallet_ui.tscn`, verifies balance display, transaction history list rendering, and empty states. |
| `smoke_test_marketplace_ui.gd` | Instantiates `scenes/marketplace_ui.tscn`, verifies keyword/price/type filter controls, active listing cards rendering, and purchase flow. |
| `smoke_test_selling_payout_ui.gd` | Instantiates `scenes/selling_payout_ui.tscn`, verifies catalog item picker, listing creation, and payout request submission. |
| `smoke_test_discovery_client.gd` | Headless verification of `DiscoveryApiClient` typed resource parsing (`WorldSummary`, `DiscoveryResult`), and search/browse methods. |
| `smoke_test_discovery_ui.gd` | Instantiates `scenes/discovery_ui.tscn`, verifies search input, category dropdown, sort options (newest/most-played/trending), pagination controls, and world entry integration. |
Run (once, to build the class cache): `godot --headless --path . --import`, Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script then for each smoke test: `godot --headless --path . --script
+4 -4
View File
@@ -7,9 +7,9 @@ advanced_options=false
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="*.json" include_filter="*.json, *.glb, *.wav"
exclude_filter="" exclude_filter=""
export_path="export/linux/agp-game.x86_64" export_path="export/linux/modbison.x86_64"
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
encrypt_pck=false encrypt_pck=false
@@ -38,9 +38,9 @@ advanced_options=false
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="*.json" include_filter="*.json, *.glb, *.wav"
exclude_filter="" exclude_filter=""
export_path="export/windows/agp-game.exe" export_path="export/windows/modbison.exe"
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
encrypt_pck=false encrypt_pck=false
+6 -1
View File
@@ -6,7 +6,7 @@ config_version=5
[application] [application]
config/name="AGP-Game" config/name="ModBison"
run/main_scene="res://scenes/main_menu.tscn" run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6.2") config/features=PackedStringArray("4.6.2")
agp/api_base_url="http://127.0.0.1:8000" agp/api_base_url="http://127.0.0.1:8000"
@@ -14,6 +14,7 @@ agp/api_base_url="http://127.0.0.1:8000"
[autoload] [autoload]
ApiClient="*res://scripts/api_client.gd" ApiClient="*res://scripts/api_client.gd"
AudioManager="*res://scripts/audio_manager.gd"
AvatarSystem="*res://scripts/avatar/avatar_system.gd" AvatarSystem="*res://scripts/avatar/avatar_system.gd"
Session="*res://scripts/session.gd" Session="*res://scripts/session.gd"
@@ -21,7 +22,11 @@ Session="*res://scripts/session.gd"
window/size/viewport_width=1920 window/size/viewport_width=1920
window/size/viewport_height=1080 window/size/viewport_height=1080
window/size/mode=3
window/size/window_width_override=1280
window/size/window_height_override=720
window/stretch/mode="canvas_items" window/stretch/mode="canvas_items"
window/stretch/aspect="keep"
[input] [input]
+155
View File
@@ -0,0 +1,155 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/discovery_ui.gd" id="1_disc"]
[node name="DiscoveryUI" 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_disc")
[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 = 24.0
offset_right = -32.0
offset_bottom = -24.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Discover Experiences"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="WorldListButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(150, 36)
layout_mode = 2
text = "🏗️ World Browser"
[node name="StatusLabel" type="Label" parent="VBox"]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.3, 0.3, 1)
text = ""
[node name="Filters" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="SearchEdit" type="LineEdit" parent="VBox/Filters"]
custom_minimum_size = Vector2(240, 36)
layout_mode = 2
placeholder_text = "Search by name or tag..."
[node name="CategoryOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
[node name="SortOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(140, 36)
layout_mode = 2
[node name="FilterButton" type="Button" parent="VBox/Filters"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
text = "Filter"
[node name="Content" type="Control" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="LoadingLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
text = "Loading experiences..."
horizontal_alignment = 1
[node name="ErrorLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
text = ""
horizontal_alignment = 1
[node name="EmptyLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1)
text = "No experiences found."
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="VBox/Content"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="WorldsVBox" type="VBoxContainer" parent="VBox/Content/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 10
[node name="Pagination" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
alignment = 1
[node name="PrevButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "< Prev"
[node name="PageLabel" type="Label" parent="VBox/Pagination"]
layout_mode = 2
text = "Page 1 of 1"
horizontal_alignment = 1
[node name="NextButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "Next >"
+3 -3
View File
@@ -99,7 +99,7 @@ theme_override_constants/separation = 8
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"] [node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
placeholder_text = "Enter User ID to add..." placeholder_text = "Enter username or ID..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"] [node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
custom_minimum_size = Vector2(120, 32) custom_minimum_size = Vector2(120, 32)
@@ -128,7 +128,7 @@ theme_override_constants/separation = 8
[node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"] [node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
placeholder_text = "Enter User ID to follow..." placeholder_text = "Enter username or ID..."
[node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"] [node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
custom_minimum_size = Vector2(100, 32) custom_minimum_size = Vector2(100, 32)
@@ -157,7 +157,7 @@ theme_override_constants/separation = 8
[node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"] [node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
placeholder_text = "Enter User ID to block..." placeholder_text = "Enter username or ID..."
[node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"] [node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
custom_minimum_size = Vector2(100, 32) custom_minimum_size = Vector2(100, 32)
+68 -7
View File
@@ -25,7 +25,7 @@ size_flags_horizontal = 4
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"] [node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 1 layout_mode = 1
text = "AGP Game" text = "ModBison"
horizontal_alignment = 1 horizontal_alignment = 1
theme_override_font_sizes/font_size = 72 theme_override_font_sizes/font_size = 72
@@ -83,11 +83,12 @@ visible = false
placeholder_text = "Email" placeholder_text = "Email"
size_flags_horizontal = 3 size_flags_horizontal = 3
[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] [node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1 layout_mode = 1
visible = false visible = false
size_flags_horizontal = 3 size_flags_horizontal = 3
placeholder_text = "Date of birth (YYYY-MM-DD)" button_pressed = true
text = "I confirm I am 18 or older (TOS)"
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] [node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1 layout_mode = 1
@@ -107,26 +108,86 @@ visible = false
text = "" text = ""
horizontal_alignment = 1 horizontal_alignment = 1
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"] [node name="DiscoverButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1 layout_mode = 1
text = "Play" text = "🗺️ Discover Experiences"
size_flags_horizontal = 4 size_flags_horizontal = 4
disabled = true
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1
text = "🏗️ Worlds Browser & Editor"
size_flags_horizontal = 4
disabled = true
[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"] [node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1 layout_mode = 1
text = "Customize Avatar" text = "👤 Customize Avatar"
size_flags_horizontal = 4
disabled = true
[node name="SocialButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1
text = "👥 Friends & Presence"
size_flags_horizontal = 4
disabled = true
[node name="ChatButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1
text = "💬 Chat & Messages"
size_flags_horizontal = 4
disabled = true
[node name="MarketplaceButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1
text = "🛒 Marketplace"
size_flags_horizontal = 4
disabled = true
[node name="WalletButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1
text = "💰 Wallet (Buffalo Chips)"
size_flags_horizontal = 4
disabled = true
[node name="GroupsButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1
text = "🛡️ Groups & Clans"
size_flags_horizontal = 4
disabled = true
[node name="SellingButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1
text = "🏷️ Creator & Selling"
size_flags_horizontal = 4 size_flags_horizontal = 4
disabled = true disabled = true
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"] [node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(340, 38)
layout_mode = 1 layout_mode = 1
text = "Quit" text = "🚪 Quit"
size_flags_horizontal = 4 size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/DiscoverButton" to="." method="_on_discover_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/SocialButton" to="." method="_on_social_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/ChatButton" to="." method="_on_chat_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/MarketplaceButton" to="." method="_on_marketplace_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/WalletButton" to="." method="_on_wallet_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/GroupsButton" to="." method="_on_groups_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/SellingButton" to="." method="_on_selling_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"] [connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
+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 Buffalo Chips"
[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(110, 36)
layout_mode = 2
placeholder_text = "Min Chips"
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
custom_minimum_size = Vector2(110, 36)
layout_mode = 2
placeholder_text = "Max Chips"
[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
+3
View File
@@ -32,5 +32,8 @@ margin = 0.2
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"] [node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
current = true current = true
[node name="AudioListener3D" type="AudioListener3D" parent="CameraPivot/SpringArm3D/Camera3D"]
current = true
[node name="Inventory" type="Node" parent="."] [node name="Inventory" type="Node" parent="."]
script = ExtResource("2_inventory") script = ExtResource("2_inventory")
+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 (Buffalo Chips)"
[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 Buffalo Chips"
[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 Buffalo Chips"
[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 Buffalo Chips"
[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 = "Buffalo Chips (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
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/studio/studio_main.gd" id="1_studio"]
[node name="StudioMain" 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_studio")
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/studio/studio_start_screen.gd" id="1_start"]
[node name="StudioStartScreen" 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_start")
+7 -7
View File
@@ -37,7 +37,7 @@ text = "< Back"
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24 theme_override_font_sizes/font_size = 24
text = "Wallet & Credits" text = "Wallet & Buffalo Chips"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"] [node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
@@ -66,12 +66,12 @@ theme_override_constants/separation = 20
[node name="BalTitle" type="Label" parent="VBox/BalanceCard/Margin/HBox"] [node name="BalTitle" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 18 theme_override_font_sizes/font_size = 18
text = "Available Balance:" text = "Available Buffalo Chips:"
[node name="BalanceLabel" type="Label" parent="VBox/BalanceCard/Margin/HBox"] [node name="BalanceLabel" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 22 theme_override_font_sizes/font_size = 22
text = "0 CR" text = "0 Buffalo Chips"
[node name="Spacer" type="Control" parent="VBox/BalanceCard/Margin/HBox"] [node name="Spacer" type="Control" parent="VBox/BalanceCard/Margin/HBox"]
layout_mode = 2 layout_mode = 2
@@ -83,19 +83,19 @@ theme_override_constants/separation = 8
[node name="Buy100Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] [node name="Buy100Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2 layout_mode = 2
text = "+100 CR ($0.99)" text = "+100 Buffalo Chips ($0.99)"
[node name="Buy500Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] [node name="Buy500Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2 layout_mode = 2
text = "+500 CR ($4.99)" text = "+500 Buffalo Chips ($4.99)"
[node name="Buy1000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] [node name="Buy1000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2 layout_mode = 2
text = "+1,000 CR ($9.99)" text = "+1,000 Buffalo Chips ($9.99)"
[node name="Buy5000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] [node name="Buy5000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2 layout_mode = 2
text = "+5,000 CR ($44.99)" text = "+5,000 Buffalo Chips ($44.99)"
[node name="LedgerSection" type="VBoxContainer" parent="VBox"] [node name="LedgerSection" type="VBoxContainer" parent="VBox"]
layout_mode = 2 layout_mode = 2
+12
View File
@@ -36,6 +36,16 @@ horizontal_alignment = 1
size_flags_horizontal = 3 size_flags_horizontal = 3
theme_override_font_sizes/font_size = 28 theme_override_font_sizes/font_size = 28
[node name="DiscoverButton" type="Button" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "🗺️ Discover Feed"
size_flags_horizontal = 0
[node name="CreateButton" type="Button" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "+ Create World"
size_flags_horizontal = 0
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"] [node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3 layout_mode = 3
text = "Refresh" text = "Refresh"
@@ -75,4 +85,6 @@ horizontal_alignment = 1
theme_override_font_sizes/font_size = 18 theme_override_font_sizes/font_size = 18
[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"] [connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderHBox/DiscoverButton" to="." method="_on_discover_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderHBox/CreateButton" to="." method="_on_create_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"] [connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]

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