Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8019a1321c | |||
| 3f0ba543e1 | |||
| 721dbe5ae7 | |||
| 654c1ec6bf | |||
| 509e85b53e | |||
| ddcc0866b8 | |||
| a7de464e1f | |||
| 84b98e9b3a | |||
| bb65274a49 | |||
| 62fa0b0067 | |||
| 034a6901a9 | |||
| 7abf37ea81 | |||
| 7b693899a7 | |||
| ee4006d217 | |||
| 4c8a60fbc8 | |||
| f93e99f042 | |||
| 370395f1b6 | |||
| 2b355d0ed8 | |||
| 48aefe95ed | |||
| 7ecf0d53bd | |||
| 920354705a | |||
| 145af47068 | |||
| aa5f945287 | |||
| e05e18c384 | |||
| ea6948a9ba | |||
| 50598a0dcb | |||
| 7c77c0b447 | |||
| bbd9ffc46f | |||
| d2b62c5d25 | |||
| e54ff6eeb2 | |||
| 41d1f7438c | |||
| 73172b1e28 | |||
| 42520b33d5 | |||
| e6e22ac5f2 |
@@ -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/
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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')"`
|
||||||
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
|
||||||
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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,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")
|
||||||
+3
-1
@@ -7,7 +7,7 @@ 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, discovery,
|
chat_world, wallet, payments, marketplace, payouts, discovery,
|
||||||
)
|
)
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,7 +19,7 @@ from app.models.marketplace import (
|
|||||||
from app.models.world_visit import WorldVisit
|
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",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ class World(Base):
|
|||||||
)
|
)
|
||||||
category = Column(String(100), nullable=True, index=True)
|
category = Column(String(100), nullable=True, index=True)
|
||||||
tags = Column(ARRAY(String), nullable=True, server_default="{}")
|
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)
|
play_count = Column(Integer, nullable=False, server_default="0", index=True)
|
||||||
last_played_at = Column(DateTime(timezone=True), nullable=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__ = (
|
__table_args__ = (
|
||||||
Index("ix_worlds_tags", "tags", postgresql_using="gin"),
|
Index("ix_worlds_tags", "tags", postgresql_using="gin"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +157,10 @@ 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.
|
|
||||||
"""
|
"""
|
||||||
|
if req.date_of_birth is not None:
|
||||||
age = calculate_age(req.date_of_birth)
|
age = calculate_age(req.date_of_birth)
|
||||||
if age < 0 or req.date_of_birth > date.today():
|
if age < 0 or req.date_of_birth > date.today():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -174,6 +172,11 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
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=f"You must be at least {MIN_AGE} to use this platform",
|
||||||
)
|
)
|
||||||
|
elif not req.over_18:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You must confirm you are 18 or older to register",
|
||||||
|
)
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
result = await db.execute(select(User).where(User.username == req.username))
|
result = await db.execute(select(User).where(User.username == req.username))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ class DiscoveredWorld(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
|
content_guidelines: str | None = None
|
||||||
play_count: int = 0
|
play_count: int = 0
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
owner: WorldOwnerSummary
|
owner: WorldOwnerSummary
|
||||||
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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: int
|
||||||
|
if body.user_id is not None and body.user_id > 0:
|
||||||
target_id = body.user_id
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class WorldCreate(BaseModel):
|
|||||||
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
tags: list[str] | None = None
|
tags: list[str] | None = None
|
||||||
|
content_guidelines: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorldUpdate(BaseModel):
|
class WorldUpdate(BaseModel):
|
||||||
@@ -38,6 +39,7 @@ class WorldUpdate(BaseModel):
|
|||||||
visibility: WorldVisibility | None = None
|
visibility: WorldVisibility | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
tags: list[str] | None = None
|
tags: list[str] | None = None
|
||||||
|
content_guidelines: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorldResponse(BaseModel):
|
class WorldResponse(BaseModel):
|
||||||
@@ -48,6 +50,7 @@ class WorldResponse(BaseModel):
|
|||||||
visibility: WorldVisibility
|
visibility: WorldVisibility
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
tags: list[str] | None = None
|
tags: list[str] | None = None
|
||||||
|
content_guidelines: str | None = None
|
||||||
play_count: int = 0
|
play_count: int = 0
|
||||||
last_played_at: datetime | None = None
|
last_played_at: datetime | None = None
|
||||||
owner_id: int
|
owner_id: int
|
||||||
@@ -57,6 +60,7 @@ class WorldResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorldResponse])
|
@router.get("/", response_model=list[WorldResponse])
|
||||||
@@ -170,8 +174,10 @@ async def create_world(
|
|||||||
visibility=world_data.visibility,
|
visibility=world_data.visibility,
|
||||||
category=world_data.category,
|
category=world_data.category,
|
||||||
tags=world_data.tags or [],
|
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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -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
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ custom_minimum_size = Vector2(100, 36)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Refresh"
|
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"]
|
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ horizontal_alignment = 1
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 18
|
theme_override_font_sizes/font_size = 18
|
||||||
theme_override_colors/font_color = Color(1, 0.84, 0, 1)
|
theme_override_colors/font_color = Color(1, 0.84, 0, 1)
|
||||||
text = "Wallet: 0 CR"
|
text = "Wallet: 0 Buffalo Chips"
|
||||||
|
|
||||||
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||||
custom_minimum_size = Vector2(100, 36)
|
custom_minimum_size = Vector2(100, 36)
|
||||||
@@ -66,14 +66,14 @@ custom_minimum_size = Vector2(140, 36)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
|
||||||
[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
custom_minimum_size = Vector2(90, 36)
|
custom_minimum_size = Vector2(110, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
placeholder_text = "Min CR"
|
placeholder_text = "Min Chips"
|
||||||
|
|
||||||
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
custom_minimum_size = Vector2(90, 36)
|
custom_minimum_size = Vector2(110, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
placeholder_text = "Max CR"
|
placeholder_text = "Max Chips"
|
||||||
|
|
||||||
[node name="SearchBtn" type="Button" parent="VBox/FilterBar"]
|
[node name="SearchBtn" type="Button" parent="VBox/FilterBar"]
|
||||||
custom_minimum_size = Vector2(90, 36)
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ placeholder_text = "Description..."
|
|||||||
[node name="PriceInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
[node name="PriceInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||||
custom_minimum_size = Vector2(90, 36)
|
custom_minimum_size = Vector2(90, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
placeholder_text = "Price (CR)"
|
placeholder_text = "Price (Buffalo Chips)"
|
||||||
|
|
||||||
[node name="CreateBtn" type="Button" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
[node name="CreateBtn" type="Button" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||||
custom_minimum_size = Vector2(110, 36)
|
custom_minimum_size = Vector2(110, 36)
|
||||||
@@ -141,7 +141,7 @@ text = "Available to Payout:"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 18
|
theme_override_font_sizes/font_size = 18
|
||||||
theme_override_colors/font_color = Color(0.2, 0.9, 0.2, 1)
|
theme_override_colors/font_color = Color(0.2, 0.9, 0.2, 1)
|
||||||
text = "0 CR"
|
text = "0 Buffalo Chips"
|
||||||
|
|
||||||
[node name="PendTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
[node name="PendTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -151,7 +151,7 @@ text = "In Escrow:"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 18
|
theme_override_font_sizes/font_size = 18
|
||||||
theme_override_colors/font_color = Color(1, 0.8, 0.2, 1)
|
theme_override_colors/font_color = Color(1, 0.8, 0.2, 1)
|
||||||
text = "0 CR"
|
text = "0 Buffalo Chips"
|
||||||
|
|
||||||
[node name="LifeTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
[node name="LifeTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -161,7 +161,7 @@ text = "Lifetime Earned:"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 18
|
theme_override_font_sizes/font_size = 18
|
||||||
theme_override_colors/font_color = Color(0.4, 0.7, 1, 1)
|
theme_override_colors/font_color = Color(0.4, 0.7, 1, 1)
|
||||||
text = "0 CR"
|
text = "0 Buffalo Chips"
|
||||||
|
|
||||||
[node name="RequestBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
[node name="RequestBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -170,7 +170,7 @@ theme_override_constants/separation = 8
|
|||||||
[node name="AmountInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
[node name="AmountInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||||
custom_minimum_size = Vector2(130, 36)
|
custom_minimum_size = Vector2(130, 36)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
placeholder_text = "Amount (min 100)"
|
placeholder_text = "Buffalo Chips (min 100)"
|
||||||
|
|
||||||
[node name="DestInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
[node name="DestInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||||
custom_minimum_size = Vector2(220, 36)
|
custom_minimum_size = Vector2(220, 36)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
+141
-19
@@ -39,6 +39,10 @@ extends Node
|
|||||||
var _access_token: String = ""
|
var _access_token: String = ""
|
||||||
var _token_type: String = "Bearer"
|
var _token_type: String = "Bearer"
|
||||||
|
|
||||||
|
## Authenticated user identity
|
||||||
|
var current_user_id: int = 0
|
||||||
|
var current_username: String = ""
|
||||||
|
|
||||||
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
|
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
|
||||||
var bearer_token: String:
|
var bearer_token: String:
|
||||||
get:
|
get:
|
||||||
@@ -61,6 +65,8 @@ func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
|||||||
func clear_token() -> void:
|
func clear_token() -> void:
|
||||||
_access_token = ""
|
_access_token = ""
|
||||||
_token_type = "Bearer"
|
_token_type = "Bearer"
|
||||||
|
current_user_id = 0
|
||||||
|
current_username = ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -139,10 +145,11 @@ func request(
|
|||||||
body: String = "",
|
body: String = "",
|
||||||
content_type: String = "application/json",
|
content_type: String = "application/json",
|
||||||
require_auth: bool = false,
|
require_auth: bool = false,
|
||||||
custom_headers: PackedStringArray = PackedStringArray()
|
custom_headers: PackedStringArray = PackedStringArray(),
|
||||||
|
custom_timeout: float = 0.0
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var http := HTTPRequest.new()
|
var http := HTTPRequest.new()
|
||||||
http.timeout = timeout_seconds
|
http.timeout = custom_timeout if custom_timeout > 0.0 else timeout_seconds
|
||||||
http.max_redirects = 8
|
http.max_redirects = 8
|
||||||
http.accept_gzip = true
|
http.accept_gzip = true
|
||||||
add_child(http)
|
add_child(http)
|
||||||
@@ -188,7 +195,28 @@ func request(
|
|||||||
var detail := "HTTP error %d" % response_code
|
var detail := "HTTP error %d" % response_code
|
||||||
var parsed: Variant = _parse_json(json_string)
|
var parsed: Variant = _parse_json(json_string)
|
||||||
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
||||||
detail = str((parsed as Dictionary)["detail"])
|
var raw_detail = (parsed as Dictionary)["detail"]
|
||||||
|
if raw_detail is Array and not raw_detail.is_empty():
|
||||||
|
var parts: Array[String] = []
|
||||||
|
for val_err in raw_detail:
|
||||||
|
if val_err is Dictionary and val_err.has("msg"):
|
||||||
|
var loc_arr = val_err.get("loc", [])
|
||||||
|
var loc_str := ""
|
||||||
|
if loc_arr is Array:
|
||||||
|
var filtered_loc: Array[String] = []
|
||||||
|
for x in loc_arr:
|
||||||
|
if str(x) != "body":
|
||||||
|
filtered_loc.append(str(x))
|
||||||
|
loc_str = ".".join(filtered_loc)
|
||||||
|
if loc_str != "":
|
||||||
|
parts.append("%s: %s" % [loc_str, str(val_err.get("msg"))])
|
||||||
|
else:
|
||||||
|
parts.append(str(val_err.get("msg")))
|
||||||
|
else:
|
||||||
|
parts.append(str(val_err))
|
||||||
|
detail = ", ".join(parts)
|
||||||
|
else:
|
||||||
|
detail = str(raw_detail)
|
||||||
return _make_result(false, response_code, null, detail)
|
return _make_result(false, response_code, null, detail)
|
||||||
|
|
||||||
|
|
||||||
@@ -208,24 +236,41 @@ func login(username: String, password: String) -> Dictionary:
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
_store_token_from_result(result)
|
_store_token_from_result(result)
|
||||||
|
if result.ok:
|
||||||
|
current_username = username
|
||||||
|
var me := await get_me()
|
||||||
|
if me.ok and me.data is Dictionary:
|
||||||
|
current_user_id = int((me.data as Dictionary).get("id", 0))
|
||||||
|
current_username = str((me.data as Dictionary).get("username", username))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
## Register a new user. On success stores the returned access token.
|
## Register a new user. On success stores the returned access token.
|
||||||
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
|
## ModBison is an 18+ platform. Supports over_18 boolean or optional legacy DOB string.
|
||||||
## and rejects under-18s (403).
|
|
||||||
func register(
|
func register(
|
||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
date_of_birth: String
|
p4: Variant = true,
|
||||||
|
p5: String = ""
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var payload := {
|
var over_18: bool = true
|
||||||
|
var date_of_birth: String = ""
|
||||||
|
if p4 is bool:
|
||||||
|
over_18 = p4
|
||||||
|
date_of_birth = p5
|
||||||
|
elif p4 is String:
|
||||||
|
date_of_birth = p4
|
||||||
|
over_18 = true
|
||||||
|
|
||||||
|
var payload: Dictionary = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"date_of_birth": date_of_birth,
|
"over_18": over_18,
|
||||||
}
|
}
|
||||||
|
if not date_of_birth.is_empty():
|
||||||
|
payload["date_of_birth"] = date_of_birth
|
||||||
var result := await request(
|
var result := await request(
|
||||||
HTTPClient.METHOD_POST,
|
HTTPClient.METHOD_POST,
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
@@ -234,9 +279,17 @@ func register(
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
_store_token_from_result(result)
|
_store_token_from_result(result)
|
||||||
|
if result.ok:
|
||||||
|
current_username = username
|
||||||
|
var me := await get_me()
|
||||||
|
if me.ok and me.data is Dictionary:
|
||||||
|
current_user_id = int((me.data as Dictionary).get("id", 0))
|
||||||
|
current_username = str((me.data as Dictionary).get("username", username))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## List worlds visible to the current user (requires auth).
|
## List worlds visible to the current user (requires auth).
|
||||||
## @param visibility Optional filter: "public", "private", "friends_only".
|
## @param visibility Optional filter: "public", "private", "friends_only".
|
||||||
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
||||||
@@ -246,6 +299,21 @@ func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dict
|
|||||||
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Create a new world owned by the current user (requires auth).
|
||||||
|
## On success, result.data is the created World object containing {"id", "name", ...}.
|
||||||
|
func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = [], content_guidelines: String = "") -> Dictionary:
|
||||||
|
var payload: Dictionary = {
|
||||||
|
"name": world_name,
|
||||||
|
"description": description,
|
||||||
|
"visibility": visibility,
|
||||||
|
"category": category,
|
||||||
|
"tags": tags,
|
||||||
|
"content_guidelines": content_guidelines,
|
||||||
|
}
|
||||||
|
return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Ping the backend health endpoint (no auth).
|
## Ping the backend health endpoint (no auth).
|
||||||
func health_check() -> Dictionary:
|
func health_check() -> Dictionary:
|
||||||
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
||||||
@@ -253,7 +321,23 @@ func health_check() -> Dictionary:
|
|||||||
|
|
||||||
## Fetch the current user's profile from the stored JWT (requires auth).
|
## Fetch the current user's profile from the stored JWT (requires auth).
|
||||||
func get_me() -> Dictionary:
|
func get_me() -> Dictionary:
|
||||||
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
var result := await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
||||||
|
if result.ok and result.data is Dictionary:
|
||||||
|
current_user_id = int((result.data as Dictionary).get("id", 0))
|
||||||
|
current_username = str((result.data as Dictionary).get("username", current_username))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Search active users by username substring (requires auth).
|
||||||
|
func search_users(query: String, limit: int = 20) -> Dictionary:
|
||||||
|
var path := "/api/users/search?q=%s&limit=%d" % [query.uri_encode(), limit]
|
||||||
|
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Get active user summary by exact username (requires auth).
|
||||||
|
func get_user_by_username(username: String) -> Dictionary:
|
||||||
|
var path := "/api/users/by-username/%s" % username.strip_edges().uri_encode()
|
||||||
|
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
## Fetch the current user's saved avatar loadout (requires auth).
|
## Fetch the current user's saved avatar loadout (requires auth).
|
||||||
@@ -268,7 +352,7 @@ func get_avatar_loadout() -> Dictionary:
|
|||||||
## its address. On success result.data is {"world_id", "host", "port"}.
|
## its address. On success result.data is {"world_id", "host", "port"}.
|
||||||
## 503 if this backend has no game-server orchestration.
|
## 503 if this backend has no game-server orchestration.
|
||||||
func join_world(world_id: int) -> Dictionary:
|
func join_world(world_id: int) -> Dictionary:
|
||||||
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
|
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true, PackedStringArray(), 25.0)
|
||||||
|
|
||||||
|
|
||||||
## Save (replace) the current user's avatar loadout (requires auth).
|
## Save (replace) the current user's avatar loadout (requires auth).
|
||||||
@@ -283,9 +367,19 @@ func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
|
|||||||
# Social Features: Friends, Follow & Block
|
# Social Features: Friends, Follow & Block
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Send a friend request to target user_id.
|
## Send a friend request to target user_id (int) or username (String).
|
||||||
func send_friend_request(user_id: int) -> Dictionary:
|
func send_friend_request(target: Variant) -> Dictionary:
|
||||||
return await SocialApiClient.send_friend_request(self, user_id)
|
var payload: Dictionary = {}
|
||||||
|
if target is int:
|
||||||
|
payload = {"user_id": target}
|
||||||
|
elif target is String:
|
||||||
|
if (target as String).is_valid_int():
|
||||||
|
payload = {"user_id": int(target)}
|
||||||
|
else:
|
||||||
|
payload = {"username": (target as String).strip_edges()}
|
||||||
|
else:
|
||||||
|
payload = {"user_id": int(target)}
|
||||||
|
return await request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify(payload), "application/json", true)
|
||||||
|
|
||||||
## Get pending incoming friend requests.
|
## Get pending incoming friend requests.
|
||||||
func get_friend_requests() -> Dictionary:
|
func get_friend_requests() -> Dictionary:
|
||||||
@@ -307,9 +401,14 @@ func unfriend(user_id: int) -> Dictionary:
|
|||||||
func get_friends() -> Dictionary:
|
func get_friends() -> Dictionary:
|
||||||
return await SocialApiClient.get_friends(self)
|
return await SocialApiClient.get_friends(self)
|
||||||
|
|
||||||
## Follow a user.
|
## Follow a user by ID or username.
|
||||||
func follow_user(user_id: int) -> Dictionary:
|
func follow_user(target: Variant) -> Dictionary:
|
||||||
return await SocialApiClient.follow_user(self, user_id)
|
if target is String and not (target as String).is_valid_int():
|
||||||
|
var lookup := await get_user_by_username((target as String).strip_edges())
|
||||||
|
if not lookup.ok:
|
||||||
|
return lookup
|
||||||
|
target = int(lookup.data["id"])
|
||||||
|
return await request(HTTPClient.METHOD_POST, "/api/friends/%d/follow" % int(target), "", "application/json", true)
|
||||||
|
|
||||||
## Unfollow a user.
|
## Unfollow a user.
|
||||||
func unfollow_user(user_id: int) -> Dictionary:
|
func unfollow_user(user_id: int) -> Dictionary:
|
||||||
@@ -319,9 +418,14 @@ func unfollow_user(user_id: int) -> Dictionary:
|
|||||||
func get_following() -> Dictionary:
|
func get_following() -> Dictionary:
|
||||||
return await SocialApiClient.get_following(self)
|
return await SocialApiClient.get_following(self)
|
||||||
|
|
||||||
## Block a user.
|
## Block a user by ID or username.
|
||||||
func block_user(user_id: int) -> Dictionary:
|
func block_user(target: Variant) -> Dictionary:
|
||||||
return await SocialApiClient.block_user(self, user_id)
|
if target is String and not (target as String).is_valid_int():
|
||||||
|
var lookup := await get_user_by_username((target as String).strip_edges())
|
||||||
|
if not lookup.ok:
|
||||||
|
return lookup
|
||||||
|
target = int(lookup.data["id"])
|
||||||
|
return await request(HTTPClient.METHOD_POST, "/api/friends/%d/block" % int(target), "", "application/json", true)
|
||||||
|
|
||||||
## Unblock a user.
|
## Unblock a user.
|
||||||
func unblock_user(user_id: int) -> Dictionary:
|
func unblock_user(user_id: int) -> Dictionary:
|
||||||
@@ -456,6 +560,24 @@ func report_message(message_id: int, conversation_type: String, reason: String,
|
|||||||
return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text)
|
return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text)
|
||||||
|
|
||||||
|
|
||||||
|
## Submit a moderation report for a world, user, or chat message.
|
||||||
|
func submit_moderation_report(target_type: String, target_id: int, reason: String, category: String = "general") -> Dictionary:
|
||||||
|
var payload := {
|
||||||
|
"target_type": target_type,
|
||||||
|
"target_id": target_id,
|
||||||
|
"reason": reason,
|
||||||
|
"category": category,
|
||||||
|
}
|
||||||
|
return await request(
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
"/api/moderation/reports",
|
||||||
|
JSON.stringify(payload),
|
||||||
|
"application/json",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Discovery Features
|
# Discovery Features
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
extends Node
|
||||||
|
## Central Audio Manager singleton (autoload name: "AudioManager").
|
||||||
|
##
|
||||||
|
## Pre-warms an AudioStreamPlayer pool for UI and game SFX.
|
||||||
|
## Provides convenience methods for standard sound effects and
|
||||||
|
## automatically hooks UI buttons for tactile click audio feedback.
|
||||||
|
|
||||||
|
const STREAM_CLICK: AudioStream = preload("res://assets/audio/click.wav")
|
||||||
|
const STREAM_COIN: AudioStream = preload("res://assets/audio/coin.wav")
|
||||||
|
const STREAM_CHIME: AudioStream = preload("res://assets/audio/chime.wav")
|
||||||
|
const STREAM_FANFARE: AudioStream = preload("res://assets/audio/fanfare.wav")
|
||||||
|
const STREAM_BELL: AudioStream = preload("res://assets/audio/bell.wav")
|
||||||
|
const STREAM_DOOR: AudioStream = preload("res://assets/audio/door_open.wav")
|
||||||
|
|
||||||
|
const POOL_SIZE: int = 16
|
||||||
|
const SETTINGS_FILE := "user://audio_settings.json"
|
||||||
|
|
||||||
|
signal sound_toggled(enabled: bool)
|
||||||
|
|
||||||
|
var _players: Array[AudioStreamPlayer] = []
|
||||||
|
var _player_index: int = 0
|
||||||
|
|
||||||
|
## Whether sound effects and audio output are enabled.
|
||||||
|
var sound_enabled: bool = true
|
||||||
|
|
||||||
|
## Number of active AudioStreamPlayers in the pool.
|
||||||
|
var pool_size: int:
|
||||||
|
get:
|
||||||
|
return _players.size()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||||
|
_init_pool()
|
||||||
|
_load_settings()
|
||||||
|
var tree := get_tree()
|
||||||
|
if tree != null:
|
||||||
|
tree.node_added.connect(_on_node_added)
|
||||||
|
_hook_existing_buttons(tree.root)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_settings() -> void:
|
||||||
|
if FileAccess.file_exists(SETTINGS_FILE):
|
||||||
|
var file := FileAccess.open(SETTINGS_FILE, FileAccess.READ)
|
||||||
|
if file != null:
|
||||||
|
var text := file.get_as_text()
|
||||||
|
var parsed = JSON.parse_string(text)
|
||||||
|
if parsed is Dictionary and parsed.has("sound_enabled"):
|
||||||
|
set_sound_enabled(bool(parsed["sound_enabled"]), false)
|
||||||
|
return
|
||||||
|
set_sound_enabled(true, false)
|
||||||
|
|
||||||
|
|
||||||
|
func _save_settings() -> void:
|
||||||
|
var file := FileAccess.open(SETTINGS_FILE, FileAccess.WRITE)
|
||||||
|
if file != null:
|
||||||
|
file.store_string(JSON.stringify({"sound_enabled": sound_enabled}))
|
||||||
|
|
||||||
|
|
||||||
|
## Check whether sound is currently enabled.
|
||||||
|
func is_sound_enabled() -> bool:
|
||||||
|
return sound_enabled
|
||||||
|
|
||||||
|
|
||||||
|
## Enable or disable sound output.
|
||||||
|
func set_sound_enabled(enabled: bool, save: bool = true) -> void:
|
||||||
|
sound_enabled = enabled
|
||||||
|
if not sound_enabled:
|
||||||
|
for player in _players:
|
||||||
|
if player != null and is_instance_valid(player) and player.playing:
|
||||||
|
player.stop()
|
||||||
|
var master_idx := AudioServer.get_bus_index("Master")
|
||||||
|
if master_idx >= 0:
|
||||||
|
AudioServer.set_bus_mute(master_idx, not sound_enabled)
|
||||||
|
if save:
|
||||||
|
_save_settings()
|
||||||
|
sound_toggled.emit(sound_enabled)
|
||||||
|
|
||||||
|
|
||||||
|
## Toggle sound between ON and OFF. Returns new state.
|
||||||
|
func toggle_sound() -> bool:
|
||||||
|
set_sound_enabled(not sound_enabled)
|
||||||
|
if sound_enabled:
|
||||||
|
play_click()
|
||||||
|
return sound_enabled
|
||||||
|
|
||||||
|
|
||||||
|
func _init_pool() -> void:
|
||||||
|
if not _players.is_empty():
|
||||||
|
return
|
||||||
|
for i in range(POOL_SIZE):
|
||||||
|
var player := AudioStreamPlayer.new()
|
||||||
|
player.name = "AudioPlayer_%d" % i
|
||||||
|
player.bus = "Master"
|
||||||
|
add_child(player)
|
||||||
|
_players.append(player)
|
||||||
|
|
||||||
|
|
||||||
|
## Play an audio stream on the specified bus with volume and pitch settings.
|
||||||
|
func play_sound(stream: AudioStream, bus: String = "Master", volume_db: float = 0.0, pitch_scale: float = 1.0) -> AudioStreamPlayer:
|
||||||
|
if not sound_enabled:
|
||||||
|
return null
|
||||||
|
if stream == null:
|
||||||
|
return null
|
||||||
|
if _players.is_empty():
|
||||||
|
_init_pool()
|
||||||
|
|
||||||
|
var player: AudioStreamPlayer = _players[_player_index]
|
||||||
|
_player_index = (_player_index + 1) % POOL_SIZE
|
||||||
|
|
||||||
|
if player.playing:
|
||||||
|
player.stop()
|
||||||
|
|
||||||
|
player.stream = stream
|
||||||
|
player.bus = bus
|
||||||
|
player.volume_db = volume_db
|
||||||
|
player.pitch_scale = pitch_scale
|
||||||
|
player.play()
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
## Play UI button click with subtle random pitch variance.
|
||||||
|
func play_click(volume_db: float = 0.0, pitch_variance: float = 0.05) -> AudioStreamPlayer:
|
||||||
|
var pitch := 1.0
|
||||||
|
if pitch_variance > 0.0:
|
||||||
|
pitch += randf_range(-pitch_variance, pitch_variance)
|
||||||
|
return play_sound(STREAM_CLICK, "Master", volume_db, pitch)
|
||||||
|
|
||||||
|
|
||||||
|
## Play coin transaction sound (credits / marketplace / payouts).
|
||||||
|
func play_coin(volume_db: float = 0.0) -> AudioStreamPlayer:
|
||||||
|
return play_sound(STREAM_COIN, "Master", volume_db, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
## Play chime sound (successful world / chunk save).
|
||||||
|
func play_chime(volume_db: float = 0.0) -> AudioStreamPlayer:
|
||||||
|
return play_sound(STREAM_CHIME, "Master", volume_db, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
## Play victory / achievement fanfare.
|
||||||
|
func play_fanfare(volume_db: float = 0.0) -> AudioStreamPlayer:
|
||||||
|
return play_sound(STREAM_FANFARE, "Master", volume_db, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
## Play alert bell sound.
|
||||||
|
func play_bell(volume_db: float = 0.0) -> AudioStreamPlayer:
|
||||||
|
return play_sound(STREAM_BELL, "Master", volume_db, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
## Play door open transition sound.
|
||||||
|
func play_door(volume_db: float = 0.0) -> AudioStreamPlayer:
|
||||||
|
return play_sound(STREAM_DOOR, "Master", volume_db, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Universal Tactile UI Click Wiring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_node_added(node: Node) -> void:
|
||||||
|
if node is BaseButton:
|
||||||
|
_hook_button(node as BaseButton)
|
||||||
|
|
||||||
|
|
||||||
|
func _hook_existing_buttons(node: Node) -> void:
|
||||||
|
if node is BaseButton:
|
||||||
|
_hook_button(node as BaseButton)
|
||||||
|
for child in node.get_children():
|
||||||
|
_hook_existing_buttons(child)
|
||||||
|
|
||||||
|
|
||||||
|
func _hook_button(btn: BaseButton) -> void:
|
||||||
|
var callable := Callable(self, "_on_button_pressed").bind(btn)
|
||||||
|
if not btn.pressed.is_connected(callable):
|
||||||
|
btn.pressed.connect(callable)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_button_pressed(btn: BaseButton) -> void:
|
||||||
|
if not sound_enabled:
|
||||||
|
return
|
||||||
|
if not is_instance_valid(btn):
|
||||||
|
return
|
||||||
|
if btn.is_in_group("no_click_sound"):
|
||||||
|
return
|
||||||
|
if btn.is_visible_in_tree() and not btn.disabled:
|
||||||
|
play_click()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -191,13 +191,42 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
|||||||
# skeleton; static props (the common case) are unaffected.
|
# skeleton; static props (the common case) are unaffected.
|
||||||
_retarget_skin(attachment_node)
|
_retarget_skin(attachment_node)
|
||||||
|
|
||||||
# Mount to the appropriate bone via a BoneAttachment3D so it follows the
|
# Check if this attachment has multi-bone children (e.g. Attach_LeftUpperLeg, Attach_Hips).
|
||||||
# skeleton. Falls back to a plain skeleton child if the mount bone is
|
var multi_bone_children: Array[Node] = []
|
||||||
# missing (a bad catalog entry) rather than dropping the attachment.
|
for child in attachment_node.get_children():
|
||||||
|
var cname := child.name
|
||||||
|
if cname.begins_with("Attach_") or cname.begins_with("attach_"):
|
||||||
|
var target_bone := cname.substr(7)
|
||||||
|
if skeleton.find_bone(target_bone) != -1:
|
||||||
|
multi_bone_children.append(child)
|
||||||
|
|
||||||
|
var mount_node: Node3D
|
||||||
|
if not multi_bone_children.is_empty():
|
||||||
|
var created_bas: Array[BoneAttachment3D] = []
|
||||||
|
for child in multi_bone_children:
|
||||||
|
var target_bone := child.name.substr(7)
|
||||||
|
var ba := BoneAttachment3D.new()
|
||||||
|
ba.name = "Mount_%s_%s" % [slot_id, target_bone]
|
||||||
|
ba.bone_name = target_bone
|
||||||
|
skeleton.add_child(ba)
|
||||||
|
attachment_node.remove_child(child)
|
||||||
|
ba.add_child(child)
|
||||||
|
created_bas.append(ba)
|
||||||
|
|
||||||
|
skeleton.add_child(attachment_node)
|
||||||
|
attachment_node.set_meta("bone_attachments", created_bas)
|
||||||
|
attachment_node.tree_exiting.connect(func():
|
||||||
|
for ba in created_bas:
|
||||||
|
if is_instance_valid(ba) and not ba.is_queued_for_deletion():
|
||||||
|
ba.queue_free()
|
||||||
|
)
|
||||||
|
mount_node = attachment_node
|
||||||
|
else:
|
||||||
|
# Single mount bone via a BoneAttachment3D so it follows the skeleton.
|
||||||
|
# Falls back to a plain skeleton child if the mount bone is missing.
|
||||||
var mount_bone: String = asset_meta.get("mount_bone", "")
|
var mount_bone: String = asset_meta.get("mount_bone", "")
|
||||||
if mount_bone.is_empty():
|
if mount_bone.is_empty():
|
||||||
mount_bone = _slot_to_mount_bone(slot_id)
|
mount_bone = _slot_to_mount_bone(slot_id)
|
||||||
var mount_node: Node3D
|
|
||||||
if not mount_bone.is_empty() and skeleton.find_bone(mount_bone) != -1:
|
if not mount_bone.is_empty() and skeleton.find_bone(mount_bone) != -1:
|
||||||
var ba := BoneAttachment3D.new()
|
var ba := BoneAttachment3D.new()
|
||||||
ba.name = "Mount_%s" % slot_id if slot_id != "accessory" else "Mount_accessory_%s" % asset_id
|
ba.name = "Mount_%s" % slot_id if slot_id != "accessory" else "Mount_accessory_%s" % asset_id
|
||||||
@@ -228,11 +257,19 @@ func unload_attachment(slot_id: String) -> void:
|
|||||||
if slot_id == "accessory":
|
if slot_id == "accessory":
|
||||||
for node in _attachments.get("accessory", []):
|
for node in _attachments.get("accessory", []):
|
||||||
if is_instance_valid(node):
|
if is_instance_valid(node):
|
||||||
|
if node.has_meta("bone_attachments"):
|
||||||
|
for ba in node.get_meta("bone_attachments"):
|
||||||
|
if is_instance_valid(ba):
|
||||||
|
ba.queue_free()
|
||||||
node.queue_free()
|
node.queue_free()
|
||||||
_attachments.erase("accessory")
|
_attachments.erase("accessory")
|
||||||
else:
|
else:
|
||||||
var node = _attachments.get(slot_id)
|
var node = _attachments.get(slot_id)
|
||||||
if is_instance_valid(node):
|
if is_instance_valid(node):
|
||||||
|
if node.has_meta("bone_attachments"):
|
||||||
|
for ba in node.get_meta("bone_attachments"):
|
||||||
|
if is_instance_valid(ba):
|
||||||
|
ba.queue_free()
|
||||||
node.queue_free()
|
node.queue_free()
|
||||||
_attachments.erase(slot_id)
|
_attachments.erase(slot_id)
|
||||||
|
|
||||||
|
|||||||
@@ -65,16 +65,13 @@ func _init() -> void:
|
|||||||
# Serialization
|
# Serialization
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Create a loadout from a JSON string.
|
## Create a loadout from a Dictionary.
|
||||||
static func from_json(json_string: String) -> AvatarLoadout:
|
static func from_dict(root: Dictionary) -> AvatarLoadout:
|
||||||
var loadout := _Self.new()
|
var loadout := _Self.new()
|
||||||
var data: Variant = JSON.parse_string(json_string)
|
if root == null:
|
||||||
if not (data is Dictionary):
|
loadout.validation_errors.append("Loadout root is null")
|
||||||
loadout.validation_errors.append("Loadout JSON root is not an object")
|
|
||||||
return loadout
|
return loadout
|
||||||
|
|
||||||
var root: Dictionary = data as Dictionary
|
|
||||||
|
|
||||||
# Parse base_body.
|
# Parse base_body.
|
||||||
if root.has("base_body"):
|
if root.has("base_body"):
|
||||||
var bb = root["base_body"]
|
var bb = root["base_body"]
|
||||||
@@ -111,8 +108,18 @@ static func from_json(json_string: String) -> AvatarLoadout:
|
|||||||
return loadout
|
return loadout
|
||||||
|
|
||||||
|
|
||||||
## Serialize this loadout to a JSON string.
|
## Create a loadout from a JSON string.
|
||||||
func to_json() -> String:
|
static func from_json(json_string: String) -> AvatarLoadout:
|
||||||
|
var data: Variant = JSON.parse_string(json_string)
|
||||||
|
if not (data is Dictionary):
|
||||||
|
var loadout := _Self.new()
|
||||||
|
loadout.validation_errors.append("Loadout JSON root is not an object")
|
||||||
|
return loadout
|
||||||
|
return from_dict(data as Dictionary)
|
||||||
|
|
||||||
|
|
||||||
|
## Serialize this loadout to a Dictionary.
|
||||||
|
func to_dict() -> Dictionary:
|
||||||
var data := {
|
var data := {
|
||||||
"base_body": base_body,
|
"base_body": base_body,
|
||||||
"slots": {},
|
"slots": {},
|
||||||
@@ -133,7 +140,12 @@ func to_json() -> String:
|
|||||||
"a": skin_tone.a,
|
"a": skin_tone.a,
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(data, " ")
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
## Serialize this loadout to a JSON string.
|
||||||
|
func to_json() -> String:
|
||||||
|
return JSON.stringify(to_dict(), " ")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -249,14 +249,42 @@ func has_loadout() -> bool:
|
|||||||
func ensure_catalog() -> bool:
|
func ensure_catalog() -> bool:
|
||||||
if is_catalog_loaded:
|
if is_catalog_loaded:
|
||||||
return true
|
return true
|
||||||
return load_catalog() == OK
|
var ok := load_catalog() == OK
|
||||||
|
if ok and current_loadout == null:
|
||||||
|
_try_load_cached_loadout()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
func _try_load_cached_loadout() -> void:
|
||||||
|
if FileAccess.file_exists("user://loadout.json"):
|
||||||
|
var f := FileAccess.open("user://loadout.json", FileAccess.READ)
|
||||||
|
if f != null:
|
||||||
|
var txt := f.get_as_text()
|
||||||
|
f.close()
|
||||||
|
var dict: Variant = JSON.parse_string(txt)
|
||||||
|
if dict is Dictionary:
|
||||||
|
set_loadout_from_dict(dict as Dictionary)
|
||||||
|
|
||||||
|
|
||||||
|
## Return the current loadout as a dictionary. If none is set, returns a default
|
||||||
|
## loadout using the first available base body, or {} if catalog is empty.
|
||||||
|
func get_current_loadout_dict() -> Dictionary:
|
||||||
|
if current_loadout != null:
|
||||||
|
return current_loadout.to_dict()
|
||||||
|
ensure_catalog()
|
||||||
|
if current_loadout != null:
|
||||||
|
return current_loadout.to_dict()
|
||||||
|
var bases := get_assets_for_slot("base_body")
|
||||||
|
if not bases.is_empty():
|
||||||
|
return {"base_body": bases[0], "slots": {}}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
## Set the current player's loadout from the dict shape the backend's
|
## Set the current player's loadout from the dict shape the backend's
|
||||||
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
|
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
|
||||||
## Returns true if it validated and was stored.
|
## Returns true if it validated and was stored.
|
||||||
func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
|
func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
|
||||||
var lo := AvatarLoadout.from_json(JSON.stringify(loadout_dict))
|
var lo := AvatarLoadout.from_dict(loadout_dict)
|
||||||
if not lo.validate():
|
if not lo.validate():
|
||||||
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
|
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
|
||||||
% str(lo.validation_errors))
|
% str(lo.validation_errors))
|
||||||
@@ -287,24 +315,35 @@ func build_current_avatar() -> Node3D:
|
|||||||
## Assemble a standalone avatar node from [param loadout] using a throwaway
|
## Assemble a standalone avatar node from [param loadout] using a throwaway
|
||||||
## loader -- unlike load_avatar(), this touches no shared state, so it's safe
|
## loader -- unlike load_avatar(), this touches no shared state, so it's safe
|
||||||
## to call many times (local player + one per remote peer). Returns null only
|
## to call many times (local player + one per remote peer). Returns null only
|
||||||
## if the assets are genuinely missing.
|
## if the assets are genuinely missing. Accepts either AvatarLoadout or Dictionary.
|
||||||
func assemble_avatar(loadout) -> Node3D:
|
func assemble_avatar(loadout) -> Node3D:
|
||||||
if loadout == null or not is_catalog_loaded:
|
if loadout == null:
|
||||||
return null
|
return null
|
||||||
var validation := catalog.validate_loadout(loadout)
|
if not ensure_catalog():
|
||||||
|
return null
|
||||||
|
|
||||||
|
var lo: AvatarLoadout = null
|
||||||
|
if loadout is AvatarLoadout:
|
||||||
|
lo = loadout
|
||||||
|
elif loadout is Dictionary:
|
||||||
|
lo = AvatarLoadout.from_dict(loadout as Dictionary)
|
||||||
|
else:
|
||||||
|
return null
|
||||||
|
|
||||||
|
var validation := catalog.validate_loadout(lo)
|
||||||
if not validation.valid:
|
if not validation.valid:
|
||||||
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
|
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
var l = AvatarLoader.new()
|
var l = AvatarLoader.new()
|
||||||
l.init_loader(catalog)
|
l.init_loader(catalog)
|
||||||
var base := l.load_base_body(loadout.base_body)
|
var base := l.load_base_body(lo.base_body)
|
||||||
if base == null:
|
if base == null:
|
||||||
l.free()
|
l.free()
|
||||||
return null
|
return null
|
||||||
|
|
||||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
var v = loadout.slots.get(slot_id)
|
var v = lo.slots.get(slot_id)
|
||||||
if slot_id == "accessory":
|
if slot_id == "accessory":
|
||||||
if v is Array:
|
if v is Array:
|
||||||
for aid in v:
|
for aid in v:
|
||||||
@@ -314,11 +353,11 @@ func assemble_avatar(loadout) -> Node3D:
|
|||||||
l.load_attachment(slot_id, v)
|
l.load_attachment(slot_id, v)
|
||||||
|
|
||||||
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
||||||
var v = loadout.slots.get(slot_id)
|
var v = lo.slots.get(slot_id)
|
||||||
if v is String and not (v as String).is_empty():
|
if v is String and not (v as String).is_empty():
|
||||||
l.set_base_mesh_visible(slot_id, false)
|
l.set_base_mesh_visible(slot_id, false)
|
||||||
|
|
||||||
l.apply_skin_tone(loadout.skin_tone)
|
l.apply_skin_tone(lo.skin_tone)
|
||||||
|
|
||||||
l.free()
|
l.free()
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ extends Node
|
|||||||
|
|
||||||
const STRIDE := 0.42 ## peak hip swing, radians (~24 deg)
|
const STRIDE := 0.42 ## peak hip swing, radians (~24 deg)
|
||||||
const ARM_SWING := 0.32 ## peak shoulder swing, radians
|
const ARM_SWING := 0.32 ## peak shoulder swing, radians
|
||||||
const KNEE := 0.5 ## peak knee bend, radians
|
const KNEE := 0.0 ## peak knee bend, radians (0.0 for rigid placeholder limbs so feet/shoes don't dislocate)
|
||||||
const CYCLE := 1.9 ## phase (radians) advanced per metre travelled
|
const CYCLE := 1.9 ## phase (radians) advanced per metre travelled
|
||||||
const BLEND := 12.0 ## ease rate toward the target pose
|
const BLEND := 12.0 ## ease rate toward the target pose
|
||||||
const MOVE_EPS := 0.35 ## m/s below which we treat the avatar as idle
|
const MOVE_EPS := 0.35 ## m/s below which we treat the avatar as idle
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ extends Control
|
|||||||
## Full-screen host for the AvatarCustomizationUI.
|
## Full-screen host for the AvatarCustomizationUI.
|
||||||
##
|
##
|
||||||
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
|
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
|
||||||
## layout programmatically and emits `closed` when the player backs out; this
|
## layout programmatically, auto-saves on exit, and emits `closed` / `back_requested`
|
||||||
## thin wrapper is what makes it a navigable scene reachable from the main menu.
|
## when the player backs out. This thin wrapper hosts it as a navigable scene
|
||||||
|
## reachable from the main menu and handles ESC / back transitions.
|
||||||
|
|
||||||
const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd")
|
const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd")
|
||||||
|
|
||||||
@@ -18,3 +19,13 @@ func _ready() -> void:
|
|||||||
|
|
||||||
func _on_closed() -> void:
|
func _on_closed() -> void:
|
||||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
var ui = get_node_or_null("AvatarCustomizationUI")
|
||||||
|
if ui != null and ui.has_method("save_and_exit"):
|
||||||
|
ui.save_and_exit()
|
||||||
|
else:
|
||||||
|
_on_closed()
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@
|
|||||||
# - godot on PATH (or set GODOT_BIN below)
|
# - godot on PATH (or set GODOT_BIN below)
|
||||||
#
|
#
|
||||||
# Usage: scripts/build.sh
|
# Usage: scripts/build.sh
|
||||||
# Output: export/linux/agp-game.x86_64, export/windows/agp-game.exe
|
# Output: export/linux/modbison.x86_64, export/windows/modbison.exe
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -32,10 +32,10 @@ echo "Godot version: $("$GODOT_BIN" --version)"
|
|||||||
mkdir -p export/linux export/windows
|
mkdir -p export/linux export/windows
|
||||||
|
|
||||||
echo "=== Exporting Linux/X11 ==="
|
echo "=== Exporting Linux/X11 ==="
|
||||||
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/agp-game.x86_64
|
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/modbison.x86_64
|
||||||
|
|
||||||
echo "=== Exporting Windows Desktop ==="
|
echo "=== Exporting Windows Desktop ==="
|
||||||
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/agp-game.exe
|
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/modbison.exe
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Build artifacts:"
|
echo "Build artifacts:"
|
||||||
|
|||||||
+7
-1
@@ -99,6 +99,12 @@ func _on_back_pressed() -> void:
|
|||||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
_on_back_pressed()
|
||||||
|
|
||||||
|
|
||||||
func show_message(text: String, is_error: bool = false) -> void:
|
func show_message(text: String, is_error: bool = false) -> void:
|
||||||
if status_label:
|
if status_label:
|
||||||
status_label.text = text
|
status_label.text = text
|
||||||
@@ -274,7 +280,7 @@ func _add_message_row(container: VBoxContainer, msg_data: Dictionary, conv_type:
|
|||||||
elif msg_data.has("sender") and msg_data["sender"] is Dictionary:
|
elif msg_data.has("sender") and msg_data["sender"] is Dictionary:
|
||||||
author = str(msg_data["sender"].get("username", author))
|
author = str(msg_data["sender"].get("username", author))
|
||||||
|
|
||||||
var content: String = str(msg_data.get("content", ""))
|
var content: String = str(msg_data.get("content", msg_data.get("body", "")))
|
||||||
|
|
||||||
var label := Label.new()
|
var label := Label.new()
|
||||||
label.text = "[%s]: %s" % [author, content]
|
label.text = "[%s]: %s" % [author, content]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user