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)
|
||||
*.uid
|
||||
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",
|
||||
"mount_bone": "HeadTop"
|
||||
},
|
||||
"hair_waterbuffalo_01": {
|
||||
"type": "attachment",
|
||||
"slot": "hair",
|
||||
"path": "res://assets/avatars/hair_waterbuffalo_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Water Buffalo Lodge Hat",
|
||||
"mount_bone": "HeadTop"
|
||||
},
|
||||
"face_glasses_01": {
|
||||
"type": "attachment",
|
||||
"slot": "face",
|
||||
@@ -71,6 +79,14 @@
|
||||
"display_name": "Left Glove",
|
||||
"mount_bone": "LeftHand"
|
||||
},
|
||||
"left_hand_wristband_01": {
|
||||
"type": "attachment",
|
||||
"slot": "left_hand",
|
||||
"path": "res://assets/avatars/left_hand_wristband_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Left Gold Band",
|
||||
"mount_bone": "LeftHand"
|
||||
},
|
||||
"right_hand_glove_01": {
|
||||
"type": "attachment",
|
||||
"slot": "right_hand",
|
||||
@@ -79,6 +95,14 @@
|
||||
"display_name": "Right Glove",
|
||||
"mount_bone": "RightHand"
|
||||
},
|
||||
"right_hand_wristband_01": {
|
||||
"type": "attachment",
|
||||
"slot": "right_hand",
|
||||
"path": "res://assets/avatars/right_hand_wristband_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Right Gold Band",
|
||||
"mount_bone": "RightHand"
|
||||
},
|
||||
"left_foot_boot_01": {
|
||||
"type": "attachment",
|
||||
"slot": "left_foot",
|
||||
@@ -87,6 +111,14 @@
|
||||
"display_name": "Left Boot",
|
||||
"mount_bone": "LeftFoot"
|
||||
},
|
||||
"left_foot_sneaker_01": {
|
||||
"type": "attachment",
|
||||
"slot": "left_foot",
|
||||
"path": "res://assets/avatars/left_foot_sneaker_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Left Sneaker",
|
||||
"mount_bone": "LeftFoot"
|
||||
},
|
||||
"right_foot_boot_01": {
|
||||
"type": "attachment",
|
||||
"slot": "right_foot",
|
||||
@@ -95,6 +127,14 @@
|
||||
"display_name": "Right Boot",
|
||||
"mount_bone": "RightFoot"
|
||||
},
|
||||
"right_foot_sneaker_01": {
|
||||
"type": "attachment",
|
||||
"slot": "right_foot",
|
||||
"path": "res://assets/avatars/right_foot_sneaker_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Right Sneaker",
|
||||
"mount_bone": "RightFoot"
|
||||
},
|
||||
"accessory_belt_01": {
|
||||
"type": "attachment",
|
||||
"slot": "accessory",
|
||||
|
||||
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",
|
||||
"category": "interactables"
|
||||
},
|
||||
"buffalo_chip_coin": {
|
||||
"asset_id": "props/buffalo_chip_coin.glb",
|
||||
"type": "prop",
|
||||
"display_name": "Buffalo Chip Coin",
|
||||
"category": "props"
|
||||
},
|
||||
"bison": {
|
||||
"asset_id": "props/bison.glb",
|
||||
"type": "prop",
|
||||
"display_name": "Bison",
|
||||
"category": "nature"
|
||||
},
|
||||
"spawn_point": {
|
||||
"asset_id": "",
|
||||
"type": "spawn_point",
|
||||
|
||||
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.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,
|
||||
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(world_content.router, prefix="/api/worlds", tags=["world-content"])
|
||||
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
||||
app.include_router(asset_collections.router, prefix="/api/assets/collections", tags=["asset-collections"])
|
||||
app.include_router(asset_collections.router, prefix="/assets/collections", tags=["asset-collections"])
|
||||
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
|
||||
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
|
||||
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
|
||||
|
||||
@@ -2,6 +2,7 @@ from app.models.user import User
|
||||
from app.models.session import Session
|
||||
from app.models.world import World
|
||||
from app.models.asset import Asset
|
||||
from app.models.asset_collection import AssetCollection, CollectionAsset
|
||||
from app.models.avatar_loadout import AvatarLoadout
|
||||
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
|
||||
from app.models.social import Friendship, Follow, Block, FriendshipStatus
|
||||
@@ -18,7 +19,7 @@ from app.models.marketplace import (
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
__all__ = [
|
||||
"User", "Session", "World", "Asset", "AvatarLoadout",
|
||||
"User", "Session", "World", "Asset", "AssetCollection", "CollectionAsset", "AvatarLoadout",
|
||||
"WorldRoom", "WorldChunk", "WorldChunkRevision",
|
||||
"Friendship", "Follow", "Block", "FriendshipStatus",
|
||||
"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")
|
||||
worlds = relationship("World", back_populates="owner")
|
||||
assets = relationship("Asset", back_populates="owner")
|
||||
asset_collections = relationship("AssetCollection", back_populates="owner", cascade="all, delete-orphan")
|
||||
avatar_loadout = relationship(
|
||||
"AvatarLoadout",
|
||||
back_populates="user",
|
||||
|
||||
@@ -31,11 +31,13 @@ class World(Base):
|
||||
)
|
||||
category = Column(String(100), nullable=True, index=True)
|
||||
tags = Column(ARRAY(String), nullable=True, server_default="{}")
|
||||
content_guidelines = Column(Text, nullable=True)
|
||||
play_count = Column(Integer, nullable=False, server_default="0", index=True)
|
||||
last_played_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
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 os
|
||||
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.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -42,8 +44,8 @@ class AssetResponse(BaseModel):
|
||||
world_id: Optional[int]
|
||||
owner_id: int
|
||||
is_public: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
created_at: Union[datetime, str]
|
||||
updated_at: Union[datetime, str]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -241,3 +243,20 @@ async def upload_asset_file(
|
||||
await db.commit()
|
||||
await db.refresh(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
|
||||
email: EmailStr
|
||||
password: str
|
||||
# Real date of birth -- the server computes age from it and rejects
|
||||
# anyone under MIN_AGE. Still self-attested (no document check yet), but
|
||||
# a concrete, stored, legally-meaningful data point rather than a
|
||||
# throwaway checkbox, and the seam for a future hard ID-verification
|
||||
# pass. See docs/architecture/content_moderation.md 5.2.
|
||||
date_of_birth: date
|
||||
# ModBison is an adults-only platform (18+). Users confirm agreement with
|
||||
# 18+ Terms of Service. Date of birth is optional / deprecated.
|
||||
over_18: bool = True
|
||||
date_of_birth: date | None = None
|
||||
|
||||
|
||||
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)):
|
||||
"""Register a new user and return access + refresh tokens.
|
||||
|
||||
Requires a date of birth; the server computes age and rejects anyone
|
||||
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
|
||||
verification design and its self-attestation limitation.
|
||||
Requires confirming 18+ TOS agreement. If a date of birth is supplied,
|
||||
verifies the user is >= MIN_AGE (18).
|
||||
"""
|
||||
if req.date_of_birth is not None:
|
||||
age = calculate_age(req.date_of_birth)
|
||||
if age < 0 or req.date_of_birth > date.today():
|
||||
raise HTTPException(
|
||||
@@ -174,6 +172,11 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"You must be at least {MIN_AGE} to use this platform",
|
||||
)
|
||||
elif not req.over_18:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You must confirm you are 18 or older to register",
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
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 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.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -97,7 +97,33 @@ _BODY_MAX_LEN = 4000
|
||||
|
||||
|
||||
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):
|
||||
@@ -111,6 +137,11 @@ class DMMessageResponse(BaseModel):
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def content(self) -> str:
|
||||
return self.body
|
||||
|
||||
|
||||
class DMMessageListResponse(BaseModel):
|
||||
messages: list[DMMessageResponse]
|
||||
@@ -397,3 +428,65 @@ async def list_conversations(
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_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])
|
||||
async def list_reports(
|
||||
current_user: CurrentUser,
|
||||
@@ -146,6 +179,7 @@ async def list_reports(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
|
||||
@moderation_router.post(
|
||||
"/reports/{report_id}/resolve",
|
||||
response_model=ResolveReportResponse,
|
||||
|
||||
@@ -39,7 +39,7 @@ from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
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.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
@@ -68,9 +68,31 @@ _BODY_MAX_LEN = 4000
|
||||
|
||||
|
||||
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
|
||||
|
||||
@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):
|
||||
id: int | None
|
||||
@@ -92,6 +114,11 @@ class WorldMessageResponse(BaseModel):
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def content(self) -> str:
|
||||
return self.body
|
||||
|
||||
|
||||
class WorldMessageListResponse(BaseModel):
|
||||
messages: list[WorldMessageResponse]
|
||||
|
||||
@@ -37,10 +37,12 @@ class DiscoveredWorld(BaseModel):
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
tags: list[str] = []
|
||||
content_guidelines: str | None = None
|
||||
play_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
owner: WorldOwnerSummary
|
||||
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
class FriendRequestCreate(BaseModel):
|
||||
user_id: int
|
||||
user_id: int | None = None
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class FriendRequestResponse(BaseModel):
|
||||
@@ -55,6 +56,7 @@ class FriendRequestResponse(BaseModel):
|
||||
addressee_id: int
|
||||
status: FriendshipStatus
|
||||
created_at: datetime
|
||||
requester_username: str | None = None
|
||||
|
||||
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)
|
||||
async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep):
|
||||
"""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
|
||||
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:
|
||||
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()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
|
||||
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)
|
||||
async def list_incoming_requests(current_user: CurrentUser, db: DbDep):
|
||||
"""List pending friend requests sent *to* the authenticated user."""
|
||||
result = await db.execute(
|
||||
select(Friendship).where(
|
||||
select(Friendship, User.username)
|
||||
.join(User, Friendship.requester_id == User.id)
|
||||
.where(
|
||||
Friendship.addressee_id == current_user.id,
|
||||
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)
|
||||
|
||||
@@ -25,6 +25,13 @@ class UserResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserSummary(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
is_active: bool | None = None
|
||||
@@ -40,6 +47,43 @@ async def get_current_user_profile(
|
||||
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)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
|
||||
@@ -76,3 +76,38 @@ async def get_wallet_transactions(
|
||||
limit=limit,
|
||||
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
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
content_guidelines: str | None = None
|
||||
|
||||
|
||||
class WorldUpdate(BaseModel):
|
||||
@@ -38,6 +39,7 @@ class WorldUpdate(BaseModel):
|
||||
visibility: WorldVisibility | None = None
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
content_guidelines: str | None = None
|
||||
|
||||
|
||||
class WorldResponse(BaseModel):
|
||||
@@ -48,6 +50,7 @@ class WorldResponse(BaseModel):
|
||||
visibility: WorldVisibility
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
content_guidelines: str | None = None
|
||||
play_count: int = 0
|
||||
last_played_at: datetime | None = None
|
||||
owner_id: int
|
||||
@@ -57,6 +60,7 @@ class WorldResponse(BaseModel):
|
||||
|
||||
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/", response_model=list[WorldResponse])
|
||||
@@ -170,8 +174,10 @@ async def create_world(
|
||||
visibility=world_data.visibility,
|
||||
category=world_data.category,
|
||||
tags=world_data.tags or [],
|
||||
content_guidelines=world_data.content_guidelines,
|
||||
)
|
||||
|
||||
|
||||
db.add(world)
|
||||
await db.flush() # assigns world.id, needed for the room's FK
|
||||
await WorldStore(db).get_or_create_main_room(world.id)
|
||||
|
||||
@@ -50,6 +50,7 @@ class _WorldServer:
|
||||
proc: subprocess.Popen
|
||||
status_path: Path
|
||||
token: str
|
||||
log_path: Path | None = None
|
||||
started_at: 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._lock = asyncio.Lock()
|
||||
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)
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────
|
||||
@@ -150,6 +151,7 @@ class GameServerSupervisor:
|
||||
token = secrets.token_urlsafe(32)
|
||||
status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
|
||||
status_path.unlink(missing_ok=True)
|
||||
log_path = self._runtime_dir / f"world_{world_id}_{port}.log"
|
||||
cmd = [
|
||||
settings.GODOT_BIN, "--headless",
|
||||
"--path", settings.GAME_PROJECT_DIR,
|
||||
@@ -160,9 +162,10 @@ class GameServerSupervisor:
|
||||
"--api-url", settings.GAME_SERVER_INTERNAL_API_URL,
|
||||
"--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(
|
||||
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:
|
||||
@@ -199,13 +202,27 @@ class GameServerSupervisor:
|
||||
if srv.is_ready():
|
||||
return
|
||||
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(
|
||||
f"server process for world {srv.world_id} exited before becoming ready"
|
||||
+ (f": {log_snippet}" if log_snippet else "")
|
||||
)
|
||||
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(
|
||||
f"server for world {srv.world_id} did not report ready within "
|
||||
f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s"
|
||||
+ (f": {log_snippet}" if log_snippet else "")
|
||||
)
|
||||
|
||||
async def _reap_loop(self) -> None:
|
||||
|
||||
@@ -87,11 +87,11 @@ class PayoutService:
|
||||
"""Create a new payout request after reserving funds from available creator balance."""
|
||||
if amount < MIN_PAYOUT_AMOUNT:
|
||||
raise InvalidPayoutAmountError(
|
||||
f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Credits"
|
||||
f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Buffalo Chips"
|
||||
)
|
||||
if amount > MAX_PAYOUT_AMOUNT:
|
||||
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
|
||||
@@ -100,7 +100,7 @@ class PayoutService:
|
||||
cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True)
|
||||
if cb.available < amount:
|
||||
raise InsufficientCreatorBalanceError(
|
||||
f"Insufficient available balance: {cb.available} CR available, {amount} CR requested"
|
||||
f"Insufficient available balance: {cb.available} Buffalo Chips available, {amount} Buffalo Chips requested"
|
||||
)
|
||||
|
||||
# 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")
|
||||
assert req.date_of_birth == date(1990, 1, 1)
|
||||
|
||||
def test_dob_required(self):
|
||||
with pytest.raises(Exception):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||
def test_dob_optional_with_over_18_default(self):
|
||||
req = 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):
|
||||
with pytest.raises(Exception):
|
||||
@@ -50,6 +51,7 @@ class TestRegisterRequestDob:
|
||||
date_of_birth="not-a-date")
|
||||
|
||||
|
||||
|
||||
class TestRegisterAgeGate:
|
||||
"""End-to-end: the /register endpoint computes age from the DOB."""
|
||||
|
||||
|
||||
@@ -149,18 +149,34 @@ class TestRegister:
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_missing_dob(self):
|
||||
"""Missing the required date_of_birth field returns 422."""
|
||||
def test_register_without_dob_succeeds(self):
|
||||
"""DOB is no longer collected; registration with 18+ TOS agreement succeeds."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"username": "adult_user_nodob",
|
||||
"email": "adult_user_nodob@example.com",
|
||||
"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
|
||||
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
|
||||
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"])
|
||||
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:
|
||||
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/blocked").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)
|
||||
assert tx_data["transactions"][0]["amount"] == -400
|
||||
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
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="*.json"
|
||||
include_filter="*.json, *.glb, *.wav"
|
||||
exclude_filter=""
|
||||
export_path="export/linux/agp-game.x86_64"
|
||||
export_path="export/linux/modbison.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
@@ -38,9 +38,9 @@ advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="*.json"
|
||||
include_filter="*.json, *.glb, *.wav"
|
||||
exclude_filter=""
|
||||
export_path="export/windows/agp-game.exe"
|
||||
export_path="export/windows/modbison.exe"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
|
||||
+6
-1
@@ -6,7 +6,7 @@ config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="AGP-Game"
|
||||
config/name="ModBison"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6.2")
|
||||
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]
|
||||
|
||||
ApiClient="*res://scripts/api_client.gd"
|
||||
AudioManager="*res://scripts/audio_manager.gd"
|
||||
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||
Session="*res://scripts/session.gd"
|
||||
|
||||
@@ -21,7 +22,11 @@ Session="*res://scripts/session.gd"
|
||||
|
||||
window/size/viewport_width=1920
|
||||
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/aspect="keep"
|
||||
|
||||
[input]
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="WorldListButton" type="Button" parent="VBox/Header"]
|
||||
custom_minimum_size = Vector2(150, 36)
|
||||
layout_mode = 2
|
||||
text = "🏗️ World Browser"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
|
||||
@@ -99,7 +99,7 @@ theme_override_constants/separation = 8
|
||||
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Enter User ID to add..."
|
||||
placeholder_text = "Enter username or ID..."
|
||||
|
||||
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
|
||||
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"]
|
||||
layout_mode = 2
|
||||
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"]
|
||||
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"]
|
||||
layout_mode = 2
|
||||
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"]
|
||||
custom_minimum_size = Vector2(100, 32)
|
||||
|
||||
+68
-7
@@ -25,7 +25,7 @@ size_flags_horizontal = 4
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
text = "AGP Game"
|
||||
text = "ModBison"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 72
|
||||
|
||||
@@ -83,11 +83,12 @@ visible = false
|
||||
placeholder_text = "Email"
|
||||
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
|
||||
visible = false
|
||||
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"]
|
||||
layout_mode = 1
|
||||
@@ -107,26 +108,86 @@ visible = false
|
||||
text = ""
|
||||
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
|
||||
text = "Play"
|
||||
text = "🗺️ Discover Experiences"
|
||||
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"]
|
||||
custom_minimum_size = Vector2(340, 38)
|
||||
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
|
||||
disabled = true
|
||||
|
||||
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(340, 38)
|
||||
layout_mode = 1
|
||||
text = "Quit"
|
||||
text = "🚪 Quit"
|
||||
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/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/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/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"]
|
||||
|
||||
@@ -44,7 +44,7 @@ horizontal_alignment = 1
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(1, 0.84, 0, 1)
|
||||
text = "Wallet: 0 CR"
|
||||
text = "Wallet: 0 Buffalo Chips"
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
@@ -66,14 +66,14 @@ custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
custom_minimum_size = Vector2(110, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Min CR"
|
||||
placeholder_text = "Min Chips"
|
||||
|
||||
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
custom_minimum_size = Vector2(110, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Max CR"
|
||||
placeholder_text = "Max Chips"
|
||||
|
||||
[node name="SearchBtn" type="Button" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
|
||||
@@ -32,5 +32,8 @@ margin = 0.2
|
||||
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
|
||||
current = true
|
||||
|
||||
[node name="AudioListener3D" type="AudioListener3D" parent="CameraPivot/SpringArm3D/Camera3D"]
|
||||
current = true
|
||||
|
||||
[node name="Inventory" type="Node" parent="."]
|
||||
script = ExtResource("2_inventory")
|
||||
|
||||
@@ -86,7 +86,7 @@ placeholder_text = "Description..."
|
||||
[node name="PriceInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Price (CR)"
|
||||
placeholder_text = "Price (Buffalo Chips)"
|
||||
|
||||
[node name="CreateBtn" type="Button" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||
custom_minimum_size = Vector2(110, 36)
|
||||
@@ -141,7 +141,7 @@ text = "Available to Payout:"
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.2, 0.9, 0.2, 1)
|
||||
text = "0 CR"
|
||||
text = "0 Buffalo Chips"
|
||||
|
||||
[node name="PendTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
@@ -151,7 +151,7 @@ text = "In Escrow:"
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(1, 0.8, 0.2, 1)
|
||||
text = "0 CR"
|
||||
text = "0 Buffalo Chips"
|
||||
|
||||
[node name="LifeTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
@@ -161,7 +161,7 @@ text = "Lifetime Earned:"
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.4, 0.7, 1, 1)
|
||||
text = "0 CR"
|
||||
text = "0 Buffalo Chips"
|
||||
|
||||
[node name="RequestBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||
layout_mode = 2
|
||||
@@ -170,7 +170,7 @@ theme_override_constants/separation = 8
|
||||
[node name="AmountInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||
custom_minimum_size = Vector2(130, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Amount (min 100)"
|
||||
placeholder_text = "Buffalo Chips (min 100)"
|
||||
|
||||
[node name="DestInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||
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
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Wallet & Credits"
|
||||
text = "Wallet & Buffalo Chips"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[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"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Available Balance:"
|
||||
text = "Available Buffalo Chips:"
|
||||
|
||||
[node name="BalanceLabel" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "0 CR"
|
||||
text = "0 Buffalo Chips"
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
@@ -83,19 +83,19 @@ theme_override_constants/separation = 8
|
||||
|
||||
[node name="Buy100Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||
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"]
|
||||
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"]
|
||||
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"]
|
||||
layout_mode = 2
|
||||
text = "+5,000 CR ($44.99)"
|
||||
text = "+5,000 Buffalo Chips ($44.99)"
|
||||
|
||||
[node name="LedgerSection" type="VBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
|
||||
@@ -36,6 +36,16 @@ horizontal_alignment = 1
|
||||
size_flags_horizontal = 3
|
||||
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"]
|
||||
layout_mode = 3
|
||||
text = "Refresh"
|
||||
@@ -75,4 +85,6 @@ horizontal_alignment = 1
|
||||
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/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"]
|
||||
|
||||
+141
-19
@@ -39,6 +39,10 @@ extends Node
|
||||
var _access_token: String = ""
|
||||
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.
|
||||
var bearer_token: String:
|
||||
get:
|
||||
@@ -61,6 +65,8 @@ func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
||||
func clear_token() -> void:
|
||||
_access_token = ""
|
||||
_token_type = "Bearer"
|
||||
current_user_id = 0
|
||||
current_username = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -139,10 +145,11 @@ func request(
|
||||
body: String = "",
|
||||
content_type: String = "application/json",
|
||||
require_auth: bool = false,
|
||||
custom_headers: PackedStringArray = PackedStringArray()
|
||||
custom_headers: PackedStringArray = PackedStringArray(),
|
||||
custom_timeout: float = 0.0
|
||||
) -> Dictionary:
|
||||
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.accept_gzip = true
|
||||
add_child(http)
|
||||
@@ -188,7 +195,28 @@ func request(
|
||||
var detail := "HTTP error %d" % response_code
|
||||
var parsed: Variant = _parse_json(json_string)
|
||||
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)
|
||||
|
||||
|
||||
@@ -208,24 +236,41 @@ func login(username: String, password: String) -> Dictionary:
|
||||
false
|
||||
)
|
||||
_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
|
||||
|
||||
|
||||
## 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
|
||||
## and rejects under-18s (403).
|
||||
## ModBison is an 18+ platform. Supports over_18 boolean or optional legacy DOB string.
|
||||
func register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
date_of_birth: String
|
||||
p4: Variant = true,
|
||||
p5: String = ""
|
||||
) -> 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,
|
||||
"email": email,
|
||||
"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(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/register",
|
||||
@@ -234,9 +279,17 @@ func register(
|
||||
false
|
||||
)
|
||||
_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
|
||||
|
||||
|
||||
|
||||
|
||||
## List worlds visible to the current user (requires auth).
|
||||
## @param visibility Optional filter: "public", "private", "friends_only".
|
||||
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)
|
||||
|
||||
|
||||
## 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).
|
||||
func health_check() -> Dictionary:
|
||||
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).
|
||||
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).
|
||||
@@ -268,7 +352,7 @@ func get_avatar_loadout() -> Dictionary:
|
||||
## its address. On success result.data is {"world_id", "host", "port"}.
|
||||
## 503 if this backend has no game-server orchestration.
|
||||
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).
|
||||
@@ -283,9 +367,19 @@ func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
|
||||
# Social Features: Friends, Follow & Block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Send a friend request to target user_id.
|
||||
func send_friend_request(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.send_friend_request(self, user_id)
|
||||
## Send a friend request to target user_id (int) or username (String).
|
||||
func send_friend_request(target: Variant) -> Dictionary:
|
||||
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.
|
||||
func get_friend_requests() -> Dictionary:
|
||||
@@ -307,9 +401,14 @@ func unfriend(user_id: int) -> Dictionary:
|
||||
func get_friends() -> Dictionary:
|
||||
return await SocialApiClient.get_friends(self)
|
||||
|
||||
## Follow a user.
|
||||
func follow_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.follow_user(self, user_id)
|
||||
## Follow a user by ID or username.
|
||||
func follow_user(target: Variant) -> Dictionary:
|
||||
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.
|
||||
func unfollow_user(user_id: int) -> Dictionary:
|
||||
@@ -319,9 +418,14 @@ func unfollow_user(user_id: int) -> Dictionary:
|
||||
func get_following() -> Dictionary:
|
||||
return await SocialApiClient.get_following(self)
|
||||
|
||||
## Block a user.
|
||||
func block_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.block_user(self, user_id)
|
||||
## Block a user by ID or username.
|
||||
func block_user(target: Variant) -> Dictionary:
|
||||
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.
|
||||
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)
|
||||
|
||||
|
||||
## 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
_retarget_skin(attachment_node)
|
||||
|
||||
# Mount to the appropriate bone via a BoneAttachment3D so it follows the
|
||||
# skeleton. Falls back to a plain skeleton child if the mount bone is
|
||||
# missing (a bad catalog entry) rather than dropping the attachment.
|
||||
# Check if this attachment has multi-bone children (e.g. Attach_LeftUpperLeg, Attach_Hips).
|
||||
var multi_bone_children: Array[Node] = []
|
||||
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", "")
|
||||
if mount_bone.is_empty():
|
||||
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:
|
||||
var ba := BoneAttachment3D.new()
|
||||
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":
|
||||
for node in _attachments.get("accessory", []):
|
||||
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()
|
||||
_attachments.erase("accessory")
|
||||
else:
|
||||
var node = _attachments.get(slot_id)
|
||||
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()
|
||||
_attachments.erase(slot_id)
|
||||
|
||||
|
||||
@@ -65,16 +65,13 @@ func _init() -> void:
|
||||
# Serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Create a loadout from a JSON string.
|
||||
static func from_json(json_string: String) -> AvatarLoadout:
|
||||
## Create a loadout from a Dictionary.
|
||||
static func from_dict(root: Dictionary) -> AvatarLoadout:
|
||||
var loadout := _Self.new()
|
||||
var data: Variant = JSON.parse_string(json_string)
|
||||
if not (data is Dictionary):
|
||||
loadout.validation_errors.append("Loadout JSON root is not an object")
|
||||
if root == null:
|
||||
loadout.validation_errors.append("Loadout root is null")
|
||||
return loadout
|
||||
|
||||
var root: Dictionary = data as Dictionary
|
||||
|
||||
# Parse base_body.
|
||||
if root.has("base_body"):
|
||||
var bb = root["base_body"]
|
||||
@@ -111,8 +108,18 @@ static func from_json(json_string: String) -> AvatarLoadout:
|
||||
return loadout
|
||||
|
||||
|
||||
## Serialize this loadout to a JSON string.
|
||||
func to_json() -> String:
|
||||
## Create a loadout from a 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 := {
|
||||
"base_body": base_body,
|
||||
"slots": {},
|
||||
@@ -133,7 +140,12 @@ func to_json() -> String:
|
||||
"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:
|
||||
if is_catalog_loaded:
|
||||
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
|
||||
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
|
||||
## Returns true if it validated and was stored.
|
||||
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():
|
||||
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
|
||||
% str(lo.validation_errors))
|
||||
@@ -287,24 +315,35 @@ func build_current_avatar() -> Node3D:
|
||||
## Assemble a standalone avatar node from [param loadout] using a throwaway
|
||||
## 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
|
||||
## if the assets are genuinely missing.
|
||||
## if the assets are genuinely missing. Accepts either AvatarLoadout or Dictionary.
|
||||
func assemble_avatar(loadout) -> Node3D:
|
||||
if loadout == null or not is_catalog_loaded:
|
||||
if loadout == 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:
|
||||
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
|
||||
return null
|
||||
|
||||
var l = AvatarLoader.new()
|
||||
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:
|
||||
l.free()
|
||||
return null
|
||||
|
||||
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 v is Array:
|
||||
for aid in v:
|
||||
@@ -314,11 +353,11 @@ func assemble_avatar(loadout) -> Node3D:
|
||||
l.load_attachment(slot_id, v)
|
||||
|
||||
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():
|
||||
l.set_base_mesh_visible(slot_id, false)
|
||||
|
||||
l.apply_skin_tone(loadout.skin_tone)
|
||||
l.apply_skin_tone(lo.skin_tone)
|
||||
|
||||
l.free()
|
||||
return base
|
||||
|
||||
@@ -12,7 +12,7 @@ extends Node
|
||||
|
||||
const STRIDE := 0.42 ## peak hip swing, radians (~24 deg)
|
||||
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 BLEND := 12.0 ## ease rate toward the target pose
|
||||
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.
|
||||
##
|
||||
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
|
||||
## layout programmatically and emits `closed` when the player backs out; this
|
||||
## thin wrapper is what makes it a navigable scene reachable from the main menu.
|
||||
## layout programmatically, auto-saves on exit, and emits `closed` / `back_requested`
|
||||
## 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")
|
||||
|
||||
@@ -18,3 +19,13 @@ func _ready() -> void:
|
||||
|
||||
func _on_closed() -> void:
|
||||
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)
|
||||
#
|
||||
# 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
|
||||
cd "$(dirname "$0")/.."
|
||||
@@ -32,10 +32,10 @@ echo "Godot version: $("$GODOT_BIN" --version)"
|
||||
mkdir -p export/linux export/windows
|
||||
|
||||
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 ==="
|
||||
"$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 "Build artifacts:"
|
||||
|
||||
+7
-1
@@ -99,6 +99,12 @@ func _on_back_pressed() -> void:
|
||||
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:
|
||||
if status_label:
|
||||
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:
|
||||
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()
|
||||
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