Compare commits

..

2 Commits

382 changed files with 354 additions and 54273 deletions
-39
View File
@@ -1,39 +0,0 @@
name: checkpoint
# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke
# tests, multiplayer, fresh-clone boot) on every push and PR.
#
# Runs on a `host`-label act_runner on the AGP box: Docker, ~/godot/godot,
# and Python 3 are already there. Deliberately uses no JavaScript actions
# (actions/checkout etc.) so the host needs no Node install -- checkout is a
# plain git clone from the local Gitea. See docs/ci.md.
on:
push:
branches: [main]
pull_request:
jobs:
checkpoint:
runs-on: host
steps:
- name: Checkout
run: |
set -eu
rm -rf repo && mkdir repo && cd repo
git init -q
git remote add origin "http://x:${{ github.token }}@localhost:3000/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Python env
working-directory: repo
run: |
set -eu
python3 -m venv .venv
.venv/bin/pip install --quiet --upgrade pip
.venv/bin/pip install --quiet -r backend/requirements.txt
- name: Checkpoint suite
working-directory: repo
run: .venv/bin/python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690
-8
View File
@@ -8,11 +8,3 @@ export/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Godot UID files (regenerated from path)
*.uid
assets/build_info.json
# Dynamic uploads
uploads/
backend/uploads/
-40
View File
@@ -1,40 +0,0 @@
# ModBison (Project & Agent Guidelines)
> **CRITICAL REBRANDING NOTICE**
> - **Game Name**: **ModBison** (formerly `agp-game`).
> - "ModBison" represents a play on game modification and the 1960s British "mod" subculture, adhering to the user's "bison" project naming convention (e.g. host machine `cloudbison`, Windows client machine `pinkbison`, social platform `WiseBison`).
> - **In-Game Currency**: **Buffalo Chips** (formerly `Credits` / `CR`).
> - Named playfully after bison poop. Displayed throughout all UI menus, wallet, marketplace, and creator payout screens as "Buffalo Chips".
> - **Git Repository**:
> - Local clone path: `/home/daddy/modbison` (symlinked as `/home/daddy/agp-game-dev` for backwards compatibility).
> - Gitea repository name: `modbison`
> - Clone URL: `http://localhost:3000/daddy/modbison.git`
> - Web UI: `https://git.cloudbison.duckdns.org/daddy/modbison`
> - **Binaries & Exports**:
> - Windows: `modbison.exe` (legacy alias `agp-game.exe` maintained for backwards compatibility)
> - Linux: `modbison.x86_64`
---
## Architecture Overview
1. **Client**: Godot 4.6.2 (GDScript).
- Headless engine binary located at `~/godot/godot`.
- Export presets defined in `export_presets.cfg`.
- Main scenes in `res://scenes/` (`main_menu.tscn`, `world_3d.tscn`, `wallet_ui.tscn`, `marketplace_ui.tscn`, `selling_payout_ui.tscn`, etc.).
- Scripts in `res://scripts/`.
2. **Backend**: FastAPI + SQLAlchemy (asyncpg) + Alembic in `backend/`.
- Python environment: `/home/daddy/agp/venv` (or project `.venv`).
- Runtime game server supervisor allocates headless Godot instances per active world session under `/tmp/modbison-game-servers`.
3. **Orchestration & Playtesting (`/home/daddy/agp`)**:
- PM Dashboard: `http://localhost:7070` (`agp-dashboard.service`).
- Checkpoint system: `pm/checkpoint_ops.py` controls playtest instances (`PC1` through `PC5`).
- Current active checkpoint: **PC5** (backend on `http://192.168.58.138:28005`, postgres on `25545`).
- Building Windows package: `PYTHONPATH=. /home/daddy/agp/venv/bin/python3 -c "import pm.checkpoint_ops as ops; ops.build_exe('PC5')"`
4. **Testing Protocols**:
- Headless smoke tests: `AGP_API_BASE_URL=http://127.0.0.1:28005 ~/godot/godot --headless --path . --script res://scripts/smoke_test_<name>.gd`
- Fast checkpoint tests: `PYTHONPATH=. /home/daddy/agp/venv/bin/python3 scripts/run_checkpoint_tests.py --skip-backend --filter <keyword>`
- Backend pytest: `PYTHONPATH=. /home/daddy/agp/venv/bin/pytest backend/tests`
-18
View File
@@ -1,18 +0,0 @@
# 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')"`
+2 -128
View File
@@ -1,129 +1,3 @@
# ModBison # agp-game
> **Godot 4 Multiplayer Sandbox Platform** Agentic Game Platform - Godot game built by autonomous agents
> Customizable 3D avatars, real-time multiplayer worlds, in-game building tools, social community hubs, and a creator economy powered by **Buffalo Chips**.
---
## 🦬 About ModBison
**ModBison** (formerly `agp-game`) is a collaborative sandbox game and platform built in Godot 4 and Python FastAPI.
* **The Name**: "ModBison" is a double play on game modification and the 1960s British "mod" youth subculture, aligning with the host and infrastructure naming convention (`cloudbison`, `pinkbison`, `WiseBison`).
* **The Currency**: **Buffalo Chips** — the in-game virtual currency used for marketplace trading, creator selling, and cash payouts (a playful reference to bison poop).
---
## 🏛️ Architecture Overview
The platform consists of two integrated environments:
1. **Client & Game Server (`modbison`)**:
- Built with **Godot 4.6.2** (GDScript).
- Headless Godot dedicated server (`scenes/server.tscn`) spawned per active world session via high ENet ports.
- Dual-platform export configuration: Windows Desktop (`modbison.exe`) and Linux (`modbison.x86_64`).
- Binary chunk streaming and client-side interpolation.
2. **Backend & Orchestration Platform (`agp`)**:
- **FastAPI** (Python 3.12) REST API with **PostgreSQL** (asyncpg) and **Alembic** migrations.
- **Redis** for real-time presence heartbeats and pub/sub messaging.
- **Game Server Supervisor**: Dynamic headless Godot instance orchestrator allocating runtime servers in `/tmp/modbison-game-servers`.
- **PM Dashboard & Checkpoints**: Live orchestration dashboard running at `http://localhost:7070` managing Package Checkpoints (**PC1** through **PC4**).
---
## ✨ What Has Been Built (Feature Summary)
### 1. 👤 3D Avatar & Customization Engine
* **Canonical Rig**: 24-bone skeleton supporting attachment sockets for Head, Hair, Face, Chest, Pants, Left/Right Hand, Left/Right Foot, and Accessories.
* **Customization Studio** (`scenes/avatar_customization.tscn`): Full 3D turntable preview, orbit camera controls, slot-based equipment, RGB tint pickers, and backend persistence (`/api/avatar/loadout`).
* **Multiplayer Replication**: Remote players receive complete avatar loadout dictionaries via RPC upon joining, dynamically assembling meshes and materials in real time.
* **Solid Double-Sided Rendering**: Fixed polygon winding order and material setups to ensure characters render fully solid from all camera angles.
### 2. 🌍 Multiplayer World & Chunk Streaming
* **Dedicated Server Architecture**: Server-authoritative player movement, client prediction, reconciliation, and collision boundaries.
* **Chunk Streaming**: Binary stream encoding/decoding (`chunk_codec.py` / `chunk_data_decoder.gd`) enabling real-time streaming of world geometry, collision boxes, and dynamic props as players explore.
* **Stable Spawn System**: Server-authoritative solid ground anchoring ensuring players never fall into the void upon connection.
### 3. 🛠️ In-Game World Editor & Sandboxed Scripting
* **Runtime World Editor** (`scripts/editor/`): In-game tool registry, block/prop palette, selection manager, raycast placement/deletion, and undo/redo action stacks.
* **Chunk Persistence** (`chunk_save_service.gd`): Saves player modifications and created structures back to the world backend.
* **Rule-Based Scripting** (`scripts/scripting/`): Sandboxed behavior interpreter (`script_interpreter.gd`, `script_rule.gd`) allowing props to trigger gameplay actions, inventory changes, and spawn events.
* **In-Game HUD Bar**: Hotkey-driven overlay bar giving players instant access to build tools and menus while inside a live world.
### 4. 👥 Social, Chat & Clans
* **Friends & Presence** (`scenes/friends_presence_ui.tscn`): Friend requests, accept/decline flows, and live online/in-world presence heartbeats via Redis.
* **Multi-Channel Chat** (`scenes/chat_ui.tscn`): Direct messaging (DM), group chat, and spatial in-world chat with automated text filtering (`text_filter.py`).
* **Groups & Clans** (`scenes/groups_ui.tscn`): Group creation, member roles, descriptions, and membership rosters.
### 5. 💰 Virtual Economy & Creator Payouts
* **In-Game Currency**: **Buffalo Chips** displayed across all menus, wallets, listings, and payout screens.
* **Wallet UI** (`scenes/wallet_ui.tscn`): Live balance display, quick-purchase packs (+100, +500, +1,000, +5,000 Buffalo Chips), and transaction history ledger.
* **Marketplace UI** (`scenes/marketplace_ui.tscn`): Browse items for sale, filter by price in Buffalo Chips, search catalog, and instant purchase with inventory delivery.
* **Creator Selling & Payouts** (`scenes/selling_payout_ui.tscn`): Catalog item listing, price configuration, escrow tracking (Available, In Escrow, Lifetime Earned), and cash payout requests (minimum 100 Buffalo Chips).
### 6. 🌐 Discovery & Catalog
* **World Discovery UI** (`scenes/discovery_ui.tscn`): Public world browser with search by title, tags, or category.
* **Trending Algorithm**: Decay-weighted play counts ranking popular worlds on the discovery front page.
---
## 🎮 In-Game Controls & Hotkeys
| Key | Action |
| :--- | :--- |
| **`W` `A` `S` `D`** | Move character |
| **`Space`** | Jump |
| **`Mouse`** | Look / Camera rotation |
| **`T`** or **`Enter`** | Open Chat |
| **`Tab`** or **`F1`** | Open Friends & Social Panel |
| **`F2`** | Toggle In-Game World Editor |
| **`ESC`** | Close current modal / Toggle In-Game HUD Menu |
---
## 🚀 Development, Testing & Builds
### Automated Smoke Tests (Headless Godot)
All UI panels and client subsystems have headless GDScript smoke tests:
```bash
# Set target backend (e.g. PC4 playtest instance)
export AGP_API_BASE_URL=http://127.0.0.1:28004
# Run individual UI & subsystem smoke tests
~/godot/godot --headless --path . --script res://scripts/smoke_test_main_menu_full_flow.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_marketplace_ui.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_wallet_ui.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_selling_payout_ui.gd
~/godot/godot --headless --path . --script res://scripts/smoke_test_avatar_in_world.gd
```
### Backend Test Suite
```bash
# Run 335+ FastAPI & SQLAlchemy test suite
PYTHONPATH=. /home/daddy/agp/venv/bin/pytest backend/tests
```
### Building Release Binaries
```bash
# Export Linux and Windows binaries
scripts/build.sh
# Or build pre-configured Windows package for current checkpoint (PC4):
PYTHONPATH=. /home/daddy/agp/venv/bin/python3 -c "import pm.checkpoint_ops as ops; ops.build_exe('PC4')"
```
Output artifacts:
- Windows: `export/windows/modbison.exe` (or `agp/playtest/PC4/build/windows/modbison.exe`)
- Linux: `export/linux/modbison.x86_64`
---
## 📦 Project Links & Configuration
* **Gitea Repository**: `http://localhost:3000/daddy/modbison.git`
* **Web Interface**: [https://git.cloudbison.duckdns.org/daddy/modbison](https://git.cloudbison.duckdns.org/daddy/modbison)
* **Active Playtest Instance**: **PC4**
- Backend URL: `http://192.168.58.138:28004`
- Database: PostgreSQL on port `25544`
* **PM Dashboard**: `http://localhost:7070`
* **Working Directory**: `/home/daddy/modbison` (symlinked as `/home/daddy/agp-game-dev`)
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dvln67rbfmcnt"
path="res://.godot/imported/bell.wav-c9e8b15c72938ff0cc4247d900f73a2f.sample"
[deps]
source_file="res://assets/audio/bell.wav"
dest_files=["res://.godot/imported/bell.wav-c9e8b15c72938ff0cc4247d900f73a2f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://uvtdcjsrs4oc"
path="res://.godot/imported/chime.wav-cbf50c3ed007f7bda834f64e66ec1ffa.sample"
[deps]
source_file="res://assets/audio/chime.wav"
dest_files=["res://.godot/imported/chime.wav-cbf50c3ed007f7bda834f64e66ec1ffa.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cxiiaesukt1ci"
path="res://.godot/imported/click.wav-726094e36c7052fadb6392aaf7409595.sample"
[deps]
source_file="res://assets/audio/click.wav"
dest_files=["res://.godot/imported/click.wav-726094e36c7052fadb6392aaf7409595.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cssl2kgkc53is"
path="res://.godot/imported/coin.wav-e1bec9e9502a5df388e6fd33d4bb5689.sample"
[deps]
source_file="res://assets/audio/coin.wav"
dest_files=["res://.godot/imported/coin.wav-e1bec9e9502a5df388e6fd33d4bb5689.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c10qifwxnhin6"
path="res://.godot/imported/door_open.wav-d530c3547ac0143568ab23d68285c71c.sample"
[deps]
source_file="res://assets/audio/door_open.wav"
dest_files=["res://.godot/imported/door_open.wav-d530c3547ac0143568ab23d68285c71c.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[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.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bm01jsuomi50i"
path="res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"
[deps]
source_file="res://assets/avatars/accessory_belt_01.glb"
dest_files=["res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dvou487niwnjt"
path="res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"
[deps]
source_file="res://assets/avatars/accessory_necklace_01.glb"
dest_files=["res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dknbtq33pssf4"
path="res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"
[deps]
source_file="res://assets/avatars/back_backpack_01.glb"
dest_files=["res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://brfp6g6pcbbw7"
path="res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"
[deps]
source_file="res://assets/avatars/base_human_male_01.glb"
dest_files=["res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
-155
View File
@@ -1,155 +0,0 @@
{
"assets": {
"base_human_male_01": {
"type": "base_body",
"slot": "base_body",
"path": "res://assets/avatars/base_human_male_01.glb",
"version": "1.0.0",
"display_name": "Human Male Base"
},
"head_cyber_01": {
"type": "attachment",
"slot": "head",
"path": "res://assets/avatars/head_cyber_01.glb",
"version": "1.0.0",
"display_name": "Cyber Head",
"mount_bone": "Head"
},
"hair_mohawk_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_mohawk_01.glb",
"version": "1.0.0",
"display_name": "Mohawk",
"mount_bone": "HeadTop"
},
"hair_ponytail_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_ponytail_01.glb",
"version": "1.0.0",
"display_name": "Ponytail",
"mount_bone": "HeadTop"
},
"hair_waterbuffalo_01": {
"type": "attachment",
"slot": "hair",
"path": "res://assets/avatars/hair_waterbuffalo_01.glb",
"version": "1.0.0",
"display_name": "Water Buffalo Lodge Hat",
"mount_bone": "HeadTop"
},
"face_glasses_01": {
"type": "attachment",
"slot": "face",
"path": "res://assets/avatars/face_glasses_01.glb",
"version": "1.0.0",
"display_name": "Glasses",
"mount_bone": "Jaw"
},
"chest_bandit_01": {
"type": "attachment",
"slot": "chest",
"path": "res://assets/avatars/chest_bandit_01.glb",
"version": "1.0.0",
"display_name": "Bandit Shirt",
"mount_bone": "Chest"
},
"back_backpack_01": {
"type": "attachment",
"slot": "back",
"path": "res://assets/avatars/back_backpack_01.glb",
"version": "1.0.0",
"display_name": "Backpack",
"mount_bone": "Chest"
},
"pants_cargo_01": {
"type": "attachment",
"slot": "pants",
"path": "res://assets/avatars/pants_cargo_01.glb",
"version": "1.0.0",
"display_name": "Cargo Pants",
"mount_bone": "Hips"
},
"left_hand_glove_01": {
"type": "attachment",
"slot": "left_hand",
"path": "res://assets/avatars/left_hand_glove_01.glb",
"version": "1.0.0",
"display_name": "Left Glove",
"mount_bone": "LeftHand"
},
"left_hand_wristband_01": {
"type": "attachment",
"slot": "left_hand",
"path": "res://assets/avatars/left_hand_wristband_01.glb",
"version": "1.0.0",
"display_name": "Left Gold Band",
"mount_bone": "LeftHand"
},
"right_hand_glove_01": {
"type": "attachment",
"slot": "right_hand",
"path": "res://assets/avatars/right_hand_glove_01.glb",
"version": "1.0.0",
"display_name": "Right Glove",
"mount_bone": "RightHand"
},
"right_hand_wristband_01": {
"type": "attachment",
"slot": "right_hand",
"path": "res://assets/avatars/right_hand_wristband_01.glb",
"version": "1.0.0",
"display_name": "Right Gold Band",
"mount_bone": "RightHand"
},
"left_foot_boot_01": {
"type": "attachment",
"slot": "left_foot",
"path": "res://assets/avatars/left_foot_boot_01.glb",
"version": "1.0.0",
"display_name": "Left Boot",
"mount_bone": "LeftFoot"
},
"left_foot_sneaker_01": {
"type": "attachment",
"slot": "left_foot",
"path": "res://assets/avatars/left_foot_sneaker_01.glb",
"version": "1.0.0",
"display_name": "Left Sneaker",
"mount_bone": "LeftFoot"
},
"right_foot_boot_01": {
"type": "attachment",
"slot": "right_foot",
"path": "res://assets/avatars/right_foot_boot_01.glb",
"version": "1.0.0",
"display_name": "Right Boot",
"mount_bone": "RightFoot"
},
"right_foot_sneaker_01": {
"type": "attachment",
"slot": "right_foot",
"path": "res://assets/avatars/right_foot_sneaker_01.glb",
"version": "1.0.0",
"display_name": "Right Sneaker",
"mount_bone": "RightFoot"
},
"accessory_belt_01": {
"type": "attachment",
"slot": "accessory",
"path": "res://assets/avatars/accessory_belt_01.glb",
"version": "1.0.0",
"display_name": "Belt",
"mount_bone": "Root"
},
"accessory_necklace_01": {
"type": "attachment",
"slot": "accessory",
"path": "res://assets/avatars/accessory_necklace_01.glb",
"version": "1.0.0",
"display_name": "Necklace",
"mount_bone": "Root"
}
}
}
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bva2etlpa8act"
path="res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"
[deps]
source_file="res://assets/avatars/chest_bandit_01.glb"
dest_files=["res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ctwcxf552y325"
path="res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"
[deps]
source_file="res://assets/avatars/face_glasses_01.glb"
dest_files=["res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://j4dc6l3l0j6f"
path="res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"
[deps]
source_file="res://assets/avatars/hair_mohawk_01.glb"
dest_files=["res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dp4vqp2trrank"
path="res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"
[deps]
source_file="res://assets/avatars/hair_ponytail_01.glb"
dest_files=["res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dbpg1j858253k"
path="res://.godot/imported/hair_waterbuffalo_01.glb-c029a4cc5c7f4da664b2a6ff40a4d5a5.scn"
[deps]
source_file="res://assets/avatars/hair_waterbuffalo_01.glb"
dest_files=["res://.godot/imported/hair_waterbuffalo_01.glb-c029a4cc5c7f4da664b2a6ff40a4d5a5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bkp35fnns36xb"
path="res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"
[deps]
source_file="res://assets/avatars/head_cyber_01.glb"
dest_files=["res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bgfjpdu8kg0aj"
path="res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"
[deps]
source_file="res://assets/avatars/left_foot_boot_01.glb"
dest_files=["res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[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.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dtq7tgio1iymd"
path="res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"
[deps]
source_file="res://assets/avatars/left_hand_glove_01.glb"
dest_files=["res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bd2pj86s3jgw6"
path="res://.godot/imported/left_hand_wristband_01.glb-bbb8beaefb98cfa41b275a9c621f1f25.scn"
[deps]
source_file="res://assets/avatars/left_hand_wristband_01.glb"
dest_files=["res://.godot/imported/left_hand_wristband_01.glb-bbb8beaefb98cfa41b275a9c621f1f25.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://yo204q1m6hs3"
path="res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"
[deps]
source_file="res://assets/avatars/pants_cargo_01.glb"
dest_files=["res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://30ij0eapm6ti"
path="res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"
[deps]
source_file="res://assets/avatars/right_foot_boot_01.glb"
dest_files=["res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[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.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bcrtxtp47dv8h"
path="res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"
[deps]
source_file="res://assets/avatars/right_hand_glove_01.glb"
dest_files=["res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b64ejiiluvql4"
path="res://.godot/imported/right_hand_wristband_01.glb-65f50dbbe9aaf5f2de1555fec5ca3385.scn"
[deps]
source_file="res://assets/avatars/right_hand_wristband_01.glb"
dest_files=["res://.godot/imported/right_hand_wristband_01.glb-65f50dbbe9aaf5f2de1555fec5ca3385.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
-52
View File
@@ -1,52 +0,0 @@
{
"objects": {
"crate_small": {
"asset_id": "props/crate_small.glb",
"type": "prop",
"display_name": "Small Crate",
"category": "props"
},
"rock_01": {
"asset_id": "props/rock_01.glb",
"type": "prop",
"display_name": "Rock",
"category": "nature"
},
"tree_pine_01": {
"asset_id": "props/tree_pine_01.glb",
"type": "prop",
"display_name": "Pine Tree",
"category": "nature"
},
"lamp_post_01": {
"asset_id": "props/lamp_post_01.glb",
"type": "prop",
"display_name": "Lamp Post",
"category": "props"
},
"chest_01": {
"asset_id": "props/chest.glb",
"type": "interactable",
"display_name": "Treasure Chest",
"category": "interactables"
},
"buffalo_chip_coin": {
"asset_id": "props/buffalo_chip_coin.glb",
"type": "prop",
"display_name": "Buffalo Chip Coin",
"category": "props"
},
"bison": {
"asset_id": "props/bison.glb",
"type": "prop",
"display_name": "Bison",
"category": "nature"
},
"spawn_point": {
"asset_id": "",
"type": "spawn_point",
"display_name": "Spawn Point",
"category": "gameplay"
}
}
}
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[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.

Before

Width:  |  Height:  |  Size: 71 KiB

@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://oemjaiyc1jor"
path="res://.godot/imported/bison_04bizontexture.jpg-ee62bcbf71190dd9c3260c488d0f5226.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "62ac14285e2f49777af903582f16d021"
}
[deps]
source_file="res://assets/props/bison_04bizontexture.jpg"
dest_files=["res://.godot/imported/bison_04bizontexture.jpg-ee62bcbf71190dd9c3260c488d0f5226.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dxjew7eex2jh4"
path="res://.godot/imported/buffalo_chip_coin.glb-ccee212ee6fb7ddcc2555bc23ea60f4b.scn"
[deps]
source_file="res://assets/props/buffalo_chip_coin.glb"
dest_files=["res://.godot/imported/buffalo_chip_coin.glb-ccee212ee6fb7ddcc2555bc23ea60f4b.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://kik8lcd3jp7x"
path="res://.godot/imported/chest.glb-22b4964b33afcd001921fbaa02f82b8a.scn"
[deps]
source_file="res://assets/props/chest.glb"
dest_files=["res://.godot/imported/chest.glb-22b4964b33afcd001921fbaa02f82b8a.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://n18xbm0q3jai"
path="res://.godot/imported/crate_small.glb-e6fdaf315193cd20d4514609adaa68f2.scn"
[deps]
source_file="res://assets/props/crate_small.glb"
dest_files=["res://.godot/imported/crate_small.glb-e6fdaf315193cd20d4514609adaa68f2.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b3nfmxbm87pl3"
path="res://.godot/imported/lamp_post_01.glb-eea7873c5c7bafcb4d19a09b9d97e53c.scn"
[deps]
source_file="res://assets/props/lamp_post_01.glb"
dest_files=["res://.godot/imported/lamp_post_01.glb-eea7873c5c7bafcb4d19a09b9d97e53c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://boi3fsj4ehqvb"
path="res://.godot/imported/rock_01.glb-b0b73bccccfa933f3863bdc2afcb3246.scn"
[deps]
source_file="res://assets/props/rock_01.glb"
dest_files=["res://.godot/imported/rock_01.glb-b0b73bccccfa933f3863bdc2afcb3246.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[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
@@ -1,17 +0,0 @@
{
"content_flags": {
"violence": "none",
"mature": false
},
"rules": [
{
"trigger": {"type": "on_interact"},
"conditions": [],
"actions": [
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
{"verb": "play_sound", "sound_id": "chest_open"},
{"verb": "rotate", "target": "self", "degrees": 90, "axis": "y", "duration": 0.4}
]
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"templates": {
"small_crate": {
"asset_id": "props/crate_small.glb",
"display_name": "Small Crate"
}
}
}
+4 -7
View File
@@ -4,13 +4,10 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
# Redis # Redis
REDIS_URL=redis://localhost:6379/0 REDIS_URL=redis://localhost:6379/0
# JWT / Auth # JWT
ALGORITHM=RS256 SECRET_KEY=change-me-to-a-secure-random-string
ACCESS_TOKEN_EXPIRE_MINUTES=60 ALGORITHM=HS256
REFRESH_TOKEN_EXPIRE_DAYS=30 ACCESS_TOKEN_EXPIRE_MINUTES=30
# Directory where the RSA keypair is loaded/generated at runtime.
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
RSA_KEY_DIR=~/.agp/keys
# App # App
APP_NAME=AGP Backend APP_NAME=AGP Backend
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
from app.models import user, session, world, asset, avatar_loadout, world_content # noqa: F401 from app.models import user, session, world, asset # noqa: F401
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
@@ -1,36 +0,0 @@
"""add refresh_tokens table
Revision ID: 0003_add_refresh_tokens_table
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-07-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0003_add_refresh_tokens_table"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the refresh_tokens table."""
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
"""Drop the refresh_tokens table."""
op.drop_table("refresh_tokens")
@@ -1,39 +0,0 @@
"""add avatar_loadouts table
Revision ID: 0004_add_avatar_loadouts_table
Revises: 0003_add_refresh_tokens_table
Create Date: 2026-09-05 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0004_add_avatar_loadouts_table"
down_revision: Union[str, None] = "0003_add_refresh_tokens_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the avatar_loadouts table (one row per user)."""
op.create_table(
"avatar_loadouts",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("data", postgresql.JSONB(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
"""Drop the avatar_loadouts table."""
op.drop_table("avatar_loadouts")
@@ -1,120 +0,0 @@
"""add world_rooms, world_chunks, world_chunk_revisions tables
Revision ID: 0005_add_world_content_tables
Revises: 0004_add_avatar_loadouts_table
Create Date: 2026-09-14 00:00:00.000000
See docs/architecture/world_persistence_impl.md §4. Chunk content
(rooms, chunk payloads, chunk revisions) lives in Postgres, purely
additive to the existing ``worlds`` table.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0005_add_world_content_tables"
down_revision: Union[str, None] = "0004_add_avatar_loadouts_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"world_rooms",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"world_id", sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("slug", sa.String(64), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column(
"grid", postgresql.JSONB(), nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}',
),
sa.Column("bounds", postgresql.JSONB(), nullable=True),
sa.Column("spawn", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),
)
op.create_index("ix_world_rooms_world_id", "world_rooms", ["world_id"])
op.create_table(
"world_chunks",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"world_id", sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"room_id", sa.Integer(),
sa.ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("chunk_key", sa.String(160), nullable=False, unique=True),
sa.Column("cx", sa.Integer(), nullable=False),
sa.Column("cy", sa.Integer(), nullable=False),
sa.Column("cz", sa.Integer(), nullable=False, server_default="0"),
sa.Column("format_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("payload", sa.LargeBinary(), nullable=False),
sa.Column("payload_len", sa.Integer(), nullable=False),
sa.Column("checksum", sa.String(64), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column("content_flags", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("is_mature", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"updated_by_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
)
op.create_index("ix_world_chunks_world_id", "world_chunks", ["world_id"])
op.create_index("ix_world_chunks_room_id", "world_chunks", ["room_id"])
op.create_index(
"ix_world_chunks_room_coord", "world_chunks", ["room_id", "cx", "cy", "cz"],
)
op.create_index(
"ix_world_chunks_mature", "world_chunks", ["world_id"],
postgresql_where=sa.text("is_mature"),
)
op.create_table(
"world_chunk_revisions",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("chunk_key", sa.String(160), nullable=False),
sa.Column("world_id", sa.Integer(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("action", sa.String(16), nullable=False),
sa.Column(
"actor_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("checksum", sa.String(64), nullable=False),
sa.Column("payload", sa.LargeBinary(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_world_chunk_revisions_chunk_key", "world_chunk_revisions", ["chunk_key"])
op.create_index("ix_world_chunk_revisions_world_id", "world_chunk_revisions", ["world_id"])
op.create_index("ix_world_chunk_revisions_created_at", "world_chunk_revisions", ["created_at"])
def downgrade() -> None:
op.drop_table("world_chunk_revisions")
op.drop_table("world_chunks")
op.drop_table("world_rooms")
@@ -1,33 +0,0 @@
"""add presence columns to users
Revision ID: 0006_add_presence_columns
Revises: 0005_add_world_content_tables
Create Date: 2026-09-14 00:00:00.000000
Ported forward from the orphaned agent/dd2a126b57a1 branch (originally
2a9b9873244a_add_presence_columns.py, down_revision 0001_initial) and
rebased onto the current migration head. See app/services/presence.py /
app/routers/presence.py.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0006_add_presence_columns"
down_revision: Union[str, None] = "0005_add_world_content_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add last_seen_at and status_message columns to users table."""
op.add_column("users", sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("status_message", sa.Text(), nullable=True))
def downgrade() -> None:
"""Remove presence columns from users table."""
op.drop_column("users", "status_message")
op.drop_column("users", "last_seen_at")
@@ -1,118 +0,0 @@
"""add social graph tables (friendships, follows, blocks)
Revision ID: 0007_add_social_graph_tables
Revises: 0006_add_presence_columns
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-1 / app/models/social.py. Models + migration
only -- the request/accept/block business logic lives in the sibling
"Friends & follow API router" task.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0007_add_social_graph_tables"
down_revision: Union[str, None] = "0006_add_presence_columns"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
friendship_status = postgresql.ENUM(
"pending", "accepted", name="friendship_status"
)
friendship_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"friendships",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"requester_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"addressee_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
postgresql.ENUM(
"pending", "accepted", name="friendship_status", create_type=False
),
nullable=False,
server_default="pending",
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("requester_id", "addressee_id", name="uq_friendships_pair"),
)
op.create_index("ix_friendships_requester_id", "friendships", ["requester_id"])
op.create_index("ix_friendships_addressee_id", "friendships", ["addressee_id"])
op.create_table(
"follows",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"follower_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"followed_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("follower_id", "followed_id", name="uq_follows_pair"),
)
op.create_index("ix_follows_follower_id", "follows", ["follower_id"])
op.create_index("ix_follows_followed_id", "follows", ["followed_id"])
op.create_table(
"blocks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"blocker_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"blocked_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("blocker_id", "blocked_id", name="uq_blocks_pair"),
)
op.create_index("ix_blocks_blocker_id", "blocks", ["blocker_id"])
op.create_index("ix_blocks_blocked_id", "blocks", ["blocked_id"])
def downgrade() -> None:
op.drop_index("ix_blocks_blocked_id", table_name="blocks")
op.drop_index("ix_blocks_blocker_id", table_name="blocks")
op.drop_table("blocks")
op.drop_index("ix_follows_followed_id", table_name="follows")
op.drop_index("ix_follows_follower_id", table_name="follows")
op.drop_table("follows")
op.drop_index("ix_friendships_addressee_id", table_name="friendships")
op.drop_index("ix_friendships_requester_id", table_name="friendships")
op.drop_table("friendships")
postgresql.ENUM(name="friendship_status").drop(op.get_bind(), checkfirst=True)
@@ -1,178 +0,0 @@
"""add chat tables (conversations, messages, message_reports)
Revision ID: 0008_add_chat_tables
Revises: 0007_add_social_graph_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-4 / app/models/chat.py. Models + migration only
-- text filtering, rate limiting, and send/report/moderation endpoints live
in sibling tasks that depend on this one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0008_add_chat_tables"
down_revision: Union[str, None] = "0007_add_social_graph_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
message_report_status = postgresql.ENUM(
"open", "resolved", "dismissed", name="message_report_status"
)
message_report_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"conversations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_a_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_b_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_a_id", "user_b_id", name="uq_conversations_pair"),
sa.CheckConstraint("user_a_id < user_b_id", name="ck_conversations_ordered_pair"),
)
op.create_index("ix_conversations_user_a_id", "conversations", ["user_a_id"])
op.create_index("ix_conversations_user_b_id", "conversations", ["user_b_id"])
op.create_table(
"conversation_participants",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"conversation_id",
sa.Integer(),
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint(
"conversation_id", "user_id", name="uq_conversation_participants_pair"
),
)
op.create_index(
"ix_conversation_participants_conversation_id",
"conversation_participants",
["conversation_id"],
)
op.create_index(
"ix_conversation_participants_user_id", "conversation_participants", ["user_id"]
)
op.create_table(
"messages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"sender_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"conversation_id",
sa.Integer(),
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"world_id",
sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"room_id",
sa.Integer(),
sa.ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("filtered", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_messages_sender_id", "messages", ["sender_id"])
op.create_index(
"ix_messages_conversation_created", "messages", ["conversation_id", "created_at"]
)
op.create_index("ix_messages_world_created", "messages", ["world_id", "created_at"])
op.create_table(
"message_reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"message_id",
sa.Integer(),
sa.ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"reporter_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
postgresql.ENUM(
"open", "resolved", "dismissed", name="message_report_status", create_type=False
),
nullable=False,
server_default="open",
),
sa.Column(
"moderator_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_message_reports_message_id", "message_reports", ["message_id"])
op.create_index("ix_message_reports_reporter_id", "message_reports", ["reporter_id"])
op.create_index("ix_message_reports_moderator_id", "message_reports", ["moderator_id"])
def downgrade() -> None:
op.drop_index("ix_message_reports_moderator_id", table_name="message_reports")
op.drop_index("ix_message_reports_reporter_id", table_name="message_reports")
op.drop_index("ix_message_reports_message_id", table_name="message_reports")
op.drop_table("message_reports")
op.drop_index("ix_messages_world_created", table_name="messages")
op.drop_index("ix_messages_conversation_created", table_name="messages")
op.drop_index("ix_messages_sender_id", table_name="messages")
op.drop_table("messages")
op.drop_index("ix_conversation_participants_user_id", table_name="conversation_participants")
op.drop_index(
"ix_conversation_participants_conversation_id", table_name="conversation_participants"
)
op.drop_table("conversation_participants")
op.drop_index("ix_conversations_user_b_id", table_name="conversations")
op.drop_index("ix_conversations_user_a_id", table_name="conversations")
op.drop_table("conversations")
postgresql.ENUM(name="message_report_status").drop(op.get_bind(), checkfirst=True)
@@ -1,80 +0,0 @@
"""add group tables (groups, group_members)
Revision ID: 0009_add_group_tables
Revises: 0008_add_chat_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-5 / app/models/groups.py. Models + migration only
-- create/join/leave, role authorization, and the group page endpoint live
in a sibling task ("Groups API") that depends on this one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0009_add_group_tables"
down_revision: Union[str, None] = "0008_add_chat_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
group_role = postgresql.ENUM("owner", "admin", "member", name="group_role")
group_role.create(op.get_bind(), checkfirst=True)
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"owner_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_groups_owner_id", "groups", ["owner_id"])
op.create_table(
"group_members",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False),
nullable=False,
server_default="member",
),
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
op.create_index("ix_group_members_user_id", "group_members", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_group_members_user_id", table_name="group_members")
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_owner_id", table_name="groups")
op.drop_table("groups")
postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True)
@@ -1,65 +0,0 @@
"""add profile tables (profiles, profile_favorites)
Revision ID: 0010_add_profile_tables
Revises: 0009_add_group_tables
Create Date: 2026-09-15 00:00:00.000000
app/models/profile.py. Models + migration + owner-only /api/profile/me
endpoints only -- the public profile view endpoint (GET /api/profile/{user_id})
belongs to a sibling task that depends on this one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0010_add_profile_tables"
down_revision: Union[str, None] = "0009_add_group_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"profiles",
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column(
"avatar_asset_id",
sa.Integer(),
sa.ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("bio", sa.Text(), nullable=True),
sa.Column("privacy_json", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_profiles_avatar_asset_id", "profiles", ["avatar_asset_id"])
op.create_table(
"profile_favorites",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"world_id",
sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_profile_favorites_world_id", "profile_favorites", ["world_id"])
def downgrade() -> None:
op.drop_index("ix_profile_favorites_world_id", table_name="profile_favorites")
op.drop_table("profile_favorites")
op.drop_index("ix_profiles_avatar_asset_id", table_name="profiles")
op.drop_table("profiles")
@@ -1,36 +0,0 @@
"""add last_read_at to conversation_participants (DM unread tracking)
Revision ID: 0011_add_dm_last_read
Revises: 0010_add_profile_tables
Create Date: 2026-09-15 00:00:00.000000
app/routers/chat_dm.py (DM send/list/conversations endpoints). Unread-count
tracking is implemented as a last-read timestamp per participant: whenever a
user fetches a conversation's message history, their
``conversation_participants`` row's ``last_read_at`` is bumped to "now", and
the conversation-list endpoint counts messages with
``created_at > last_read_at`` (or all messages if never read) as unread for
that participant. Nullable/no default so existing rows (none yet in
practice, but consistent with "never read") read as "everything unread".
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0011_add_dm_last_read"
down_revision: Union[str, None] = "0010_add_profile_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"conversation_participants",
sa.Column("last_read_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("conversation_participants", "last_read_at")
@@ -1,90 +0,0 @@
"""add wallets and transactions ledger tables
Revision ID: 0012_add_wallet_and_transactions
Revises: 0011_add_dm_last_read
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-1 (task 8343bc0a730f).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0012_add_wallet_and_transactions"
down_revision: Union[str, None] = "0011_add_dm_last_read"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TRANSACTION_TYPES = (
"credit_purchase",
"marketplace_purchase",
"marketplace_sale",
"escrow_release",
"payout",
"refund",
"admin_adjustment",
)
def upgrade() -> None:
# 1. Create transaction_type enum for Postgres
bind = op.get_bind()
if bind.dialect.name == "postgresql":
tx_type_enum = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
tx_type_enum.create(bind, checkfirst=True)
tx_type_col = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
else:
tx_type_col = sa.Enum(*TRANSACTION_TYPES, name="transaction_type")
# 2. Create wallets table
op.create_table(
"wallets",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("balance", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("balance >= 0", name="check_wallet_balance_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_wallets_user_id"),
)
op.create_index(op.f("ix_wallets_user_id"), "wallets", ["user_id"], unique=True)
# 3. Create transactions table
op.create_table(
"transactions",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("wallet_id", sa.BigInteger(), nullable=False),
sa.Column("type", tx_type_col, nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("balance_after", sa.BigInteger(), nullable=False),
sa.Column("reference_type", sa.String(), nullable=True),
sa.Column("reference_id", sa.String(), nullable=True),
sa.Column("idempotency_key", sa.String(), nullable=True),
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if bind.dialect.name == "postgresql" else sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("idempotency_key", name="uq_transactions_idempotency_key"),
)
op.create_index(op.f("ix_transactions_wallet_id"), "transactions", ["wallet_id"], unique=False)
op.create_index(op.f("ix_transactions_idempotency_key"), "transactions", ["idempotency_key"], unique=True)
op.create_index(op.f("ix_transactions_created_at"), "transactions", ["created_at"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_transactions_created_at"), table_name="transactions")
op.drop_index(op.f("ix_transactions_idempotency_key"), table_name="transactions")
op.drop_index(op.f("ix_transactions_wallet_id"), table_name="transactions")
op.drop_table("transactions")
op.drop_index(op.f("ix_wallets_user_id"), table_name="wallets")
op.drop_table("wallets")
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(*TRANSACTION_TYPES, name="transaction_type").drop(bind, checkfirst=True)
@@ -1,146 +0,0 @@
"""add marketplace tables: catalog_items, marketplace_listings, purchases, creator_balances
Revision ID: 0013_add_marketplace_tables
Revises: 0012_add_wallet_and_transactions
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_marketplace_tables"
down_revision: Union[str, None] = "0012_add_wallet_and_transactions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ITEM_TYPES = ("asset", "avatar_item", "experience_pass")
LISTING_STATUSES = ("active", "sold", "delisted", "removed_by_moderation")
PURCHASE_STATUSES = ("pending_escrow", "completed", "refunded", "reversed")
def upgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
# Enums
if is_pg:
item_type_enum = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
item_type_enum.create(bind, checkfirst=True)
item_type_col = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
listing_status_enum = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
listing_status_enum.create(bind, checkfirst=True)
listing_status_col = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
purchase_status_enum = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
purchase_status_enum.create(bind, checkfirst=True)
purchase_status_col = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
else:
item_type_col = sa.Enum(*ITEM_TYPES, name="catalog_item_type")
listing_status_col = sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status")
purchase_status_col = sa.Enum(*PURCHASE_STATUSES, name="purchase_status")
# 1. catalog_items
op.create_table(
"catalog_items",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("asset_id", sa.Integer(), nullable=True),
sa.Column("item_type", item_type_col, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=2000), nullable=True),
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if is_pg else sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_catalog_items_owner_id"), "catalog_items", ["owner_id"], unique=False)
op.create_index(op.f("ix_catalog_items_asset_id"), "catalog_items", ["asset_id"], unique=False)
# 2. marketplace_listings
op.create_table(
"marketplace_listings",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("catalog_item_id", sa.BigInteger(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("status", listing_status_col, server_default="active", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("price > 0", name="check_listing_price_positive"),
sa.ForeignKeyConstraint(["catalog_item_id"], ["catalog_items.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_marketplace_listings_catalog_item_id"), "marketplace_listings", ["catalog_item_id"], unique=False)
op.create_index(op.f("ix_marketplace_listings_seller_id"), "marketplace_listings", ["seller_id"], unique=False)
# 3. purchases
op.create_table(
"purchases",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("listing_id", sa.BigInteger(), nullable=True),
sa.Column("buyer_id", sa.Integer(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("platform_fee", sa.BigInteger(), nullable=False),
sa.Column("seller_net", sa.BigInteger(), nullable=False),
sa.Column("status", purchase_status_col, server_default="pending_escrow", nullable=False),
sa.Column("escrow_hold_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["listing_id"], ["marketplace_listings.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["buyer_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_purchases_listing_id"), "purchases", ["listing_id"], unique=False)
op.create_index(op.f("ix_purchases_buyer_id"), "purchases", ["buyer_id"], unique=False)
op.create_index(op.f("ix_purchases_seller_id"), "purchases", ["seller_id"], unique=False)
# 4. creator_balances
op.create_table(
"creator_balances",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("available", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("pending", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("lifetime_earned", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
sa.CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
sa.CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_creator_balances_user_id"),
)
op.create_index(op.f("ix_creator_balances_user_id"), "creator_balances", ["user_id"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_creator_balances_user_id"), table_name="creator_balances")
op.drop_table("creator_balances")
op.drop_index(op.f("ix_purchases_seller_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_buyer_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_listing_id"), table_name="purchases")
op.drop_table("purchases")
op.drop_index(op.f("ix_marketplace_listings_seller_id"), table_name="marketplace_listings")
op.drop_index(op.f("ix_marketplace_listings_catalog_item_id"), table_name="marketplace_listings")
op.drop_table("marketplace_listings")
op.drop_index(op.f("ix_catalog_items_asset_id"), table_name="catalog_items")
op.drop_index(op.f("ix_catalog_items_owner_id"), table_name="catalog_items")
op.drop_table("catalog_items")
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(*PURCHASE_STATUSES, name="purchase_status").drop(bind, checkfirst=True)
sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status").drop(bind, checkfirst=True)
sa.Enum(*ITEM_TYPES, name="catalog_item_type").drop(bind, checkfirst=True)
@@ -1,68 +0,0 @@
"""add payout_requests table and payout_status enum
Revision ID: 0014_add_payout_requests
Revises: 0013_add_marketplace_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-5 (task f5b4684f2e65).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014_add_payout_requests"
down_revision: Union[str, None] = "0013_add_marketplace_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PAYOUT_STATUSES = ("pending", "approved", "paid", "rejected")
def upgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
if is_pg:
payout_status_enum = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
payout_status_enum.create(bind, checkfirst=True)
payout_status_col = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
else:
payout_status_col = sa.Enum(*PAYOUT_STATUSES, name="payout_status")
op.create_table(
"payout_requests",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("creator_id", sa.Integer(), nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("status", payout_status_col, server_default="pending", nullable=False),
sa.Column("external_ref", sa.String(length=255), nullable=True),
sa.Column("provider_ref", sa.String(length=255), nullable=True),
sa.Column("requested_by", sa.Integer(), nullable=False),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requested_by"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
sa.CheckConstraint("amount > 0", name="check_payout_amount_positive"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_payout_requests_creator_id", "payout_requests", ["creator_id"], unique=False)
op.create_index("ix_payout_requests_requested_by", "payout_requests", ["requested_by"], unique=False)
op.create_index("ix_payout_requests_reviewed_by", "payout_requests", ["reviewed_by"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
op.drop_index("ix_payout_requests_reviewed_by", table_name="payout_requests")
op.drop_index("ix_payout_requests_requested_by", table_name="payout_requests")
op.drop_index("ix_payout_requests_creator_id", table_name="payout_requests")
op.drop_table("payout_requests")
if is_pg:
op.execute("DROP TYPE IF EXISTS payout_status")
@@ -1,62 +0,0 @@
"""add discovery metadata to worlds and create world_visits table
Revision ID: 0015_add_discovery_metadata
Revises: 0014_add_payout_requests
Create Date: 2026-09-15 00:00:00.000000
docs/planning/discovery.md (task b377353b8167).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0015_add_discovery_metadata"
down_revision: Union[str, None] = "0014_add_payout_requests"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add discovery metadata columns to worlds table
op.add_column("worlds", sa.Column("category", sa.String(length=100), nullable=True))
op.add_column("worlds", sa.Column("tags", postgresql.ARRAY(sa.String()), server_default="{}", nullable=True))
op.add_column("worlds", sa.Column("play_count", sa.Integer(), server_default="0", nullable=False))
op.add_column("worlds", sa.Column("last_played_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_worlds_category", "worlds", ["category"], unique=False)
op.create_index("ix_worlds_tags", "worlds", ["tags"], unique=False, postgresql_using="gin")
# 2. Create world_visits table
op.create_table(
"world_visits",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("world_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("anonymous_token", sa.String(length=255), nullable=True),
sa.Column("visited_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_world_visits_world_id", "world_visits", ["world_id"], unique=False)
op.create_index("ix_world_visits_user_id", "world_visits", ["user_id"], unique=False)
op.create_index("ix_world_visits_visited_at", "world_visits", ["visited_at"], unique=False)
op.create_index("ix_world_visits_world_visited_at", "world_visits", ["world_id", "visited_at"], unique=False)
def downgrade() -> None:
op.drop_index("ix_world_visits_world_visited_at", table_name="world_visits")
op.drop_index("ix_world_visits_visited_at", table_name="world_visits")
op.drop_index("ix_world_visits_user_id", table_name="world_visits")
op.drop_index("ix_world_visits_world_id", table_name="world_visits")
op.drop_table("world_visits")
op.drop_index("ix_worlds_tags", table_name="worlds", postgresql_using="gin")
op.drop_index("ix_worlds_category", table_name="worlds")
op.drop_column("worlds", "last_played_at")
op.drop_column("worlds", "play_count")
op.drop_column("worlds", "tags")
op.drop_column("worlds", "category")
@@ -1,30 +0,0 @@
"""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")
@@ -1,53 +0,0 @@
"""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")
+5 -121
View File
@@ -1,56 +1,10 @@
"""Application configuration via pydantic-settings.""" """Application configuration via pydantic-settings."""
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey,
RSAPublicKey,
generate_private_key,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
load_pem_public_key,
)
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
"""Load existing RSA keys or generate a new pair.
Keys are stored as PEM files in *key_dir*. The directory is created
if it does not exist. Files are named ``private.pem`` and
``public.pem`` and are **not** committed to git.
"""
private_path = key_dir / "private.pem"
public_path = key_dir / "public.pem"
if private_path.exists() and public_path.exists():
private_pem = private_path.read_bytes()
public_pem = public_path.read_bytes()
else:
# Generate a new 2048-bit RSA key pair
private_key = generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
Encoding.PEM,
PrivateFormat.PKCS8,
NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
Encoding.PEM,
PublicFormat.SubjectPublicKeyInfo,
)
key_dir.mkdir(parents=True, exist_ok=True)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
return private_pem, public_pem
class Settings(BaseSettings): class Settings(BaseSettings):
# App # App
APP_NAME: str = "AGP Backend" APP_NAME: str = "AGP Backend"
@@ -64,62 +18,18 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0" REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth # JWT / Auth
ALGORITHM: str = "RS256" # NOTE: production must override SECRET_KEY with a secure random value
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 # (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
REFRESH_TOKEN_EXPIRE_DAYS: int = 30 SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256"
# RS256 key storage. Defaults to a directory outside the repo so the ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# generated keypair is never accidentally committed.
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
# CORS # CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000" CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
# Per-IP auth rate limiting (app/rate_limit.py). Disabled by the
# checkpoint smoke suite, which runs every test from one client IP.
RATE_LIMIT_ENABLED: bool = True
# File uploads # File uploads
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100 MAX_UPLOAD_SIZE_MB: int = 100
GLTF_MAX_FILE_SIZE_MB: int = 200
# ── Game-server orchestration ──────────────────────────────────────────
# The join flow (POST /api/worlds/{id}/join) ensures a headless Godot
# dedicated-server process exists for a world and returns its address.
# Disabled by default so nothing tries to spawn Godot in environments
# that don't have it (plain pytest, CI without the engine); the
# checkpoint/playtest launcher turns it on.
GAME_SERVERS_ENABLED: bool = False
# Path to the Godot 4 binary. "godot" resolves via PATH.
GODOT_BIN: str = str(Path.home() / "godot" / "godot")
# Godot project directory (the game repo root -- backend/ is a subdir).
GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2])
# The host clients should connect to for ENet. Defaults to the LAN IP the
# launcher bakes into the exe; overridable for local testing.
GAME_SERVER_HOST: str = "127.0.0.1"
# Port range for per-world dedicated servers.
GAME_SERVER_PORT_MIN: int = 27000
GAME_SERVER_PORT_MAX: int = 27099
# Kill a world's server this long after its last join with no active
# players reported.
GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300
# How long ensure_server() waits for a spawned server to report
# "ready": true in its status file before giving up (503 to the
# joining client). See world_persistence_impl.md §7.4 -- content
# loading makes a slow start more likely than the old bare-port-bind
# wait, so this is a separate, longer-by-default budget.
GAME_SERVER_READY_TIMEOUT_SECONDS: float = 20.0
# The base URL the dedicated Godot server should call back into this
# backend on, for the read-only /api/internal content routes
# (world_persistence_impl.md §4.4 / §7).
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
# ── World content persistence (world_persistence_impl.md §4.4) ─────────
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
@@ -131,32 +41,6 @@ class Settings(BaseSettings):
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@property
def rsa_key_dir(self) -> Path:
return Path(self.RSA_KEY_DIR)
@property
def private_pem(self) -> bytes:
"""Return the private key PEM bytes (loads/generates on first call)."""
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
return private_pem
@property
def public_pem(self) -> bytes:
"""Return the public key PEM bytes (loads/generates on first call)."""
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
return public_pem
@property
def private_key(self) -> RSAPrivateKey:
"""Return the deserialized RSA private key object."""
return load_pem_private_key(self.private_pem, password=None)
@property
def public_key(self) -> RSAPublicKey:
"""Return the deserialized RSA public key object."""
return load_pem_public_key(self.public_pem)
@lru_cache() @lru_cache()
def get_settings() -> Settings: def get_settings() -> Settings:
+1 -31
View File
@@ -27,7 +27,7 @@ async def get_current_user(
) )
try: try:
payload = jwt.decode( payload = jwt.decode(
token, settings.public_pem, algorithms=[settings.ALGORITHM] token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
) )
user_id: str | None = payload.get("sub") user_id: str | None = payload.get("sub")
if user_id is None: if user_id is None:
@@ -53,33 +53,3 @@ async def get_current_user(
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_optional_user(
token: Annotated[str | None, Depends(oauth2_scheme_optional)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User | None:
"""Resolve the JWT bearer token to a User if present and valid, otherwise None."""
if not token:
return None
try:
payload = jwt.decode(
token, settings.public_pem, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
return None
user_id_int = int(user_id)
except (JWTError, ValueError, TypeError):
return None
result = await db.execute(select(User).where(User.id == user_id_int))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
-1
View File
@@ -1 +0,0 @@
"""WebSocket real-time gateway package."""
-538
View File
@@ -1,538 +0,0 @@
"""Real-time WebSocket gateway.
Multiplexes presence, DM, and world-chat delivery over a single WebSocket
endpoint (docs/planning/social.md US-2, US-4).
Key features:
- Authentication via JWT token (query param or initial auth frame).
- Scoped subscriptions to DM conversations and world channels.
- Server-derived presence heartbeat (Redis TTL refreshed periodically).
- Broadcast presence changes (online/offline) to mutual friends.
- Fan-out via Redis pub/sub.
- Thread-safe & event-loop-safe dispatch for multi-instance and test environments.
- Graceful reconnect without duplicate delivery or orphaned state.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from jose import JWTError, jwt
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.config import settings
from app.database import async_session_factory, get_db
from app.models.chat import ConversationParticipant
from app.models.social import Friendship, FriendshipStatus
from app.models.user import User
from app.models.world import World, WorldVisibility
from app.services.presence import presence_store
logger = logging.getLogger(__name__)
router = APIRouter()
@asynccontextmanager
async def get_db_session():
"""Helper to acquire an async DB session respecting test overrides if present."""
from app.main import app
override = app.dependency_overrides.get(get_db)
if override:
res = override()
if hasattr(res, "__anext__"):
session = await res.__anext__()
try:
yield session
finally:
try:
await res.__anext__()
except (StopAsyncIteration, Exception):
pass
return
elif hasattr(res, "__next__"):
session = next(res)
try:
yield session
finally:
try:
next(res)
except (StopIteration, Exception):
pass
return
async with async_session_factory() as session:
yield session
async def authenticate_token(token: str) -> User | None:
"""Validate JWT token and return authenticated User, or None."""
try:
payload = jwt.decode(token, settings.public_pem, algorithms=[settings.ALGORITHM])
user_id_raw = payload.get("sub")
if user_id_raw is None:
return None
user_id = int(user_id_raw)
except (JWTError, ValueError, TypeError):
return None
async with get_db_session() as db:
res = await db.execute(select(User).where(User.id == user_id))
user = res.scalar_one_or_none()
if user and user.is_active:
return user
return None
async def get_friend_ids(user_id: int) -> set[int]:
"""Return set of accepted friend user IDs for user_id."""
try:
async with get_db_session() as db:
res = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = res.scalars().all()
return {
f.addressee_id if f.requester_id == user_id else f.requester_id
for f in friendships
}
except Exception as exc:
logger.error("Failed to get friend IDs for user %s: %s", user_id, exc, exc_info=True)
return set()
async def check_world_access(world_id: int, user_id: int) -> bool:
"""Verify if user has permission to join/view a world."""
async with get_db_session() as db:
res = await db.execute(select(World).where(World.id == world_id))
world = res.scalar_one_or_none()
if not world:
return False
if world.visibility == WorldVisibility.PUBLIC:
return True
if world.owner_id == user_id:
return True
if world.visibility == WorldVisibility.FRIENDS_ONLY:
res_fr = await db.execute(
select(Friendship.id).where(
or_(
and_(Friendship.requester_id == world.owner_id, Friendship.addressee_id == user_id),
and_(Friendship.requester_id == user_id, Friendship.addressee_id == world.owner_id),
),
Friendship.status == FriendshipStatus.ACCEPTED,
).limit(1)
)
return res_fr.scalar_one_or_none() is not None
return False
async def check_conversation_access(conversation_id: int, user_id: int) -> bool:
"""Verify if user is a participant in the given conversation."""
async with get_db_session() as db:
res = await db.execute(
select(ConversationParticipant.id).where(
ConversationParticipant.conversation_id == conversation_id,
ConversationParticipant.user_id == user_id,
).limit(1)
)
return res.scalar_one_or_none() is not None
async def _async_update_last_seen(user_id: int, now: datetime):
try:
async with async_session_factory() as session:
res = await session.execute(select(User).where(User.id == user_id))
db_user = res.scalar_one_or_none()
if db_user:
db_user.last_seen_at = now
await session.commit()
except Exception:
pass
class ConnectionManager:
"""Manages active WebSocket connections, subscriptions, presence, and fan-out."""
def __init__(self):
# ws -> User
self._connections: dict[WebSocket, User] = {}
# ws -> event loop id
self._ws_loops: dict[WebSocket, int] = {}
# user_id -> set of active WebSockets
self._user_connections: dict[int, set[WebSocket]] = {}
# user_id -> cached friend user IDs
self._user_friend_ids: dict[int, set[int]] = {}
# world_id -> set of WebSockets
self._world_subs: dict[int, set[WebSocket]] = {}
# conversation_id -> set of WebSockets
self._dm_subs: dict[int, set[WebSocket]] = {}
# ws -> asyncio heartbeat task
self._heartbeat_tasks: dict[WebSocket, asyncio.Task] = {}
# Per-event-loop Redis pub/sub listener tasks and events
self._redis_tasks: dict[int, asyncio.Task] = {}
self._subscribed_events: dict[int, asyncio.Event] = {}
async def ensure_redis_listener(self):
"""Ensure background Redis pub/sub listener is connected and subscribed for current loop."""
loop = asyncio.get_running_loop()
loop_key = id(loop)
task = self._redis_tasks.get(loop_key)
if task is None or task.done():
event = asyncio.Event()
self._subscribed_events[loop_key] = event
task = asyncio.create_task(self._redis_listener(loop_key, event))
self._redis_tasks[loop_key] = task
else:
event = self._subscribed_events.get(loop_key)
if event:
try:
await asyncio.wait_for(event.wait(), timeout=2.0)
except asyncio.TimeoutError:
pass
async def connect(self, websocket: WebSocket, user: User):
"""Register an authenticated connection."""
await self.ensure_redis_listener()
loop_key = id(asyncio.get_running_loop())
self._connections[websocket] = user
self._ws_loops[websocket] = loop_key
self._user_connections.setdefault(user.id, set()).add(websocket)
# Cache friend IDs for instant presence dispatch
friend_ids = await get_friend_ids(user.id)
self._user_friend_ids[user.id] = friend_ids
# Mark user online in Redis and DB
await presence_store.heartbeat(user.id)
now = datetime.now(timezone.utc)
asyncio.create_task(_async_update_last_seen(user.id, now))
# Start periodic heartbeat task (every 20 seconds)
self._heartbeat_tasks[websocket] = asyncio.create_task(
self._run_periodic_heartbeat(user.id)
)
# Broadcast online presence to friends via Redis
await self.broadcast_presence(user.id, user.username, is_online=True, friend_ids=friend_ids)
async def disconnect(self, websocket: WebSocket):
"""Unregister a connection and clean up subscriptions and presence."""
user = self._connections.pop(websocket, None)
self._ws_loops.pop(websocket, None)
task = self._heartbeat_tasks.pop(websocket, None)
if task:
task.cancel()
# Clean subscriptions
for world_id in list(self._world_subs.keys()):
self._world_subs[world_id].discard(websocket)
if not self._world_subs[world_id]:
self._world_subs.pop(world_id, None)
for conv_id in list(self._dm_subs.keys()):
self._dm_subs[conv_id].discard(websocket)
if not self._dm_subs[conv_id]:
self._dm_subs.pop(conv_id, None)
if user is None:
return
user_id = user.id
username = user.username
user_set = self._user_connections.get(user_id)
if user_set:
user_set.discard(websocket)
if not user_set:
self._user_connections.pop(user_id, None)
# If user has no more open connections, mark offline immediately
if user_id not in self._user_connections:
await presence_store.clear_heartbeat(user_id)
now = datetime.now(timezone.utc)
asyncio.create_task(_async_update_last_seen(user_id, now))
friend_ids = self._user_friend_ids.pop(user_id, set())
await self.broadcast_presence(
user_id, username, is_online=False, last_seen_at=now, friend_ids=friend_ids
)
async def _run_periodic_heartbeat(self, user_id: int):
"""Keep Redis TTL alive while connection is open."""
try:
while True:
await asyncio.sleep(20)
await presence_store.heartbeat(user_id)
except asyncio.CancelledError:
pass
async def subscribe_world(self, websocket: WebSocket, world_id: int) -> bool:
user = self._connections.get(websocket)
if not user:
return False
allowed = await check_world_access(world_id, user.id)
if not allowed:
return False
self._world_subs.setdefault(world_id, set()).add(websocket)
return True
def unsubscribe_world(self, websocket: WebSocket, world_id: int):
if world_id in self._world_subs:
self._world_subs[world_id].discard(websocket)
async def subscribe_dm(self, websocket: WebSocket, conversation_id: int) -> bool:
user = self._connections.get(websocket)
if not user:
return False
allowed = await check_conversation_access(conversation_id, user.id)
if not allowed:
return False
self._dm_subs.setdefault(conversation_id, set()).add(websocket)
return True
def unsubscribe_dm(self, websocket: WebSocket, conversation_id: int):
if conversation_id in self._dm_subs:
self._dm_subs[conversation_id].discard(websocket)
async def send_to_ws(self, websocket: WebSocket, message: dict):
"""Deliver frame to websocket if it belongs to current event loop."""
cur_loop = id(asyncio.get_running_loop())
if self._ws_loops.get(websocket) != cur_loop:
return
try:
await websocket.send_text(json.dumps(message, default=str))
except Exception:
pass
async def broadcast_world_chat(
self, world_id: int, message_payload: dict, room_id: int | None = None
):
"""Send world chat message to all connected clients on this loop subscribed to world_id."""
targets = list(self._world_subs.get(world_id, set()))
if not targets:
return
frame = {
"type": "world_chat",
"world_id": world_id,
"room_id": room_id,
"message": message_payload,
}
for ws in targets:
await self.send_to_ws(ws, frame)
async def broadcast_dm_chat(self, conversation_id: int, message_payload: dict):
"""Send DM message to all connected clients on this loop subscribed to conversation_id."""
targets = list(self._dm_subs.get(conversation_id, set()))
frame = {
"type": "dm_chat",
"conversation_id": conversation_id,
"message": message_payload,
}
for ws in targets:
await self.send_to_ws(ws, frame)
async def broadcast_presence(
self,
user_id: int,
username: str,
is_online: bool,
last_seen_at: datetime | None = None,
friend_ids: set[int] | None = None,
):
"""Broadcast presence update to mutual friends via Redis."""
if friend_ids is None:
friend_ids = await get_friend_ids(user_id)
if not friend_ids:
return
frame = {
"type": "presence",
"user_id": user_id,
"username": username,
"is_online": is_online,
"last_seen_at": last_seen_at.isoformat() if last_seen_at else None,
"friend_ids": list(friend_ids),
}
# Redis broadcast for all loops/instances
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async with client:
await client.publish("presence:events", json.dumps(frame, default=str))
except Exception as exc:
logger.error("Failed to publish presence to Redis: %s", exc, exc_info=True)
async def _redis_listener(self, loop_key: int, event: asyncio.Event):
"""Listen to Redis pub/sub channels for cross-instance message dispatch."""
while True:
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
pubsub = client.pubsub()
await pubsub.psubscribe("world:*:chat", "dm:*:chat", "presence:events")
event.set()
logger.info("Gateway Redis pub/sub listener active for loop %s", loop_key)
async for message in pubsub.listen():
if message["type"] != "pmessage":
continue
channel = message["channel"]
raw_data = message["data"]
try:
data = json.loads(raw_data)
except Exception:
continue
if channel.startswith("world:") and channel.endswith(":chat"):
parts = channel.split(":")
world_id = int(parts[1])
await self.broadcast_world_chat(world_id, data, room_id=data.get("room_id"))
elif channel.startswith("dm:") and channel.endswith(":chat"):
parts = channel.split(":")
conv_id = int(parts[1])
await self.broadcast_dm_chat(conv_id, data)
elif channel == "presence:events":
logger.info("Redis presence event received: %s. Active users: %s", data, list(self._user_connections.keys()))
for friend_id in data.get("friend_ids", []):
targets = list(self._user_connections.get(friend_id, set()))
logger.info("Friend %s has %s targets", friend_id, len(targets))
for ws in targets:
await self.send_to_ws(ws, data)
except asyncio.CancelledError:
break
except Exception as exc:
logger.debug("Redis listener encountered error, retrying in 2s: %s", exc)
event.clear()
await asyncio.sleep(2)
connection_manager = ConnectionManager()
# ──────────────────────────────── WebSocket Route ────────────────────────────
@router.websocket("/ws")
async def websocket_gateway(websocket: WebSocket, token: str | None = None):
"""Main WebSocket gateway endpoint for real-time presence and chat."""
user: User | None = None
# 1. Check query parameter token
if token:
user = await authenticate_token(token)
if user is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept()
# 2. If not authenticated via query param, wait for first auth frame
if user is None:
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=3.0)
data = json.loads(raw)
if data.get("type") == "auth" and data.get("token"):
user = await authenticate_token(data["token"])
except (asyncio.TimeoutError, json.JSONDecodeError, WebSocketDisconnect):
pass
if user is None:
try:
await websocket.send_text(json.dumps({"type": "error", "message": "Authentication failed"}))
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
except Exception:
pass
return
# Mark user online in Redis immediately upon successful auth
await presence_store.heartbeat(user.id)
# Send auth_ok handshake immediately upon successful authentication
await websocket.send_text(
json.dumps({
"type": "auth_ok",
"user_id": user.id,
"username": user.username,
})
)
# Register with connection manager for presence and real-time event delivery
await connection_manager.connect(websocket, user)
try:
while True:
text = await websocket.receive_text()
try:
msg = json.loads(text)
except json.JSONDecodeError:
await websocket.send_text(json.dumps({"type": "error", "message": "Invalid JSON"}))
continue
msg_type = msg.get("type")
if msg_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif msg_type == "heartbeat":
await presence_store.heartbeat(user.id)
await websocket.send_text(json.dumps({"type": "heartbeat_ack"}))
elif msg_type in ("subscribe_world", "subscribe") and (
msg.get("world_id") or msg.get("channel") == "world"
):
world_id = msg.get("world_id") or msg.get("id")
if isinstance(world_id, int):
ok = await connection_manager.subscribe_world(websocket, world_id)
if ok:
await websocket.send_text(
json.dumps({"type": "subscribed", "channel": "world", "id": world_id})
)
else:
await websocket.send_text(
json.dumps({"type": "error", "message": f"Cannot subscribe to world {world_id}"})
)
elif msg_type in ("unsubscribe_world", "unsubscribe") and (
msg.get("world_id") or msg.get("channel") == "world"
):
world_id = msg.get("world_id") or msg.get("id")
if isinstance(world_id, int):
connection_manager.unsubscribe_world(websocket, world_id)
await websocket.send_text(
json.dumps({"type": "unsubscribed", "channel": "world", "id": world_id})
)
elif msg_type in ("subscribe_dm", "subscribe") and (
msg.get("conversation_id") or msg.get("channel") == "dm"
):
conv_id = msg.get("conversation_id") or msg.get("id")
if isinstance(conv_id, int):
ok = await connection_manager.subscribe_dm(websocket, conv_id)
if ok:
await websocket.send_text(
json.dumps({"type": "subscribed", "channel": "dm", "id": conv_id})
)
else:
await websocket.send_text(
json.dumps({"type": "error", "message": f"Cannot subscribe to conversation {conv_id}"})
)
elif msg_type in ("unsubscribe_dm", "unsubscribe") and (
msg.get("conversation_id") or msg.get("channel") == "dm"
):
conv_id = msg.get("conversation_id") or msg.get("id")
if isinstance(conv_id, int):
connection_manager.unsubscribe_dm(websocket, conv_id)
await websocket.send_text(
json.dumps({"type": "unsubscribed", "channel": "dm", "id": conv_id})
)
else:
await websocket.send_text(json.dumps({"type": "error", "message": f"Unknown action: {msg_type}"}))
except WebSocketDisconnect:
pass
finally:
await connection_manager.disconnect(websocket)
+3 -33
View File
@@ -6,23 +6,15 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import ( from app.routers import auth, users, worlds, assets
auth, users, worlds, assets, asset_collections, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, marketplace, payouts, discovery,
)
from app.gateway import ws
from app.services.game_servers import supervisor
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown events.""" """Application lifespan: startup and shutdown events."""
supervisor.start() # Startup: initialize database, connect to Redis, etc.
yield yield
await supervisor.shutdown() # Shutdown: close connections, cleanup
app = FastAPI( app = FastAPI(
@@ -45,29 +37,7 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"]) app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(asset_collections.router, prefix="/api/assets/collections", tags=["asset-collections"])
app.include_router(asset_collections.router, prefix="/assets/collections", tags=["asset-collections"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
app.include_router(groups.router, prefix="/api/groups", tags=["groups"])
app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"])
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"])
app.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"])
app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"])
app.include_router(chat_world.router, prefix="/api/chat", tags=["chat-world"])
app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"])
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"])
app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"])
app.include_router(ws.router, prefix="/api", tags=["websocket"])
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
@app.get("/api/health") @app.get("/api/health")
+1 -27
View File
@@ -2,31 +2,5 @@ from app.models.user import User
from app.models.session import Session from app.models.session import Session
from app.models.world import World from app.models.world import World
from app.models.asset import Asset from app.models.asset import Asset
from app.models.asset_collection import AssetCollection, CollectionAsset
from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
)
from app.models.groups import Group, GroupMember, GroupRole
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
from app.models.wallet import Wallet, Transaction, TransactionType
from app.models.marketplace import (
CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
ItemType, ListingStatus, PurchaseStatus,
)
from app.models.world_visit import WorldVisit
__all__ = [ __all__ = ["User", "Session", "World", "Asset"]
"User", "Session", "World", "Asset", "AssetCollection", "CollectionAsset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole",
"Profile", "ProfileFavorite", "ProfileVisibility",
"Wallet", "Transaction", "TransactionType",
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
"ItemType", "ListingStatus", "PurchaseStatus",
"WorldVisit",
]
+1 -6
View File
@@ -23,12 +23,7 @@ class Asset(Base):
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
asset_type = Column( asset_type = Column(
ENUM( ENUM(AssetType, name="asset_type", create_type=False),
AssetType,
name="asset_type",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False, nullable=False,
) )
file_path = Column(String(512), nullable=False) file_path = Column(String(512), nullable=False)
-53
View File
@@ -1,53 +0,0 @@
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")

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