Compare commits
203 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8822a03f53 | |||
| 6809018ef1 | |||
| 43a3b888da | |||
| 181ba6ae02 | |||
| 4200a8b358 | |||
| 1e917e196b | |||
| eb4da62f99 | |||
| 3fb45f0ef2 | |||
| 1b1260f371 | |||
| 9731615c34 | |||
| 6ad91b895c | |||
| 44bfcb1871 | |||
| 3639fb3132 | |||
| da4e1420ee | |||
| 80f24bfd01 | |||
| 54bc763dff | |||
| 87f78b0226 | |||
| bd1bdc417a | |||
| 7ccdd3063a | |||
| 67473dddcd | |||
| d76bf4da19 | |||
| 4df1caa279 | |||
| 02747cc266 | |||
| 187ec90440 | |||
| fb52c4fd08 | |||
| bc3901da68 | |||
| 7bd8124199 | |||
| 855311e731 | |||
| 50c6a31c25 | |||
| dba865ae4b | |||
| 260b7a38da | |||
| 682f1f89f7 | |||
| 587a15a52e | |||
| e59cf158e5 | |||
| af85048deb | |||
| d6cd3293ae | |||
| f51c49fcfa | |||
| 3596922696 | |||
| 1526f3a636 | |||
| b6e146e4b2 | |||
| 59ca5a4c6e | |||
| b422e41a0f | |||
| bd44ecba14 | |||
| bae326e14c | |||
| 140db9df91 | |||
| 2dff83bae3 | |||
| 1a8821e3c8 | |||
| 019dfca14f | |||
| 7be50e3313 | |||
| 29de1c5c3a | |||
| 92c10fa657 | |||
| 8357643072 | |||
| 736ebce0d0 | |||
| b60480798f | |||
| 77f7511a4f | |||
| 8be962248d | |||
| 7790670559 | |||
| c4eab8db92 | |||
| 4d754ae48e | |||
| e2a77b055f | |||
| 5966fa9044 | |||
| d1d03df2e6 | |||
| 4dda636ed0 | |||
| 945c498a31 | |||
| be0acb1862 | |||
| 8118f3074b | |||
| 4276a89a6a | |||
| 5803f47069 | |||
| e33b91d976 | |||
| 1c3c57dbd0 | |||
| fdc43bd5f1 | |||
| 406ff3457e | |||
| 3f7acf857c | |||
| 6648b2bba5 | |||
| bfcbd0486f | |||
| f70b5b2ee5 | |||
| 2fa7b1f475 | |||
| 7f753cb1b8 | |||
| 1be947f9ef | |||
| 66adcff145 | |||
| 88197ffe8f | |||
| 38d97a3051 | |||
| 174d3c2630 | |||
| b86e4053bd | |||
| 07f655815f | |||
| 2ffcd9cd3f | |||
| aeccc78143 | |||
| 1c64dd4967 | |||
| 4dd6c7c8af | |||
| e90b394f03 | |||
| 33b0c3f1fb | |||
| 2b88fc476e | |||
| 0ecc16d963 | |||
| 4ec71b87b9 | |||
| dc0c133af8 | |||
| 8f1a1110e2 | |||
| 0f7531008f | |||
| 12fef51020 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| 3a7b2ab306 | |||
| afc2211d70 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| fcfb767c49 | |||
| e2e771d2f8 | |||
| 8a5d3aabea | |||
| 3dd5416e81 | |||
| c5a5476620 | |||
| 06604a24a8 | |||
| 5c21e5051c | |||
| 3cb4d01073 | |||
| 3e81d60268 | |||
| b3d2d71789 | |||
| b3227f5b78 | |||
| 2ba6bc6b9f | |||
| 7d89e860fd | |||
| 03da22aa9e | |||
| 55a65bb7b1 | |||
| 62e15d0c87 | |||
| cbe5e992dd | |||
| d4998d7359 | |||
| ef78caa4de | |||
| 87575eec43 | |||
| 6d5051bbb2 | |||
| 72f39bc547 | |||
| 69b6f40eb9 | |||
| 4d467c0c9e | |||
| e43bc9ff10 | |||
| 5c91bb3f34 | |||
| ca7924d5e1 | |||
| 758307bbee | |||
| 478638d4ff | |||
| 7b074a01ed | |||
| c68877239f | |||
| ed6811a438 | |||
| f64ffd6422 | |||
| 7e28e2b525 | |||
| 99be8f1dab | |||
| b3e66fc072 | |||
| 634b2e39c0 | |||
| 1c75367c87 | |||
| f5a7162285 | |||
| 61dedcf73e | |||
| 580b95635f | |||
| ef21bf9d69 | |||
| e06cd2a23d | |||
| 77f9b74286 | |||
| 6238078565 | |||
| 6108c63c46 | |||
| 109c6ef1a0 | |||
| 33c5236a2d | |||
| 4bb4e9fb8a | |||
| ffbc4849a7 | |||
| a81d29c882 | |||
| 4236c4792b | |||
| a29cfc74af | |||
| cf8fd5a6fd | |||
| fc9a39de93 | |||
| aa2f79b125 | |||
| 8551d14591 | |||
| 073813f3e7 | |||
| cc971ee9f5 | |||
| 54abe7ee96 | |||
| a7cf385d0d | |||
| b5525665dd | |||
| ff610c0466 | |||
| acbc518b4a | |||
| 36fd406925 | |||
| ddfe5dfaad | |||
| dcaaf96d81 | |||
| 803405aba4 | |||
| ea6958c11e | |||
| 61ee81a974 | |||
| 62f12e55db | |||
| 4c0af03573 | |||
| 689c51e179 | |||
| 5cfbcd8efd | |||
| 27461d9b5e | |||
| a5dc5cb3b6 | |||
| ea5dd63cd7 | |||
| 5ebd60b0bb | |||
| b2a64a8933 | |||
| 9fcea730b9 | |||
| 5b3fc10649 | |||
| 6018396be2 | |||
| ee6513b8ad | |||
| 37a311b0c5 | |||
| 11e812cb6b | |||
| 5097cdd9f4 | |||
| 2224c34aa8 | |||
| 2c5f6fcb04 | |||
| 7cd41dc920 | |||
| c5d2eb6bd2 | |||
| b650572426 | |||
| 7d84dd5ec8 |
@@ -0,0 +1,39 @@
|
|||||||
|
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,3 +8,7 @@ export/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Godot UID files (regenerated from path)
|
||||||
|
*.uid
|
||||||
|
assets/build_info.json
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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.
@@ -0,0 +1,42 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"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}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"templates": {
|
||||||
|
"small_crate": {
|
||||||
|
"asset_id": "props/crate_small.glb",
|
||||||
|
"display_name": "Small Crate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ 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
|
# JWT / Auth
|
||||||
SECRET_KEY=change-me-to-a-secure-random-string
|
ALGORITHM=RS256
|
||||||
ALGORITHM=HS256
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
REFRESH_TOKEN_EXPIRE_DAYS=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
|
||||||
|
|||||||
@@ -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 # noqa: F401
|
from app.models import user, session, world, asset, avatar_loadout # 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.
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ def upgrade() -> None:
|
|||||||
sa.Column(
|
sa.Column(
|
||||||
"visibility",
|
"visibility",
|
||||||
postgresql.ENUM(
|
postgresql.ENUM(
|
||||||
"public", "private", "friends_only", name="world_visibility"
|
"public", "private", "friends_only", name="world_visibility",
|
||||||
|
create_type=False,
|
||||||
),
|
),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
@@ -89,7 +90,7 @@ def upgrade() -> None:
|
|||||||
"asset_type",
|
"asset_type",
|
||||||
postgresql.ENUM(
|
postgresql.ENUM(
|
||||||
"image", "audio", "video", "document", "model_3d", "other",
|
"image", "audio", "video", "document", "model_3d", "other",
|
||||||
name="asset_type",
|
name="asset_type", create_type=False,
|
||||||
),
|
),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""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,329 +0,0 @@
|
|||||||
"""trust & safety: reports, moderation_actions, appeals + user role/ban/suspend
|
|
||||||
|
|
||||||
Revision ID: 0003_trust_safety
|
|
||||||
Revises: 0002_add_date_of_birth_to_users
|
|
||||||
Create Date: 2025-01-01 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 = "0003_trust_safety"
|
|
||||||
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 trust & safety tables and extend users table."""
|
|
||||||
bind = op.get_bind()
|
|
||||||
|
|
||||||
# ── ENUM types ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# user_role
|
|
||||||
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
|
|
||||||
user_role.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# report_target_type
|
|
||||||
report_target_type = postgresql.ENUM(
|
|
||||||
"user", "world", "asset", "chat_message", name="report_target_type"
|
|
||||||
)
|
|
||||||
report_target_type.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# reason_category
|
|
||||||
reason_category = postgresql.ENUM(
|
|
||||||
"harassment",
|
|
||||||
"hate_speech",
|
|
||||||
"sexual_content",
|
|
||||||
"violence",
|
|
||||||
"spam",
|
|
||||||
"self_harm",
|
|
||||||
"other",
|
|
||||||
name="reason_category",
|
|
||||||
)
|
|
||||||
reason_category.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# report_status
|
|
||||||
report_status = postgresql.ENUM(
|
|
||||||
"open", "in_review", "resolved", "dismissed", name="report_status"
|
|
||||||
)
|
|
||||||
report_status.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# resolution
|
|
||||||
resolution_enum = postgresql.ENUM(
|
|
||||||
"action_taken", "no_action", "false_positive", name="resolution"
|
|
||||||
)
|
|
||||||
resolution_enum.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# moderation_action_type
|
|
||||||
moderation_action_type = postgresql.ENUM(
|
|
||||||
"warn", "suspend", "ban", "restrict", "remove", "restore",
|
|
||||||
name="moderation_action_type",
|
|
||||||
)
|
|
||||||
moderation_action_type.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# moderation_target_type
|
|
||||||
moderation_target_type = postgresql.ENUM(
|
|
||||||
"user", "world", "asset", "chat_message", name="moderation_target_type"
|
|
||||||
)
|
|
||||||
moderation_target_type.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# appeal_status
|
|
||||||
appeal_status = postgresql.ENUM(
|
|
||||||
"pending", "approved", "rejected", name="appeal_status"
|
|
||||||
)
|
|
||||||
appeal_status.create(bind, checkfirst=True)
|
|
||||||
|
|
||||||
# ── Extend users table ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column(
|
|
||||||
"role",
|
|
||||||
postgresql.ENUM("user", "moderator", "admin", name="user_role"),
|
|
||||||
nullable=False,
|
|
||||||
server_default="user",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── reports table ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"reports",
|
|
||||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("reporter_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"target_type",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"user", "world", "asset", "chat_message", name="report_target_type"
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("target_id", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"reason_category",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"harassment",
|
|
||||||
"hate_speech",
|
|
||||||
"sexual_content",
|
|
||||||
"violence",
|
|
||||||
"spam",
|
|
||||||
"self_harm",
|
|
||||||
"other",
|
|
||||||
name="reason_category",
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"details", sa.Text(), nullable=True,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"status",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"open", "in_review", "resolved", "dismissed", name="report_status"
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
server_default="open",
|
|
||||||
),
|
|
||||||
sa.Column("resolved_by", sa.Integer(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"resolution",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"action_taken", "no_action", "false_positive", name="resolution"
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"updated_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
onupdate=sa.func.now(),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"),
|
|
||||||
sa.UniqueConstraint(
|
|
||||||
"reporter_id", "target_type", "target_id",
|
|
||||||
name="uq_reporter_target",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_reports_status"), "reports", ["status"], unique=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── moderation_actions table ────────────────────────────────────────
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"moderation_actions",
|
|
||||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column("moderator_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("target_user_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"target_type",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"user", "world", "asset", "chat_message",
|
|
||||||
name="moderation_target_type",
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("target_id", sa.String(length=64), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"action",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"warn", "suspend", "ban", "restrict", "remove", "restore",
|
|
||||||
name="moderation_action_type",
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("reason", sa.Text(), nullable=True),
|
|
||||||
sa.Column("report_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"created_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(["moderator_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["target_user_id"], ["users.id"], ondelete="CASCADE"
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(["report_id"], ["reports.id"], ondelete="SET NULL"),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_moderation_actions_id"),
|
|
||||||
"moderation_actions",
|
|
||||||
["id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_moderation_actions_moderator_id"),
|
|
||||||
"moderation_actions",
|
|
||||||
["moderator_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_moderation_actions_target_user_id"),
|
|
||||||
"moderation_actions",
|
|
||||||
["target_user_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_moderation_actions_report_id"),
|
|
||||||
"moderation_actions",
|
|
||||||
["report_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── appeals table ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"appeals",
|
|
||||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column(
|
|
||||||
"moderation_action_id", sa.Integer(), nullable=False
|
|
||||||
),
|
|
||||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("reason", sa.Text(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"status",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"pending", "approved", "rejected", name="appeal_status"
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
server_default="pending",
|
|
||||||
),
|
|
||||||
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.func.now(),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["moderation_action_id"],
|
|
||||||
["moderation_actions.id"],
|
|
||||||
ondelete="CASCADE",
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["reviewed_by"], ["users.id"], ondelete="SET NULL"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_appeals_moderation_action_id"),
|
|
||||||
"appeals",
|
|
||||||
["moderation_action_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_appeals_user_id"),
|
|
||||||
"appeals",
|
|
||||||
["user_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_appeals_status"),
|
|
||||||
"appeals",
|
|
||||||
["status"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Remove trust & safety tables and revert users table."""
|
|
||||||
op.drop_index(op.f("ix_appeals_status"), table_name="appeals")
|
|
||||||
op.drop_index(op.f("ix_appeals_user_id"), table_name="appeals")
|
|
||||||
op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals")
|
|
||||||
op.drop_index(op.f("ix_appeals_id"), table_name="appeals")
|
|
||||||
op.drop_table("appeals")
|
|
||||||
|
|
||||||
op.drop_index(op.f("ix_moderation_actions_report_id"), table_name="moderation_actions")
|
|
||||||
op.drop_index(op.f("ix_moderation_actions_target_user_id"), table_name="moderation_actions")
|
|
||||||
op.drop_index(op.f("ix_moderation_actions_moderator_id"), table_name="moderation_actions")
|
|
||||||
op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
|
|
||||||
op.drop_table("moderation_actions")
|
|
||||||
|
|
||||||
op.drop_index(op.f("ix_reports_status"), table_name="reports")
|
|
||||||
op.drop_index(op.f("ix_reports_reporter_id"), table_name="reports")
|
|
||||||
op.drop_index(op.f("ix_reports_id"), table_name="reports")
|
|
||||||
op.drop_table("reports")
|
|
||||||
|
|
||||||
op.drop_column("users", "suspended_until")
|
|
||||||
op.drop_column("users", "banned_until")
|
|
||||||
op.drop_column("users", "role")
|
|
||||||
|
|
||||||
# Drop ENUM types
|
|
||||||
postgresql.ENUM(name="appeal_status").drop(bind, checkfirst=True)
|
|
||||||
postgresql.ENUM(name="moderation_target_type").drop(bind, checkfirst=True)
|
|
||||||
postgresql.ENUM(name="moderation_action_type").drop(bind, checkfirst=True)
|
|
||||||
postgresql.ENUM(name="resolution").drop(bind, checkfirst=True)
|
|
||||||
postgresql.ENUM(name="report_status").drop(bind, checkfirst=True)
|
|
||||||
postgresql.ENUM(name="reason_category").drop(bind, checkfirst=True)
|
|
||||||
postgresql.ENUM(name="report_target_type").drop(bind, checkfirst=True)
|
|
||||||
postgresql.ENUM(name="user_role").drop(bind, checkfirst=True)
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""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")
|
||||||
+105
-5
@@ -1,10 +1,56 @@
|
|||||||
"""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"
|
||||||
@@ -18,18 +64,46 @@ class Settings(BaseSettings):
|
|||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
# JWT / Auth
|
# JWT / Auth
|
||||||
# NOTE: production must override SECRET_KEY with a secure random value
|
ALGORITHM: str = "RS256"
|
||||||
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||||
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
ALGORITHM: str = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
# RS256 key storage. Defaults to a directory outside the repo so the
|
||||||
|
# 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
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
@@ -41,6 +115,32 @@ 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:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
token, settings.public_pem, 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:
|
||||||
|
|||||||
+7
-3
@@ -6,15 +6,18 @@ 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 auth, users, worlds, assets
|
from app.routers import auth, users, worlds, assets, avatar
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
# Startup: initialize database, connect to Redis, etc.
|
supervisor.start()
|
||||||
yield
|
yield
|
||||||
# Shutdown: close connections, cleanup
|
await supervisor.shutdown()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -38,6 +41,7 @@ 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(assets.router, prefix="/api/assets", tags=["assets"])
|
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
||||||
|
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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.report import Report
|
from app.models.avatar_loadout import AvatarLoadout
|
||||||
from app.models.moderation_action import ModerationAction
|
|
||||||
from app.models.appeal import Appeal
|
|
||||||
|
|
||||||
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
|
__all__ = ["User", "Session", "World", "Asset", "AvatarLoadout"]
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Appeal model for Trust & Safety."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column,
|
|
||||||
Integer,
|
|
||||||
Text,
|
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AppealStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
APPROVED = "approved"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
|
|
||||||
|
|
||||||
class Appeal(Base):
|
|
||||||
__tablename__ = "appeals"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
moderation_action_id = Column(
|
|
||||||
Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
reason = Column(Text, nullable=False)
|
|
||||||
status = Column(
|
|
||||||
ENUM(AppelStatus, name="appeal_status", create_type=False),
|
|
||||||
default=AppealStatus.PENDING,
|
|
||||||
)
|
|
||||||
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
moderation_action = relationship("ModerationAction", back_populates="appeals")
|
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
|
||||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from sqlalchemy import Column, Integer, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# The default a brand-new account gets before they touch the customization
|
||||||
|
# screen. Kept in sync with assets/avatars/catalog.json (the base_body id) and
|
||||||
|
# with the client's AvatarLoadout.to_json() shape.
|
||||||
|
DEFAULT_LOADOUT: dict = {
|
||||||
|
"base_body": "base_human_male_01",
|
||||||
|
"slots": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarLoadout(Base):
|
||||||
|
"""A user's equipped avatar composition.
|
||||||
|
|
||||||
|
One row per user. The whole loadout is stored as a single JSON document
|
||||||
|
matching the client's ``AvatarLoadout.to_json()`` shape
|
||||||
|
(``{"base_body": str, "slots": {...}, "skin_tone"?: {...}}``) rather than
|
||||||
|
exploded into columns -- the server has no copy of the asset catalog, so
|
||||||
|
deep validation (does this asset exist, is it valid for this slot) stays
|
||||||
|
on the client. The server only enforces the envelope: valid JSON object
|
||||||
|
with a non-empty ``base_body``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "avatar_loadouts"
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
data = Column(JSONB, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="avatar_loadout")
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""ModerationAction model for Trust & Safety."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ModerationActionType(str, enum.Enum):
|
|
||||||
WARN = "warn"
|
|
||||||
SUSPEND = "suspend"
|
|
||||||
BAN = "ban"
|
|
||||||
RESTRICT = "restrict"
|
|
||||||
REMOVE = "remove"
|
|
||||||
RESTORE = "restore"
|
|
||||||
|
|
||||||
|
|
||||||
class ModerationTargetType(str, enum.Enum):
|
|
||||||
USER = "user"
|
|
||||||
WORLD = "world"
|
|
||||||
ASSET = "asset"
|
|
||||||
CHAT_MESSAGE = "chat_message"
|
|
||||||
|
|
||||||
|
|
||||||
class ModerationAction(Base):
|
|
||||||
__tablename__ = "moderation_actions"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
moderator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
|
||||||
target_type = Column(
|
|
||||||
ENUM(ModerationTargetType, name="moderation_target_type", create_type=False),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
target_id = Column(String(64), nullable=True)
|
|
||||||
action = Column(
|
|
||||||
ENUM(ModerationActionType, name="moderation_action_type", create_type=False),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
reason = Column(Text, nullable=True)
|
|
||||||
report_id = Column(Integer, ForeignKey("reports.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
moderator = relationship("User", foreign_keys=[moderator_id])
|
|
||||||
target_user = relationship("User", foreign_keys=[target_user_id])
|
|
||||||
report = relationship("Report", foreign_keys=[report_id])
|
|
||||||
appeals = relationship("Appeal", back_populates="moderation_action", cascade="all, delete-orphan")
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Report model for Trust & Safety."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
|
||||||
UniqueConstraint,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ReportTargetType(str, enum.Enum):
|
|
||||||
USER = "user"
|
|
||||||
WORLD = "world"
|
|
||||||
ASSET = "asset"
|
|
||||||
CHAT_MESSAGE = "chat_message"
|
|
||||||
|
|
||||||
|
|
||||||
class ReasonCategory(str, enum.Enum):
|
|
||||||
HARASSMENT = "harassment"
|
|
||||||
HATE_SPEECH = "hate_speech"
|
|
||||||
SEXUAL_CONTENT = "sexual_content"
|
|
||||||
VIOLENCE = "violence"
|
|
||||||
SPAM = "spam"
|
|
||||||
SELF_HARM = "self_harm"
|
|
||||||
OTHER = "other"
|
|
||||||
|
|
||||||
|
|
||||||
class ReportStatus(str, enum.Enum):
|
|
||||||
OPEN = "open"
|
|
||||||
IN_REVIEW = "in_review"
|
|
||||||
RESOLVED = "resolved"
|
|
||||||
DISMISSED = "dismissed"
|
|
||||||
|
|
||||||
|
|
||||||
class Resolution(str, enum.Enum):
|
|
||||||
ACTION_TAKEN = "action_taken"
|
|
||||||
NO_ACTION = "no_action"
|
|
||||||
FALSE_POSITIVE = "false_positive"
|
|
||||||
|
|
||||||
|
|
||||||
class Report(Base):
|
|
||||||
__tablename__ = "reports"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
target_type = Column(
|
|
||||||
ENUM(ReportTargetType, name="report_target_type", create_type=False),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
target_id = Column(String(64), nullable=False)
|
|
||||||
reason_category = Column(
|
|
||||||
ENUM(ReasonCategory, name="reason_category", create_type=False),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
details = Column(Text, nullable=True)
|
|
||||||
status = Column(
|
|
||||||
ENUM(ReportStatus, name="report_status", create_type=False),
|
|
||||||
default=ReportStatus.OPEN,
|
|
||||||
)
|
|
||||||
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
||||||
resolution = Column(
|
|
||||||
ENUM(Resolution, name="resolution", create_type=False),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
reporter = relationship("User", foreign_keys=[reporter_id])
|
|
||||||
resolved_by_user = relationship("User", foreign_keys=[resolved_by])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("reporter_id", "target_type", "target_id",
|
|
||||||
name="uq_reporter_target"),
|
|
||||||
)
|
|
||||||
@@ -3,6 +3,7 @@ import uuid
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -18,3 +19,23 @@ class Session(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="sessions")
|
user = relationship("User", back_populates="sessions")
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base):
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
# RS256 JWTs are ~420+ chars; 512 is sufficient and matches the migration.
|
||||||
|
token = Column(String(512), unique=True, index=True, nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="refresh_tokens")
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Enum
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
import enum
|
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
|
||||||
USER = "user"
|
|
||||||
MODERATOR = "moderator"
|
|
||||||
ADMIN = "admin"
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -24,24 +17,19 @@ class User(Base):
|
|||||||
hashed_password = Column(String(255), nullable=False)
|
hashed_password = Column(String(255), nullable=False)
|
||||||
date_of_birth = Column(Date, nullable=True)
|
date_of_birth = Column(Date, nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
role = Column(
|
|
||||||
Enum(UserRole, name="user_role"),
|
|
||||||
nullable=False,
|
|
||||||
server_default="user",
|
|
||||||
)
|
|
||||||
banned_until = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
suspended_until = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||||
worlds = relationship("World", back_populates="owner")
|
worlds = relationship("World", back_populates="owner")
|
||||||
assets = relationship("Asset", back_populates="owner")
|
assets = relationship("Asset", back_populates="owner")
|
||||||
reports_made = relationship("Report", foreign_keys="Report.reporter_id", back_populates="reporter")
|
avatar_loadout = relationship(
|
||||||
reports_resolved = relationship("Report", foreign_keys="Report.resolved_by", back_populates="resolved_by_user")
|
"AvatarLoadout",
|
||||||
moderation_actions_as_moderator = relationship("ModerationAction", foreign_keys="ModerationAction.moderator_id", back_populates="moderator")
|
back_populates="user",
|
||||||
moderation_actions_as_target = relationship("ModerationAction", foreign_keys="ModerationAction.target_user_id", back_populates="target_user")
|
uselist=False,
|
||||||
appeals = relationship("Appeal", foreign_keys="Appeal.user_id", back_populates="user")
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_adult(self) -> bool:
|
def is_adult(self) -> bool:
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ class World(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
visibility = Column(
|
visibility = Column(
|
||||||
ENUM(WorldVisibility, name="world_visibility", create_type=False),
|
ENUM(
|
||||||
|
WorldVisibility,
|
||||||
|
name="world_visibility",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||||
|
),
|
||||||
default=WorldVisibility.PUBLIC,
|
default=WorldVisibility.PUBLIC,
|
||||||
)
|
)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Lightweight in-process rate limiting.
|
||||||
|
|
||||||
|
The backend runs single-worker (required by the game-server supervisor), so a
|
||||||
|
process-local sliding-window counter is sufficient and needs no Redis. Keyed
|
||||||
|
by client IP. Used to blunt credential brute-force on the auth endpoints.
|
||||||
|
|
||||||
|
If the backend is ever scaled to multiple workers/hosts this must move to a
|
||||||
|
shared store (Redis) -- flagged here rather than silently under-enforcing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowLimiter:
|
||||||
|
def __init__(self, max_hits: int, window_seconds: float) -> None:
|
||||||
|
self.max_hits = max_hits
|
||||||
|
self.window = window_seconds
|
||||||
|
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
def check(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
q = self._hits[key]
|
||||||
|
cutoff = now - self.window
|
||||||
|
while q and q[0] < cutoff:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= self.max_hits:
|
||||||
|
retry = max(1, int(q[0] + self.window - now))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many attempts. Try again shortly.",
|
||||||
|
headers={"Retry-After": str(retry)},
|
||||||
|
)
|
||||||
|
q.append(now)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._hits.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _client_key(request: Request) -> str:
|
||||||
|
# X-Forwarded-For first hop when behind the reverse proxy, else the socket.
|
||||||
|
fwd = request.headers.get("x-forwarded-for", "")
|
||||||
|
if fwd:
|
||||||
|
return fwd.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# 10 auth attempts / minute / IP -- generous for a human, painful for a
|
||||||
|
# brute-forcer.
|
||||||
|
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
|
||||||
|
|
||||||
|
|
||||||
|
async def auth_rate_limit(request: Request) -> None:
|
||||||
|
"""FastAPI dependency: rate-limit auth attempts per client IP.
|
||||||
|
|
||||||
|
A no-op when ``RATE_LIMIT_ENABLED`` is false -- the checkpoint smoke
|
||||||
|
suite runs every test against one backend from one client IP, so the
|
||||||
|
per-IP cap would trip mid-suite. The dedicated ``test_rate_limit``
|
||||||
|
module exercises the real behaviour regardless of this flag.
|
||||||
|
"""
|
||||||
|
if not settings.RATE_LIMIT_ENABLED:
|
||||||
|
return
|
||||||
|
_auth_limiter.check(f"auth:{_client_key(request)}")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_all() -> None:
|
||||||
|
"""Test hook."""
|
||||||
|
_auth_limiter.reset()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Asset management router."""
|
"""Asset management router."""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -13,6 +14,7 @@ from app.database import get_db
|
|||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
from app.models.asset import Asset, AssetType
|
from app.models.asset import Asset, AssetType
|
||||||
from app.models.world import World
|
from app.models.world import World
|
||||||
|
from app.services import gltf as gltf_service
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -64,6 +66,14 @@ def _sanitize_filename(filename: str) -> str:
|
|||||||
return safe or "file"
|
return safe or "file"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
|
||||||
|
"""Detect the asset type from filename and content."""
|
||||||
|
if filename and gltf_service.looks_like_gltf(content, filename):
|
||||||
|
return AssetType.MODEL_3D
|
||||||
|
# Fallback: let the caller decide based on mime_type or default.
|
||||||
|
return AssetType.OTHER
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
@router.post("/", response_model=AssetResponse, status_code=201)
|
||||||
@@ -183,10 +193,16 @@ async def upload_asset_file(
|
|||||||
# Sanitize filename to prevent path traversal.
|
# Sanitize filename to prevent path traversal.
|
||||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
||||||
|
|
||||||
# Validate file size against configured maximum.
|
# Read the file content.
|
||||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
file_size = len(content)
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Determine which size limit to apply.
|
||||||
|
if gltf_service.looks_like_gltf(content, file.filename):
|
||||||
|
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||||
|
else:
|
||||||
|
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
if file_size > max_bytes:
|
if file_size > max_bytes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=413,
|
status_code=413,
|
||||||
@@ -200,10 +216,28 @@ async def upload_asset_file(
|
|||||||
file_path = upload_dir / stored_name
|
file_path = upload_dir / stored_name
|
||||||
file_path.write_bytes(content)
|
file_path.write_bytes(content)
|
||||||
|
|
||||||
|
# Detect asset type from content/filename.
|
||||||
|
detected_type = _detect_asset_type(file.filename, content)
|
||||||
|
|
||||||
|
# Update asset record.
|
||||||
asset.file_path = str(file_path)
|
asset.file_path = str(file_path)
|
||||||
asset.mime_type = file.content_type
|
asset.mime_type = file.content_type
|
||||||
asset.file_size = file_size
|
asset.file_size = file_size
|
||||||
|
|
||||||
|
# If this is a glTF file, extract and store metadata.
|
||||||
|
if detected_type == AssetType.MODEL_3D:
|
||||||
|
try:
|
||||||
|
metadata = gltf_service.extract_gltf_metadata(content)
|
||||||
|
asset.metadata_json = json.dumps(metadata)
|
||||||
|
# Also set the asset type to model_3d if it was auto-detected.
|
||||||
|
if asset.asset_type == AssetType.OTHER:
|
||||||
|
asset.asset_type = AssetType.MODEL_3D
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid glTF file: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(asset)
|
await db.refresh(asset)
|
||||||
return asset
|
return asset
|
||||||
|
|||||||
+179
-33
@@ -1,55 +1,61 @@
|
|||||||
"""Authentication router - login, register, token refresh."""
|
"""Authentication router - login, register, token refresh."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from pydantic import BaseModel, EmailStr, field_validator
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
from jose import jwt
|
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.session import RefreshToken
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from app.rate_limit import auth_rate_limit
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
## Minimum age to register. This platform is adults-only.
|
||||||
|
MIN_AGE = 18
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
# --- Pydantic schemas ---
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
access_token_expires_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
date_of_birth: str # ISO format date string (YYYY-MM-DD)
|
# Real date of birth -- the server computes age from it and rejects
|
||||||
|
# anyone under MIN_AGE. Still self-attested (no document check yet), but
|
||||||
@field_validator("date_of_birth")
|
# a concrete, stored, legally-meaningful data point rather than a
|
||||||
@classmethod
|
# throwaway checkbox, and the seam for a future hard ID-verification
|
||||||
def validate_date_of_birth(cls, v: str) -> str:
|
# pass. See docs/architecture/content_moderation.md 5.2.
|
||||||
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
|
date_of_birth: date
|
||||||
try:
|
|
||||||
datetime.strptime(v, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
email: str
|
email: str
|
||||||
date_of_birth: str | None = None
|
date_of_birth: date | None = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
@@ -59,12 +65,43 @@ class UserResponse(BaseModel):
|
|||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
def _create_access_token(data: dict) -> str:
|
def _create_access_token(data: dict) -> str:
|
||||||
|
"""Create a signed access token using RS256.
|
||||||
|
|
||||||
|
Includes a unique ``jti`` claim so that concurrent/same-second calls
|
||||||
|
(e.g. back-to-back /refresh requests) still produce distinct tokens —
|
||||||
|
without it, two tokens minted for the same user within the same
|
||||||
|
second are byte-identical since ``sub``/``exp`` are the only claims.
|
||||||
|
"""
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
|
to_encode["jti"] = str(uuid.uuid4())
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
)
|
)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
return jwt.encode(
|
||||||
|
to_encode,
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_refresh_token(data: dict) -> str:
|
||||||
|
"""Create a signed refresh token using RS256.
|
||||||
|
|
||||||
|
Includes a unique ``jti`` claim so that concurrent calls produce
|
||||||
|
distinct tokens even when issued within the same second.
|
||||||
|
"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
to_encode["jti"] = str(uuid.uuid4())
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
|
)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(
|
||||||
|
to_encode,
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
@@ -80,6 +117,10 @@ def hash_password(password: str) -> str:
|
|||||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||||
"""Calculate age in years from a birth date.
|
"""Calculate age in years from a birth date.
|
||||||
|
|
||||||
|
Used by /register (PC3 Phase 3) to compute age from the submitted
|
||||||
|
date_of_birth and enforce the 18+ gate. See
|
||||||
|
docs/architecture/content_moderation.md 5.2.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
birth_date: The person's date of birth.
|
birth_date: The person's date of birth.
|
||||||
today: The reference date. Defaults to today's date (UTC).
|
today: The reference date. Defaults to today's date (UTC).
|
||||||
@@ -97,22 +138,41 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
|
|||||||
return age
|
return age
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||||
|
"""Create and persist a refresh token for the given user."""
|
||||||
|
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
|
||||||
|
refresh_token_record = RefreshToken(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=user_id,
|
||||||
|
token=refresh_token_value,
|
||||||
|
expires_at=datetime.now(timezone.utc)
|
||||||
|
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||||
|
)
|
||||||
|
db.add(refresh_token_record)
|
||||||
|
return refresh_token_value
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(auth_rate_limit)])
|
||||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Register a new user and return an access token.
|
"""Register a new user and return access + refresh tokens.
|
||||||
|
|
||||||
Requires date_of_birth and rejects users under 18.
|
Requires a date of birth; the server computes age and rejects anyone
|
||||||
|
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
|
||||||
|
verification design and its self-attestation limitation.
|
||||||
"""
|
"""
|
||||||
# Parse and validate date_of_birth
|
age = calculate_age(req.date_of_birth)
|
||||||
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
|
if age < 0 or req.date_of_birth > date.today():
|
||||||
age = calculate_age(dob)
|
|
||||||
|
|
||||||
if age < 18:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
|
detail="Invalid date of birth",
|
||||||
|
)
|
||||||
|
if age < MIN_AGE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"You must be at least {MIN_AGE} to use this platform",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
@@ -135,19 +195,29 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
username=req.username,
|
username=req.username,
|
||||||
email=req.email,
|
email=req.email,
|
||||||
hashed_password=hash_password(req.password),
|
hashed_password=hash_password(req.password),
|
||||||
date_of_birth=dob,
|
date_of_birth=req.date_of_birth,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.flush() # flush to get user.id before creating refresh token
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
access_token = _create_access_token({"sub": str(user.id)})
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
# Create and persist refresh token
|
||||||
|
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token_value,
|
||||||
|
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse, dependencies=[Depends(auth_rate_limit)])
|
||||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||||
"""Authenticate user and return an access token.
|
"""Authenticate user and return access + refresh tokens.
|
||||||
|
|
||||||
Accepts form-encoded credentials (username/password) as expected by
|
Accepts form-encoded credentials (username/password) as expected by
|
||||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
||||||
@@ -167,8 +237,84 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
|
|||||||
detail="User account is inactive",
|
detail="User account is inactive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
access_token = _create_access_token({"sub": str(user.id)})
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
# Create and persist refresh token
|
||||||
|
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token_value,
|
||||||
|
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Exchange a valid refresh token for a new access token.
|
||||||
|
|
||||||
|
Validates the refresh token against the database:
|
||||||
|
- Must exist in the refresh_tokens table
|
||||||
|
- Must not be expired (expires_at > now)
|
||||||
|
- Must be active (is_active == true)
|
||||||
|
|
||||||
|
On success, returns a new access token. The refresh token itself is
|
||||||
|
NOT rotated (the client keeps using the same refresh token).
|
||||||
|
"""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired refresh token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 1: Decode and validate the JWT signature/claims
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
req.refresh_token,
|
||||||
|
settings.public_pem,
|
||||||
|
algorithms=[settings.ALGORITHM],
|
||||||
|
)
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user_id_str: str | None = payload.get("sub")
|
||||||
|
if user_id_str is None:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = int(user_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
# Step 2: Look up the refresh token in the database
|
||||||
|
result = await db.execute(
|
||||||
|
select(RefreshToken).where(
|
||||||
|
RefreshToken.token == req.refresh_token,
|
||||||
|
RefreshToken.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stored_token = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stored_token is None:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
# Step 3: Check if the token is expired
|
||||||
|
if datetime.now(timezone.utc) >= stored_token.expires_at:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
# Step 4: Check if the token is active
|
||||||
|
if not stored_token.is_active:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
# Step 5: Issue a new access token
|
||||||
|
access_token = _create_access_token({"sub": str(user_id)})
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=req.refresh_token, # Return the same refresh token
|
||||||
|
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Avatar router - the current user's persisted avatar loadout.
|
||||||
|
|
||||||
|
The loadout is a single JSON document matching the client's
|
||||||
|
``AvatarLoadout.to_json()`` shape. The server has no copy of the asset
|
||||||
|
catalog, so it only validates the envelope (JSON object, non-empty
|
||||||
|
``base_body``); which assets are valid for which slot is the client's job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.avatar_loadout import AvatarLoadout, DEFAULT_LOADOUT
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
# A loadout document is small; reject anything absurd outright.
|
||||||
|
MAX_LOADOUT_BYTES = 16 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class LoadoutResponse(BaseModel):
|
||||||
|
data: dict[str, Any]
|
||||||
|
is_default: bool = Field(
|
||||||
|
description="True when the user has never saved a loadout and this is "
|
||||||
|
"the server default."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoadoutUpdate(BaseModel):
|
||||||
|
data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_envelope(data: dict[str, Any]) -> None:
|
||||||
|
base_body = data.get("base_body")
|
||||||
|
if not isinstance(base_body, str) or not base_body.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="loadout.data must have a non-empty string 'base_body'",
|
||||||
|
)
|
||||||
|
slots = data.get("slots", {})
|
||||||
|
if not isinstance(slots, dict):
|
||||||
|
raise HTTPException(status_code=422, detail="loadout.data.slots must be an object")
|
||||||
|
if len(str(data)) > MAX_LOADOUT_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="loadout document too large")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/loadout", response_model=LoadoutResponse)
|
||||||
|
async def get_loadout(current_user: CurrentUser, db: DbDep):
|
||||||
|
"""Return the current user's saved loadout, or the server default if they
|
||||||
|
have never saved one."""
|
||||||
|
row = await db.get(AvatarLoadout, current_user.id)
|
||||||
|
if row is None:
|
||||||
|
return LoadoutResponse(data=dict(DEFAULT_LOADOUT), is_default=True)
|
||||||
|
return LoadoutResponse(data=row.data, is_default=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/loadout", response_model=LoadoutResponse)
|
||||||
|
async def put_loadout(payload: LoadoutUpdate, current_user: CurrentUser, db: DbDep):
|
||||||
|
"""Replace the current user's loadout."""
|
||||||
|
_validate_envelope(payload.data)
|
||||||
|
|
||||||
|
row = await db.get(AvatarLoadout, current_user.id)
|
||||||
|
if row is None:
|
||||||
|
row = AvatarLoadout(user_id=current_user.id, data=payload.data)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.data = payload.data
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return LoadoutResponse(data=row.data, is_default=False)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Worlds router - CRUD for game worlds."""
|
"""Worlds router - CRUD for game worlds."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@@ -11,6 +12,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
from app.models.world import World, WorldVisibility
|
from app.models.world import World, WorldVisibility
|
||||||
|
from app.services.game_servers import supervisor
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ class WorldResponse(BaseModel):
|
|||||||
description: str | None
|
description: str | None
|
||||||
visibility: WorldVisibility
|
visibility: WorldVisibility
|
||||||
owner_id: int
|
owner_id: int
|
||||||
created_at: str | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -105,6 +107,40 @@ async def get_world(
|
|||||||
return world
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
class JoinResponse(BaseModel):
|
||||||
|
"""Address of the dedicated server hosting this world."""
|
||||||
|
world_id: int
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{world_id}/join", response_model=JoinResponse)
|
||||||
|
async def join_world(
|
||||||
|
world_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
):
|
||||||
|
"""Ensure a dedicated game server is running for this world and return its
|
||||||
|
ENet address for the client to connect to.
|
||||||
|
|
||||||
|
503 if game-server orchestration isn't enabled on this backend (e.g. a
|
||||||
|
plain dev/test instance with no Godot binary).
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(World).where(World.id == world_id))
|
||||||
|
world = result.scalar_one_or_none()
|
||||||
|
if not world:
|
||||||
|
raise HTTPException(status_code=404, detail="World not found")
|
||||||
|
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="World not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await supervisor.ensure_server(str(world_id))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc))
|
||||||
|
|
||||||
|
return JoinResponse(world_id=world_id, host=info["host"], port=info["port"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=WorldResponse, status_code=201)
|
@router.post("/", response_model=WorldResponse, status_code=201)
|
||||||
async def create_world(
|
async def create_world(
|
||||||
world_data: WorldCreate,
|
world_data: WorldCreate,
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Game-server orchestration.
|
||||||
|
|
||||||
|
On ``POST /api/worlds/{id}/join`` the backend ensures a headless Godot
|
||||||
|
dedicated-server process is running for that world and returns its address.
|
||||||
|
One process per world, keyed by ``world_id``. A background reaper kills a
|
||||||
|
world's server once its status file reports zero players for
|
||||||
|
``GAME_SERVER_IDLE_TIMEOUT_SECONDS``.
|
||||||
|
|
||||||
|
This lives in-process (a module-level singleton) rather than as a separate
|
||||||
|
service, so the backend MUST run single-worker -- which it does
|
||||||
|
(``uvicorn app.main:app`` with no ``--workers``). The Godot binary and
|
||||||
|
project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``);
|
||||||
|
the backend spawns them as subprocesses.
|
||||||
|
|
||||||
|
Design notes / limits (PC3):
|
||||||
|
- All worlds currently load the same test level (per-world content is M2/M7).
|
||||||
|
``world_id`` still scopes a distinct server process + session.
|
||||||
|
- Port allocation is a linear scan of the configured range.
|
||||||
|
- No crash-restart: if a server process dies, the next join respawns it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _WorldServer:
|
||||||
|
world_id: str
|
||||||
|
port: int
|
||||||
|
proc: subprocess.Popen
|
||||||
|
status_path: Path
|
||||||
|
started_at: float = field(default_factory=time.time)
|
||||||
|
last_join: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(self.status_path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def player_count(self) -> int:
|
||||||
|
return int(self.status().get("players", 0))
|
||||||
|
|
||||||
|
def alive(self) -> bool:
|
||||||
|
return self.proc.poll() is None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self.alive():
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
self.status_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GameServerSupervisor:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._servers: dict[str, _WorldServer] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._reaper: asyncio.Task | None = None
|
||||||
|
self._runtime_dir = Path(tempfile.gettempdir()) / "agp-game-servers"
|
||||||
|
self._runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ── lifecycle ────────────────────────────────────────────────────────
|
||||||
|
def start(self) -> None:
|
||||||
|
if not settings.GAME_SERVERS_ENABLED:
|
||||||
|
return
|
||||||
|
if self._reaper is None or self._reaper.done():
|
||||||
|
self._reaper = asyncio.create_task(self._reap_loop())
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
if self._reaper:
|
||||||
|
self._reaper.cancel()
|
||||||
|
async with self._lock:
|
||||||
|
for srv in list(self._servers.values()):
|
||||||
|
srv.stop()
|
||||||
|
self._servers.clear()
|
||||||
|
|
||||||
|
# ── join ─────────────────────────────────────────────────────────────
|
||||||
|
async def ensure_server(self, world_id: str) -> dict:
|
||||||
|
"""Return ``{"host", "port", "world_id"}`` for the world's dedicated
|
||||||
|
server, spawning it if needed. Raises RuntimeError if orchestration
|
||||||
|
is disabled or no port/engine is available."""
|
||||||
|
if not settings.GAME_SERVERS_ENABLED:
|
||||||
|
raise RuntimeError("game servers are not enabled on this backend")
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
srv = self._servers.get(world_id)
|
||||||
|
if srv is not None and not srv.alive():
|
||||||
|
srv.stop()
|
||||||
|
srv = None
|
||||||
|
del self._servers[world_id]
|
||||||
|
|
||||||
|
if srv is None:
|
||||||
|
srv = self._spawn(world_id)
|
||||||
|
self._servers[world_id] = srv
|
||||||
|
# Give the engine a moment to bind the ENet port before we
|
||||||
|
# hand the address to a client.
|
||||||
|
await self._await_port(srv.port)
|
||||||
|
|
||||||
|
srv.last_join = time.time()
|
||||||
|
return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id}
|
||||||
|
|
||||||
|
# ── internals ────────────────────────────────────────────────────────
|
||||||
|
def _spawn(self, world_id: str) -> _WorldServer:
|
||||||
|
port = self._free_port()
|
||||||
|
status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
|
||||||
|
status_path.unlink(missing_ok=True)
|
||||||
|
cmd = [
|
||||||
|
settings.GODOT_BIN, "--headless",
|
||||||
|
"--path", settings.GAME_PROJECT_DIR,
|
||||||
|
"res://scenes/server.tscn", "--",
|
||||||
|
"--port", str(port),
|
||||||
|
"--world", str(world_id),
|
||||||
|
"--status-file", str(status_path),
|
||||||
|
]
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
return _WorldServer(world_id=world_id, port=port, proc=proc, status_path=status_path)
|
||||||
|
|
||||||
|
def _free_port(self) -> int:
|
||||||
|
used = {s.port for s in self._servers.values()}
|
||||||
|
for p in range(settings.GAME_SERVER_PORT_MIN, settings.GAME_SERVER_PORT_MAX + 1):
|
||||||
|
if p in used:
|
||||||
|
continue
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind(("0.0.0.0", p))
|
||||||
|
return p
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError("no free game-server port in the configured range")
|
||||||
|
|
||||||
|
async def _await_port(self, port: int, timeout: float = 8.0) -> None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
# ENet is UDP; a bind failure means something (our server) has it.
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind(("0.0.0.0", port))
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
async def _reap_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
async with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
for wid, srv in list(self._servers.items()):
|
||||||
|
if not srv.alive():
|
||||||
|
srv.stop()
|
||||||
|
del self._servers[wid]
|
||||||
|
continue
|
||||||
|
idle_for = now - srv.last_join
|
||||||
|
if idle_for > settings.GAME_SERVER_IDLE_TIMEOUT_SECONDS and srv.player_count() == 0:
|
||||||
|
srv.stop()
|
||||||
|
del self._servers[wid]
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def debug_state(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"world_id": s.world_id, "port": s.port, "alive": s.alive(),
|
||||||
|
"players": s.player_count(), "uptime_s": round(time.time() - s.started_at),
|
||||||
|
}
|
||||||
|
for s in self._servers.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
supervisor = GameServerSupervisor()
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Lightweight glTF / GLB metadata extraction.
|
||||||
|
|
||||||
|
This module intentionally avoids heavyweight dependencies such as trimesh or
|
||||||
|
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
|
||||||
|
vertex counts, animation presence, texture references, and the bounding box)
|
||||||
|
a small amount of glTF-specific parsing is sufficient and keeps the backend
|
||||||
|
dependency-free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# GLB binary header magic bytes ("glTF").
|
||||||
|
_GLB_MAGIC = b"glTF"
|
||||||
|
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
|
||||||
|
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
|
||||||
|
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
|
||||||
|
|
||||||
|
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
|
||||||
|
# but be lenient with null bytes and other whitespace that real exporters emit.
|
||||||
|
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
|
||||||
|
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
|
||||||
|
if content.startswith(_GLB_MAGIC):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if filename:
|
||||||
|
lowered = filename.lower()
|
||||||
|
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# A bare JSON buffer (e.g. uploaded without an extension) that has the
|
||||||
|
# glTF asset marker is also a glTF file.
|
||||||
|
stripped = content.lstrip()
|
||||||
|
if stripped.startswith(b"{"):
|
||||||
|
try:
|
||||||
|
payload = json.loads(stripped.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return False
|
||||||
|
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_glb(content: bytes) -> dict[str, Any]:
|
||||||
|
"""Parse the JSON chunk of a GLB binary container."""
|
||||||
|
if len(content) < 12:
|
||||||
|
raise ValueError("GLB file is too short")
|
||||||
|
|
||||||
|
magic, _version, _length = struct.unpack_from("<III", content, 0)
|
||||||
|
if magic != _GLB_MAGIC_INT:
|
||||||
|
raise ValueError("invalid GLB magic")
|
||||||
|
|
||||||
|
offset = 12
|
||||||
|
json_chunk: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
while offset + 8 <= len(content):
|
||||||
|
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
|
||||||
|
offset += 8
|
||||||
|
chunk_end = offset + chunk_length
|
||||||
|
if chunk_end > len(content):
|
||||||
|
raise ValueError("GLB chunk overruns file boundary")
|
||||||
|
|
||||||
|
chunk_data = content[offset:chunk_end]
|
||||||
|
if chunk_type == _GLB_JSON_CHUNK_TYPE:
|
||||||
|
try:
|
||||||
|
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
|
||||||
|
|
||||||
|
offset = chunk_end
|
||||||
|
|
||||||
|
if json_chunk is None:
|
||||||
|
raise ValueError("GLB file does not contain a JSON chunk")
|
||||||
|
|
||||||
|
return json_chunk
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
|
||||||
|
"""Parse a plain .gltf JSON file."""
|
||||||
|
stripped = content.lstrip()
|
||||||
|
try:
|
||||||
|
payload = json.loads(stripped.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError(f"invalid glTF JSON: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("glTF JSON root must be an object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
|
||||||
|
"""Return POSITION accessor counts for every mesh primitive."""
|
||||||
|
counts: list[int] = []
|
||||||
|
accessors = gltf.get("accessors", [])
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for primitive in mesh.get("primitives", []):
|
||||||
|
attributes = primitive.get("attributes", {})
|
||||||
|
position_index = attributes.get("POSITION")
|
||||||
|
if position_index is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
accessor = accessors[int(position_index)]
|
||||||
|
except (IndexError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
count = accessor.get("count")
|
||||||
|
if isinstance(count, int):
|
||||||
|
counts.append(count)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
|
||||||
|
"""Return min/max bounds for every POSITION accessor that declares them."""
|
||||||
|
bounds: list[list[float]] = []
|
||||||
|
accessors = gltf.get("accessors", [])
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for primitive in mesh.get("primitives", []):
|
||||||
|
attributes = primitive.get("attributes", {})
|
||||||
|
position_index = attributes.get("POSITION")
|
||||||
|
if position_index is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
accessor = accessors[int(position_index)]
|
||||||
|
except (IndexError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
minimum = accessor.get("min")
|
||||||
|
maximum = accessor.get("max")
|
||||||
|
if isinstance(minimum, list) and isinstance(maximum, list):
|
||||||
|
bounds.append([float(x) for x in minimum])
|
||||||
|
bounds.append([float(x) for x in maximum])
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
|
||||||
|
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
|
||||||
|
"""Collect external texture source references from the image array."""
|
||||||
|
refs: list[str] = []
|
||||||
|
for index, image in enumerate(gltf.get("images", [])):
|
||||||
|
uri = image.get("uri")
|
||||||
|
if isinstance(uri, str) and not uri.startswith("data:"):
|
||||||
|
refs.append(uri)
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = image.get("name")
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
refs.append(name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(uri, str):
|
||||||
|
# Embedded data URI; keep a stable, non-huge reference.
|
||||||
|
refs.append(f"data:{index}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
buffer_view = image.get("bufferView")
|
||||||
|
if buffer_view is not None:
|
||||||
|
refs.append(f"bufferView:{buffer_view}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
refs.append(f"image:{index}")
|
||||||
|
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
|
||||||
|
"""Extract upload-relevant metadata from glTF/GLB bytes.
|
||||||
|
|
||||||
|
Raises ValueError for malformed glTF content. The returned dict contains:
|
||||||
|
mesh_count, vertex_count, has_animation, texture_refs,
|
||||||
|
bounding_box_min, bounding_box_max.
|
||||||
|
"""
|
||||||
|
if content.startswith(_GLB_MAGIC):
|
||||||
|
gltf = _parse_glb(content)
|
||||||
|
else:
|
||||||
|
gltf = _parse_gltf_json(content)
|
||||||
|
|
||||||
|
position_counts = _position_accessor_counts(gltf)
|
||||||
|
bounds = _position_bounds(gltf)
|
||||||
|
|
||||||
|
if bounds:
|
||||||
|
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
|
||||||
|
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
|
||||||
|
else:
|
||||||
|
bounding_box_min = [0.0, 0.0, 0.0]
|
||||||
|
bounding_box_max = [0.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mesh_count": len(gltf.get("meshes", [])),
|
||||||
|
"vertex_count": sum(position_counts),
|
||||||
|
"has_animation": bool(gltf.get("animations")),
|
||||||
|
"texture_refs": _texture_refs(gltf),
|
||||||
|
"bounding_box_min": bounding_box_min,
|
||||||
|
"bounding_box_max": bounding_box_max,
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: agp_password
|
POSTGRES_PASSWORD: agp_password
|
||||||
POSTGRES_DB: agp_db
|
POSTGRES_DB: agp_db
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "${REDIS_HOST_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ pydantic==2.10.3
|
|||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
# passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
|
||||||
|
# backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed --
|
||||||
|
# hashing/verifying a password then raises "password cannot be longer than
|
||||||
|
# 72 bytes" (a misleading downstream symptom of the real AttributeError:
|
||||||
|
# module 'bcrypt' has no attribute '__about__') on every call. Without this
|
||||||
|
# pin, `pip install -r requirements.txt` silently grabs whatever the latest
|
||||||
|
# bcrypt is and breaks register/login outright. Pin below 4.1 explicitly.
|
||||||
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Shared pytest fixtures + hermetic-DB bootstrap.
|
||||||
|
|
||||||
|
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
|
||||||
|
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
|
||||||
|
with no manual setup, ``pytest_configure`` below brings up an ephemeral
|
||||||
|
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
|
||||||
|
not already pointing at a reachable database. When it is (dev loop, or the
|
||||||
|
checkpoint runner which stands the DB up itself), that path is skipped and
|
||||||
|
the existing database is reused.
|
||||||
|
|
||||||
|
If Docker isn't available and no DB is reachable, the whole DB-backed suite
|
||||||
|
is skipped with a clear message rather than erroring out 60+ times.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users")
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
_COMPOSE_PROJECT = "agp-pytest"
|
||||||
|
_EPHEMERAL_PG_PORT = 25432
|
||||||
|
|
||||||
|
# Set by _ensure_database() when it starts a container it owns; torn down in
|
||||||
|
# pytest_unconfigure.
|
||||||
|
_started_container = False
|
||||||
|
_db_unavailable_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _db_reachable(url: str) -> bool:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
return _port_open(parts.hostname or "localhost", parts.port or 5432)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
global _started_container, _db_unavailable_reason
|
||||||
|
|
||||||
|
if _db_reachable(settings.DATABASE_URL):
|
||||||
|
return # dev loop / checkpoint runner already provided one
|
||||||
|
|
||||||
|
if not _run(["docker", "--version"]).returncode == 0:
|
||||||
|
_db_unavailable_reason = (
|
||||||
|
"no reachable DATABASE_URL and docker is unavailable -- run the "
|
||||||
|
"checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres "
|
||||||
|
"manually"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
|
||||||
|
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
up = _run(["docker", "compose", "up", "-d", "postgres"], env=env)
|
||||||
|
if up.returncode != 0:
|
||||||
|
_db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}"
|
||||||
|
return
|
||||||
|
_started_container = True
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
r = _run(
|
||||||
|
["docker", "compose", "exec", "-T", "postgres",
|
||||||
|
"pg_isready", "-U", "agp_user", "-d", "agp_db"],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
_db_unavailable_reason = "ephemeral Postgres never became ready"
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
|
||||||
|
os.environ["DATABASE_URL"] = url
|
||||||
|
settings.DATABASE_URL = url
|
||||||
|
|
||||||
|
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
env={**os.environ, "DATABASE_URL": url})
|
||||||
|
if mig.returncode != 0:
|
||||||
|
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_unconfigure(config):
|
||||||
|
if not _started_container:
|
||||||
|
return
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
|
||||||
|
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
_run(["docker", "compose", "down", "-v"], env=env)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _reset_database():
|
||||||
|
"""Truncate all application tables once per test session."""
|
||||||
|
if _db_unavailable_reason:
|
||||||
|
pytest.skip(_db_unavailable_reason, allow_module_level=True)
|
||||||
|
|
||||||
|
async def _truncate() -> None:
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE"))
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(_truncate())
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_rate_limiter():
|
||||||
|
"""Every TestClient request looks like the same client IP, so the
|
||||||
|
per-IP auth limiter would trip mid-suite. Clear it between tests; the
|
||||||
|
dedicated rate-limit test disables this to exercise the real behaviour.
|
||||||
|
"""
|
||||||
|
from app import rate_limit
|
||||||
|
rate_limit.reset_all()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _override_db():
|
||||||
|
"""Override get_db so the app uses a fresh session per test.
|
||||||
|
|
||||||
|
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
||||||
|
(sync) TestClient runs each request through anyio's blocking-portal
|
||||||
|
machinery, which can hand different requests different event loops.
|
||||||
|
asyncpg connections are bound to the loop they were created on, so
|
||||||
|
reusing one from the shared pool across requests raises "attached to a
|
||||||
|
different loop" the moment a test makes more than one request. NullPool
|
||||||
|
sidesteps it: every checkout is a brand-new connection.
|
||||||
|
"""
|
||||||
|
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def _get_db():
|
||||||
|
async_session = test_session_factory()
|
||||||
|
try:
|
||||||
|
yield async_session
|
||||||
|
await async_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await async_session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await async_session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _get_db
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.clear()
|
||||||
@@ -3,92 +3,102 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.routers.auth import calculate_age
|
from app.main import app
|
||||||
|
from app.routers.auth import calculate_age, MIN_AGE, RegisterRequest
|
||||||
|
|
||||||
|
|
||||||
class TestCalculateAge:
|
class TestCalculateAge:
|
||||||
"""Unit tests for the calculate_age helper."""
|
"""Unit tests for the calculate_age helper."""
|
||||||
|
|
||||||
def test_exactly_18(self):
|
def test_exactly_18(self):
|
||||||
"""A person born exactly 18 years ago today is 18."""
|
today = date(2026, 6, 15)
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18)
|
dob = today.replace(year=today.year - 18)
|
||||||
assert calculate_age(dob, today=today) == 18
|
assert calculate_age(dob, today=today) == 18
|
||||||
|
|
||||||
def test_just_turned_18(self):
|
def test_just_turned_18(self):
|
||||||
"""A person who turned 18 yesterday is 18."""
|
today = date(2026, 6, 15)
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
||||||
assert calculate_age(dob, today=today) == 18
|
assert calculate_age(dob, today=today) == 18
|
||||||
|
|
||||||
def test_one_day_short_of_18(self):
|
def test_one_day_short_of_18(self):
|
||||||
"""A person born one day after their 18th birthday is 17."""
|
today = date(2026, 6, 15)
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
||||||
assert calculate_age(dob, today=today) == 17
|
assert calculate_age(dob, today=today) == 17
|
||||||
|
|
||||||
def test_17_years_old(self):
|
def test_leap_day_birthday_before(self):
|
||||||
"""A person who is 17 years old today is 17."""
|
assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = date(2008, 6, 15)
|
|
||||||
assert calculate_age(dob, today=today) == 17
|
|
||||||
|
|
||||||
def test_65_years_old(self):
|
def test_leap_day_birthday_after(self):
|
||||||
"""A person who is 65 is correctly calculated."""
|
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 65)
|
|
||||||
assert calculate_age(dob, today=today) == 65
|
|
||||||
|
|
||||||
def test_newborn(self):
|
|
||||||
"""A newborn is 0."""
|
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today
|
|
||||||
assert calculate_age(dob, today=today) == 0
|
|
||||||
|
|
||||||
def test_birthday_not_yet_this_year(self):
|
|
||||||
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
|
|
||||||
dob = date(2000, 12, 31)
|
|
||||||
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
|
|
||||||
|
|
||||||
def test_birthday_today(self):
|
|
||||||
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
|
|
||||||
dob = date(2000, 12, 31)
|
|
||||||
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterRequestDateValidation:
|
class TestRegisterRequestDob:
|
||||||
"""Tests for the RegisterRequest date_of_birth field validator."""
|
def test_dob_parsed_from_iso_string(self):
|
||||||
|
req = RegisterRequest(username="u", email="u@example.com", password="p",
|
||||||
|
date_of_birth="1990-01-01")
|
||||||
|
assert req.date_of_birth == date(1990, 1, 1)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_dob_required(self):
|
||||||
"dob",
|
with pytest.raises(Exception):
|
||||||
[
|
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||||
"2000-01-01",
|
|
||||||
"1990-12-31",
|
|
||||||
"2004-02-29", # leap day
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_valid_dates(self, dob):
|
|
||||||
"""Valid YYYY-MM-DD dates pass validation."""
|
|
||||||
from app.routers.auth import RegisterRequest
|
|
||||||
|
|
||||||
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
def test_bad_dob_string_rejected(self):
|
||||||
assert req.date_of_birth == dob
|
with pytest.raises(Exception):
|
||||||
|
RegisterRequest(username="u", email="u@example.com", password="p",
|
||||||
|
date_of_birth="not-a-date")
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"dob",
|
|
||||||
[
|
|
||||||
"01-01-2000",
|
|
||||||
"2000/01/01",
|
|
||||||
"2000-13-01",
|
|
||||||
"2000-00-10",
|
|
||||||
"not-a-date",
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_dates_rejected(self, dob):
|
|
||||||
"""Invalid dates are rejected by the validator."""
|
|
||||||
from app.routers.auth import RegisterRequest
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
class TestRegisterAgeGate:
|
||||||
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
"""End-to-end: the /register endpoint computes age from the DOB."""
|
||||||
|
|
||||||
|
def _dob_for_age(self, years: int) -> str:
|
||||||
|
return (date.today().replace(year=date.today().year - years)).isoformat()
|
||||||
|
|
||||||
|
def test_adult_accepted(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_adult", "email": "gate_adult@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": self._dob_for_age(25),
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
def test_minor_rejected(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_minor", "email": "gate_minor@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": self._dob_for_age(15),
|
||||||
|
})
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert str(MIN_AGE) in r.json()["detail"]
|
||||||
|
|
||||||
|
def test_exactly_18_accepted(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_18", "email": "gate_18@e.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": (date.today().replace(year=date.today().year - 18) - timedelta(days=1)).isoformat(),
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
def test_future_dob_rejected(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_future", "email": "gate_future@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": "2999-01-01",
|
||||||
|
})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_dob_is_stored(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
dob = self._dob_for_age(30)
|
||||||
|
reg = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_stored", "email": "gate_stored@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": dob,
|
||||||
|
})
|
||||||
|
assert reg.status_code == 201
|
||||||
|
me = client.get("/api/auth/me", headers={
|
||||||
|
"Authorization": f"Bearer {reg.json()['access_token']}"
|
||||||
|
})
|
||||||
|
assert me.json()["date_of_birth"] == dob
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Integration tests for the auth endpoints (register + login).
|
||||||
|
|
||||||
|
These tests hit the real FastAPI app against the real configured database
|
||||||
|
(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated
|
||||||
|
in-memory DB), so they exercise the full stack: Pydantic validation → DB →
|
||||||
|
JWT → response. Requires migrations already applied (alembic upgrade head)
|
||||||
|
before running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.database import get_db
|
||||||
|
from app.routers.auth import pwd_context
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _override_db():
|
||||||
|
"""Override get_db so the app uses a fresh session per test.
|
||||||
|
|
||||||
|
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
||||||
|
(sync) TestClient runs each request through anyio's blocking-portal
|
||||||
|
machinery, which can hand different requests different event loops.
|
||||||
|
asyncpg connections are bound to the loop they were created on, so
|
||||||
|
reusing one from the shared engine's connection pool across requests
|
||||||
|
raises "cannot perform operation: another operation is in progress" the
|
||||||
|
moment a test makes more than one request (register-then-login,
|
||||||
|
register-twice, etc.) -- confirmed this is the actual cause, not a bug
|
||||||
|
in the endpoints themselves. NullPool sidesteps it: every checkout is a
|
||||||
|
brand-new connection, never handed to a different loop than the one
|
||||||
|
that opened it.
|
||||||
|
"""
|
||||||
|
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def _get_db():
|
||||||
|
async_session = test_session_factory()
|
||||||
|
try:
|
||||||
|
yield async_session
|
||||||
|
await async_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await async_session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await async_session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _get_db
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Register endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRegister:
|
||||||
|
"""Tests for POST /api/auth/register."""
|
||||||
|
|
||||||
|
def test_register_success(self):
|
||||||
|
"""A valid registration returns 201 with a token."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "new@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
def test_register_rejects_under_18(self):
|
||||||
|
"""A DOB that computes to under 18 gets a 403 (see test_auth_age_gate
|
||||||
|
for the age-math coverage)."""
|
||||||
|
from datetime import date
|
||||||
|
client = TestClient(app)
|
||||||
|
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "kid",
|
||||||
|
"email": "kid@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": minor_dob,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert "at least 18" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_register_rejects_duplicate_username(self):
|
||||||
|
"""Registering the same username twice returns 409."""
|
||||||
|
client = TestClient(app)
|
||||||
|
# First registration
|
||||||
|
client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "duplicate",
|
||||||
|
"email": "dup1@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Second registration with same username, different email
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "duplicate",
|
||||||
|
"email": "dup2@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "Username already registered" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_register_rejects_duplicate_email(self):
|
||||||
|
"""Registering the same email twice returns 409."""
|
||||||
|
client = TestClient(app)
|
||||||
|
client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "user1",
|
||||||
|
"email": "same@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "user2",
|
||||||
|
"email": "same@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "Email already registered" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_register_missing_dob(self):
|
||||||
|
"""Missing the required date_of_birth field returns 422."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "user",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLogin:
|
||||||
|
"""Tests for POST /api/auth/login."""
|
||||||
|
|
||||||
|
def _create_user(self, username: str, password: str, email: str = None):
|
||||||
|
"""Helper to create a user via register."""
|
||||||
|
client = TestClient(app)
|
||||||
|
if email is None:
|
||||||
|
email = f"{username}@example.com"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
def test_login_success(self):
|
||||||
|
"""Correct credentials return a token."""
|
||||||
|
self._create_user("loginuser", "correctpass")
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data={"username": "loginuser", "password": "correctpass"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
def test_login_wrong_password(self):
|
||||||
|
"""Wrong password returns 401."""
|
||||||
|
# Distinct username from test_login_success -- there's no per-test
|
||||||
|
# DB rollback/isolation here (real Postgres via the app's own
|
||||||
|
# dependency-overridden session), so reusing "loginuser" collided
|
||||||
|
# with the user that test already created (409 on register instead
|
||||||
|
# of the intended 401-on-login assertion this test is actually for).
|
||||||
|
self._create_user("loginuser_wrongpass", "correctpass")
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data={"username": "loginuser_wrongpass", "password": "wrongpass"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Incorrect username or password" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_login_nonexistent_user(self):
|
||||||
|
"""Non-existent user returns 401 (not 404)."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data={"username": "nobody", "password": "any"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Incorrect username or password" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_login_accepts_form_encoded(self):
|
||||||
|
"""Login accepts application/x-www-form-urlencoded (OAuth2)."""
|
||||||
|
self._create_user("formuser", "formpass")
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data={"username": "formuser", "password": "formpass"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "access_token" in resp.json()
|
||||||
|
|
||||||
|
def test_login_token_is_valid_jwt(self):
|
||||||
|
"""The returned token is a valid JWT that can be decoded."""
|
||||||
|
self._create_user("jwttoken", "jwt123")
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data={"username": "jwttoken", "password": "jwt123"},
|
||||||
|
)
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
# Verify it's a 3-part JWT
|
||||||
|
parts = token.split(".")
|
||||||
|
assert len(parts) == 3
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for expired-token rejection across auth endpoints.
|
||||||
|
|
||||||
|
Covers two distinct expiry paths:
|
||||||
|
|
||||||
|
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
|
||||||
|
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
|
||||||
|
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
|
||||||
|
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
|
||||||
|
signature is still valid.
|
||||||
|
|
||||||
|
Requires migrations already applied (alembic upgrade head) before running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from jose import jwt
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.session import RefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _override_db():
|
||||||
|
"""Override get_db so the app uses a fresh session per test."""
|
||||||
|
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def _get_db():
|
||||||
|
async_session = test_session_factory()
|
||||||
|
try:
|
||||||
|
yield async_session
|
||||||
|
await async_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await async_session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await async_session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _get_db
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _mint_access_token(exp: datetime) -> str:
|
||||||
|
"""Mint an RS256 access token with an explicit ``exp`` claim."""
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": "1", "exp": exp},
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _expire_refresh_token_in_db(token: str) -> None:
|
||||||
|
"""Set the persisted refresh token's ``expires_at`` into the past."""
|
||||||
|
|
||||||
|
async def _do():
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.token == token)
|
||||||
|
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(_do())
|
||||||
|
|
||||||
|
|
||||||
|
def _register_user(client: TestClient, username: str) -> str:
|
||||||
|
"""Register a user and return its refresh token."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": f"{username}@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()["refresh_token"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expired access token (GET /api/auth/me)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExpiredAccessToken:
|
||||||
|
def test_expired_access_token_is_rejected_by_me(self):
|
||||||
|
"""An access JWT with a past exp claim returns 401 on /me."""
|
||||||
|
client = TestClient(app)
|
||||||
|
expired_token = _mint_access_token(
|
||||||
|
datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
|
)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {expired_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Could not validate credentials" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_garbage_access_token_is_rejected_by_me(self):
|
||||||
|
"""A non-JWT bearer token returns 401 on /me."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/me",
|
||||||
|
headers={"Authorization": "Bearer not-a-real-jwt"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_missing_bearer_token_returns_401(self):
|
||||||
|
"""No Authorization header at all returns 401 on /me."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expired refresh token (POST /api/auth/refresh)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExpiredRefreshToken:
|
||||||
|
def test_expired_refresh_token_is_rejected(self):
|
||||||
|
"""A refresh token whose DB expires_at is in the past returns 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
refresh_token = _register_user(client, "expired_rt_user")
|
||||||
|
|
||||||
|
# Force the persisted record to be expired while its JWT signature
|
||||||
|
# is still valid.
|
||||||
|
_expire_refresh_token_in_db(refresh_token)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_expired_refresh_token_jwt_is_rejected(self):
|
||||||
|
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
|
||||||
|
client = TestClient(app)
|
||||||
|
expired_jwt = jwt.encode(
|
||||||
|
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": expired_jwt},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Integration tests for GET/PUT /api/avatar/loadout.
|
||||||
|
|
||||||
|
Hits the real app against the configured Postgres (same contract as the
|
||||||
|
other integration tests -- migrations applied first).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models.avatar_loadout import DEFAULT_LOADOUT
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(client: TestClient, username: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": f"{username}@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLoadout:
|
||||||
|
def test_new_user_gets_the_default(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_default")
|
||||||
|
resp = client.get("/api/avatar/loadout", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["is_default"] is True
|
||||||
|
assert body["data"] == DEFAULT_LOADOUT
|
||||||
|
|
||||||
|
def test_requires_auth(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/api/avatar/loadout").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestPutLoadout:
|
||||||
|
def test_save_then_read_back(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_save")
|
||||||
|
loadout = {
|
||||||
|
"base_body": "base_human_male_01",
|
||||||
|
"slots": {"hair": "hair_mohawk_01", "accessory": ["accessory_belt_01"]},
|
||||||
|
"skin_tone": {"r": 0.8, "g": 0.6, "b": 0.5, "a": 1.0},
|
||||||
|
}
|
||||||
|
put = client.put("/api/avatar/loadout", headers=headers, json={"data": loadout})
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
assert put.json()["is_default"] is False
|
||||||
|
|
||||||
|
got = client.get("/api/avatar/loadout", headers=headers)
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["is_default"] is False
|
||||||
|
assert got.json()["data"] == loadout
|
||||||
|
|
||||||
|
def test_put_is_idempotent_replace(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_replace")
|
||||||
|
client.put(
|
||||||
|
"/api/avatar/loadout", headers=headers,
|
||||||
|
json={"data": {"base_body": "base_human_male_01", "slots": {"hair": "hair_mohawk_01"}}},
|
||||||
|
)
|
||||||
|
second = {"base_body": "base_human_male_01", "slots": {"chest": "chest_bandit_01"}}
|
||||||
|
client.put("/api/avatar/loadout", headers=headers, json={"data": second})
|
||||||
|
got = client.get("/api/avatar/loadout", headers=headers)
|
||||||
|
assert got.json()["data"] == second
|
||||||
|
|
||||||
|
def test_rejects_missing_base_body(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_nobase")
|
||||||
|
resp = client.put("/api/avatar/loadout", headers=headers, json={"data": {"slots": {}}})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_rejects_non_object_slots(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_badslots")
|
||||||
|
resp = client.put(
|
||||||
|
"/api/avatar/loadout", headers=headers,
|
||||||
|
json={"data": {"base_body": "base_human_male_01", "slots": "nope"}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_requires_auth(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.put("/api/avatar/loadout", json={"data": {"base_body": "x", "slots": {}}})
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Tests for the lightweight glTF metadata extraction service."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.gltf import (
|
||||||
|
extract_gltf_metadata,
|
||||||
|
looks_like_gltf,
|
||||||
|
_parse_glb,
|
||||||
|
_parse_gltf_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test data helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_gltf_json(
|
||||||
|
meshes: int = 1,
|
||||||
|
vertices_per_mesh: int = 24,
|
||||||
|
has_animations: bool = False,
|
||||||
|
images: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a minimal glTF JSON structure."""
|
||||||
|
accessors = []
|
||||||
|
meshes_data = []
|
||||||
|
for i in range(meshes):
|
||||||
|
acc_index = len(accessors)
|
||||||
|
accessors.append({
|
||||||
|
"bufferView": 0,
|
||||||
|
"componentType": 5126, # FLOAT
|
||||||
|
"count": vertices_per_mesh,
|
||||||
|
"type": "VEC3",
|
||||||
|
"max": [1.0, 1.0, 1.0],
|
||||||
|
"min": [-1.0, -1.0, -1.0],
|
||||||
|
})
|
||||||
|
meshes_data.append({
|
||||||
|
"primitives": [{
|
||||||
|
"attributes": {"POSITION": acc_index},
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
gltf = {
|
||||||
|
"asset": {"version": "2.0"},
|
||||||
|
"accessors": accessors,
|
||||||
|
"meshes": meshes_data,
|
||||||
|
}
|
||||||
|
if has_animations:
|
||||||
|
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
|
||||||
|
if images:
|
||||||
|
gltf["images"] = images
|
||||||
|
return gltf
|
||||||
|
|
||||||
|
|
||||||
|
def _make_glb(gltf_json: dict) -> bytes:
|
||||||
|
"""Wrap *gltf_json* in a minimal GLB binary container."""
|
||||||
|
json_bytes = json.dumps(gltf_json).encode("utf-8")
|
||||||
|
json_length = len(json_bytes)
|
||||||
|
# Pad JSON chunk to 4-byte boundary.
|
||||||
|
json_padding = (4 - json_length % 4) % 4
|
||||||
|
json_chunk = json_bytes + b"\x00" * json_padding
|
||||||
|
json_chunk_type = 0x4E4F534A # "JSON"
|
||||||
|
# No BIN chunk.
|
||||||
|
bin_length = 0
|
||||||
|
bin_chunk_type = 0x004E4942
|
||||||
|
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
|
||||||
|
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
|
||||||
|
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
|
||||||
|
if bin_length > 0:
|
||||||
|
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
|
||||||
|
else:
|
||||||
|
bin_header = b""
|
||||||
|
return header + json_header + json_chunk + bin_header
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# looks_like_gltf
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLooksLikeGltf:
|
||||||
|
def test_glb_magic(self):
|
||||||
|
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
|
||||||
|
|
||||||
|
def test_gltf_extension(self):
|
||||||
|
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
|
||||||
|
|
||||||
|
def test_glb_extension(self):
|
||||||
|
assert looks_like_gltf(b"some binary", "model.glb") is True
|
||||||
|
|
||||||
|
def test_json_with_asset_version(self):
|
||||||
|
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
|
||||||
|
assert looks_like_gltf(payload) is True
|
||||||
|
|
||||||
|
def test_plain_json_no_asset(self):
|
||||||
|
payload = json.dumps({"foo": "bar"}).encode()
|
||||||
|
assert looks_like_gltf(payload) is False
|
||||||
|
|
||||||
|
def test_non_json_bytes(self):
|
||||||
|
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_glb
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParseGlb:
|
||||||
|
def test_parses_valid_glb(self):
|
||||||
|
gltf_json = _make_gltf_json()
|
||||||
|
glb = _make_glb(gltf_json)
|
||||||
|
result = _parse_glb(glb)
|
||||||
|
assert result["asset"]["version"] == "2.0"
|
||||||
|
assert len(result["meshes"]) == 1
|
||||||
|
|
||||||
|
def test_rejects_too_short(self):
|
||||||
|
with pytest.raises(ValueError, match="too short"):
|
||||||
|
_parse_glb(b"glTF\x00\x00")
|
||||||
|
|
||||||
|
def test_rejects_invalid_magic(self):
|
||||||
|
with pytest.raises(ValueError, match="invalid GLB magic"):
|
||||||
|
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_gltf_json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParseGltfJson:
|
||||||
|
def test_parses_valid_gltf(self):
|
||||||
|
gltf_json = _make_gltf_json()
|
||||||
|
result = _parse_gltf_json(json.dumps(gltf_json).encode())
|
||||||
|
assert result["asset"]["version"] == "2.0"
|
||||||
|
|
||||||
|
def test_rejects_non_object(self):
|
||||||
|
with pytest.raises(ValueError, match="root must be an object"):
|
||||||
|
_parse_gltf_json(b"[1, 2, 3]")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# extract_gltf_metadata (GLB path)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExtractGltfMetadataGlb:
|
||||||
|
def test_basic_glb(self):
|
||||||
|
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
|
||||||
|
glb = _make_glb(gltf_json)
|
||||||
|
meta = extract_gltf_metadata(glb)
|
||||||
|
assert meta["mesh_count"] == 1
|
||||||
|
assert meta["vertex_count"] == 24
|
||||||
|
assert meta["has_animation"] is False
|
||||||
|
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
|
||||||
|
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
|
||||||
|
assert meta["texture_refs"] == []
|
||||||
|
|
||||||
|
def test_multiple_meshes(self):
|
||||||
|
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
|
||||||
|
glb = _make_glb(gltf_json)
|
||||||
|
meta = extract_gltf_metadata(glb)
|
||||||
|
assert meta["mesh_count"] == 3
|
||||||
|
assert meta["vertex_count"] == 36
|
||||||
|
|
||||||
|
def test_with_animation(self):
|
||||||
|
gltf_json = _make_gltf_json(has_animations=True)
|
||||||
|
glb = _make_glb(gltf_json)
|
||||||
|
meta = extract_gltf_metadata(glb)
|
||||||
|
assert meta["has_animation"] is True
|
||||||
|
|
||||||
|
def test_with_external_texture(self):
|
||||||
|
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
|
||||||
|
glb = _make_glb(gltf_json)
|
||||||
|
meta = extract_gltf_metadata(glb)
|
||||||
|
assert meta["texture_refs"] == ["textures/rock.png"]
|
||||||
|
|
||||||
|
def test_with_named_texture(self):
|
||||||
|
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
|
||||||
|
glb = _make_glb(gltf_json)
|
||||||
|
meta = extract_gltf_metadata(glb)
|
||||||
|
assert meta["texture_refs"] == ["my_texture"]
|
||||||
|
|
||||||
|
def test_empty_meshes(self):
|
||||||
|
gltf_json = _make_gltf_json(meshes=0)
|
||||||
|
glb = _make_glb(gltf_json)
|
||||||
|
meta = extract_gltf_metadata(glb)
|
||||||
|
assert meta["mesh_count"] == 0
|
||||||
|
assert meta["vertex_count"] == 0
|
||||||
|
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
|
||||||
|
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# extract_gltf_metadata (plain .gltf JSON path)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExtractGltfMetadataGltfJson:
|
||||||
|
def test_plain_gltf(self):
|
||||||
|
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
|
||||||
|
raw = json.dumps(gltf_json).encode()
|
||||||
|
meta = extract_gltf_metadata(raw)
|
||||||
|
assert meta["mesh_count"] == 2
|
||||||
|
assert meta["vertex_count"] == 16
|
||||||
|
assert meta["has_animation"] is False
|
||||||
|
|
||||||
|
def test_plain_gltf_with_animation(self):
|
||||||
|
gltf_json = _make_gltf_json(has_animations=True)
|
||||||
|
raw = json.dumps(gltf_json).encode()
|
||||||
|
meta = extract_gltf_metadata(raw)
|
||||||
|
assert meta["has_animation"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExtractGltfMetadataErrors:
|
||||||
|
def test_invalid_glb(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
extract_gltf_metadata(b"not a glb")
|
||||||
|
|
||||||
|
def test_invalid_gltf_json(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_parse_gltf_json(b"[1, 2, 3]")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""The auth endpoints are rate-limited per client IP."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import rate_limit
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_limiter():
|
||||||
|
# This module tests the limiter itself: force it on (the checkpoint
|
||||||
|
# runner disables it suite-wide via RATE_LIMIT_ENABLED) and start each
|
||||||
|
# test from an empty window.
|
||||||
|
prev = settings.RATE_LIMIT_ENABLED
|
||||||
|
settings.RATE_LIMIT_ENABLED = True
|
||||||
|
rate_limit.reset_all()
|
||||||
|
yield
|
||||||
|
rate_limit.reset_all()
|
||||||
|
settings.RATE_LIMIT_ENABLED = prev
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_is_limited_after_the_threshold():
|
||||||
|
client = TestClient(app)
|
||||||
|
codes = []
|
||||||
|
for i in range(15):
|
||||||
|
r = client.post("/api/auth/login", data={"username": f"nobody{i}", "password": "x"})
|
||||||
|
codes.append(r.status_code)
|
||||||
|
# First 10 get through to the (failing) auth check; the rest are 429.
|
||||||
|
assert codes[:10] == [401] * 10
|
||||||
|
assert 429 in codes[10:]
|
||||||
|
assert codes[-1] == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_shares_the_same_bucket():
|
||||||
|
client = TestClient(app)
|
||||||
|
# Burn the budget on login...
|
||||||
|
for i in range(10):
|
||||||
|
client.post("/api/auth/login", data={"username": f"x{i}", "password": "y"})
|
||||||
|
# ...register from the same IP is now blocked too.
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "rl_user", "email": "rl@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": "1990-01-01",
|
||||||
|
})
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert "Retry-After" in r.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_limiter_window_is_per_key():
|
||||||
|
limiter = rate_limit.SlidingWindowLimiter(max_hits=2, window_seconds=60)
|
||||||
|
limiter.check("a")
|
||||||
|
limiter.check("a")
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
limiter.check("a")
|
||||||
|
limiter.check("b") # different key, unaffected
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Tests for the POST /api/auth/refresh endpoint.
|
||||||
|
|
||||||
|
These tests exercise the full stack: register → get refresh token →
|
||||||
|
refresh → new access token. Requires migrations already applied
|
||||||
|
(alembic upgrade head) before running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _override_db():
|
||||||
|
"""Override get_db so the app uses a fresh session per test."""
|
||||||
|
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def _get_db():
|
||||||
|
async_session = test_session_factory()
|
||||||
|
try:
|
||||||
|
yield async_session
|
||||||
|
await async_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await async_session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await async_session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _get_db
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Refresh endpoint tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRefresh:
|
||||||
|
"""Tests for POST /api/auth/refresh."""
|
||||||
|
|
||||||
|
def _register_user(self, username: str, password: str, email: str = None):
|
||||||
|
"""Helper to register a user and return the refresh token."""
|
||||||
|
client = TestClient(app)
|
||||||
|
if email is None:
|
||||||
|
email = f"{username}@example.com"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()["refresh_token"]
|
||||||
|
|
||||||
|
def test_refresh_success(self):
|
||||||
|
"""A valid refresh token returns a new access token."""
|
||||||
|
refresh_token = self._register_user("refep_refreshuser", "secret123")
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
# The refresh token in the response is the same one we sent
|
||||||
|
assert data["refresh_token"] == refresh_token
|
||||||
|
|
||||||
|
def test_refresh_returns_different_access_token(self):
|
||||||
|
"""Refreshing produces a new access token (different from any previous)."""
|
||||||
|
refresh_token = self._register_user("refep_refreshtoken", "secret123")
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# First refresh
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
access_token_1 = resp1.json()["access_token"]
|
||||||
|
|
||||||
|
# Second refresh
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
access_token_2 = resp2.json()["access_token"]
|
||||||
|
|
||||||
|
# Each refresh should produce a new access token
|
||||||
|
assert access_token_1 != access_token_2
|
||||||
|
|
||||||
|
def test_refresh_invalid_token(self):
|
||||||
|
"""An invalid (garbage) refresh token returns 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": "not.a.jwt.token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_refresh_empty_body(self):
|
||||||
|
"""Missing refresh_token field returns 422 validation error."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_refresh_missing_field(self):
|
||||||
|
"""No body at all returns 422."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
content="{}",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_refresh_with_login_token(self):
|
||||||
|
"""A refresh token obtained from login also works for /refresh."""
|
||||||
|
client = TestClient(app)
|
||||||
|
# Register first
|
||||||
|
client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": "refep_loginrefuser",
|
||||||
|
"email": "loginref@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Login to get a refresh token
|
||||||
|
login_resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data={"username": "refep_loginrefuser", "password": "secret123"},
|
||||||
|
)
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
refresh_token = login_resp.json()["refresh_token"]
|
||||||
|
|
||||||
|
# Use it to refresh
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "access_token" in resp.json()
|
||||||
|
|
||||||
|
def test_refresh_access_token_is_valid_jwt(self):
|
||||||
|
"""The access token returned by /refresh is a valid JWT."""
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
refresh_token = self._register_user("refep_jwttoken", "secret123")
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
access_token = resp.json()["access_token"]
|
||||||
|
|
||||||
|
# Verify it's a 3-part JWT
|
||||||
|
parts = access_token.split(".")
|
||||||
|
assert len(parts) == 3
|
||||||
|
|
||||||
|
# Verify it decodes with the public key
|
||||||
|
payload = jwt.decode(
|
||||||
|
access_token,
|
||||||
|
settings.public_pem,
|
||||||
|
algorithms=[settings.ALGORITHM],
|
||||||
|
)
|
||||||
|
assert "sub" in payload
|
||||||
|
assert "exp" in payload
|
||||||
|
|
||||||
|
def test_refresh_expires_minutes_in_response(self):
|
||||||
|
"""The response includes access_token_expires_minutes."""
|
||||||
|
refresh_token = self._register_user("refep_expiretest", "secret123")
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "access_token_expires_minutes" in resp.json()
|
||||||
|
assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Tests for refresh token uniqueness (jti claim) and model column length."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.routers.auth import _create_refresh_token
|
||||||
|
from app.models.session import RefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshTokenJtiUniqueness:
|
||||||
|
"""Verify that _create_refresh_token produces unique tokens even when
|
||||||
|
called with identical data in rapid succession (issue #1 from review)."""
|
||||||
|
|
||||||
|
def test_two_calls_produce_different_tokens(self):
|
||||||
|
"""Two calls with the same sub produce different JWTs because of jti."""
|
||||||
|
token_a = _create_refresh_token({"sub": "42"})
|
||||||
|
token_b = _create_refresh_token({"sub": "42"})
|
||||||
|
assert token_a != token_b
|
||||||
|
|
||||||
|
def test_jti_is_valid_uuid(self):
|
||||||
|
"""The jti claim inside the refresh token is a valid UUID."""
|
||||||
|
token = _create_refresh_token({"sub": "1"})
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.public_pem,
|
||||||
|
algorithms=[settings.ALGORITHM],
|
||||||
|
)
|
||||||
|
assert "jti" in payload
|
||||||
|
# Verify it parses as a UUID (raises ValueError if not)
|
||||||
|
uuid.UUID(payload["jti"])
|
||||||
|
|
||||||
|
def test_each_token_has_unique_jti(self):
|
||||||
|
"""Generate 100 tokens for the same user — all jtis must differ."""
|
||||||
|
jtis = set()
|
||||||
|
for _ in range(100):
|
||||||
|
token = _create_refresh_token({"sub": "999"})
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.public_pem,
|
||||||
|
algorithms=[settings.ALGORITHM],
|
||||||
|
)
|
||||||
|
jtis.add(payload["jti"])
|
||||||
|
assert len(jtis) == 100
|
||||||
|
|
||||||
|
def test_jti_survives_decode(self):
|
||||||
|
"""The jti claim is present and correct after decode."""
|
||||||
|
token = _create_refresh_token({"sub": "7"})
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.public_pem,
|
||||||
|
algorithms=[settings.ALGORITHM],
|
||||||
|
)
|
||||||
|
assert payload["sub"] == "7"
|
||||||
|
assert "jti" in payload
|
||||||
|
assert "exp" in payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshTokenModelColumnLength:
|
||||||
|
"""Verify the model's token column length matches the migration (issue #2)."""
|
||||||
|
|
||||||
|
def test_token_column_has_length_512(self):
|
||||||
|
"""RefreshToken.token column should be String(512), not unbounded."""
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# Get the column definition from the model's table
|
||||||
|
column = RefreshToken.__table__.columns["token"]
|
||||||
|
# The type should be String with length 512
|
||||||
|
assert column.type.length == 512
|
||||||
|
|
||||||
|
def test_token_column_type_is_string(self):
|
||||||
|
"""The token column should be a String type."""
|
||||||
|
from sqlalchemy import String as SAString
|
||||||
|
|
||||||
|
column = RefreshToken.__table__.columns["token"]
|
||||||
|
assert isinstance(column.type, SAString)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for POST /api/worlds/{id}/join.
|
||||||
|
|
||||||
|
The happy path spawns a real Godot process, so it's covered by a manual /
|
||||||
|
checkpoint integration test, not here. These cover the envelope: auth,
|
||||||
|
visibility, and the 503 when orchestration is disabled (the default).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(client: TestClient, name: str) -> tuple[dict, int]:
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": name, "email": f"{name}@e.com", "password": "secret123", "date_of_birth": "1990-01-01",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||||
|
me = client.get("/api/users/me", headers=headers)
|
||||||
|
return headers, me.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
|
||||||
|
r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestJoinWorld:
|
||||||
|
def test_requires_auth(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.post("/api/worlds/1/join").status_code == 401
|
||||||
|
|
||||||
|
def test_unknown_world_is_404(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers, _ = _auth(client, "join_404")
|
||||||
|
assert client.post("/api/worlds/999999/join", headers=headers).status_code == 404
|
||||||
|
|
||||||
|
def test_private_world_of_another_user_is_404(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
owner_headers, _ = _auth(client, "join_owner")
|
||||||
|
wid = _make_world(client, owner_headers, "private")
|
||||||
|
other_headers, _ = _auth(client, "join_other")
|
||||||
|
assert client.post(f"/api/worlds/{wid}/join", headers=other_headers).status_code == 404
|
||||||
|
|
||||||
|
def test_disabled_orchestration_returns_503(self):
|
||||||
|
# GAME_SERVERS_ENABLED defaults to False in tests.
|
||||||
|
client = TestClient(app)
|
||||||
|
headers, _ = _auth(client, "join_503")
|
||||||
|
wid = _make_world(client, headers, "public")
|
||||||
|
r = client.post(f"/api/worlds/{wid}/join", headers=headers)
|
||||||
|
assert r.status_code == 503
|
||||||
|
assert "not enabled" in r.json()["detail"]
|
||||||
@@ -449,6 +449,37 @@ The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9a. Loadout Persistence (implemented, PC3 Phase 1)
|
||||||
|
|
||||||
|
**Storage.** One row per user in `avatar_loadouts` (`user_id` PK/FK,
|
||||||
|
`data` JSONB, timestamps). The entire loadout is stored as a single JSON
|
||||||
|
document in the exact shape the client's `AvatarLoadout.to_json()` emits
|
||||||
|
(`{"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}`) rather than
|
||||||
|
exploded into columns.
|
||||||
|
|
||||||
|
**Why one JSON blob, not columns.** The server has no copy of the asset
|
||||||
|
catalog (`assets/avatars/catalog.json` ships with the client). It therefore
|
||||||
|
cannot meaningfully validate *which* asset belongs in *which* slot, whether
|
||||||
|
an asset exists, or slot-exclusivity rules — that validation stays on the
|
||||||
|
client (`AvatarLoadout.validate()` against the loaded `AvatarCatalog`). The
|
||||||
|
server enforces only the envelope: a JSON object with a non-empty string
|
||||||
|
`base_body`, an object `slots`, and a sane size cap. Keeping the schema
|
||||||
|
opaque to the server also means catalog/loadout-shape changes don't need a
|
||||||
|
migration.
|
||||||
|
|
||||||
|
**Endpoints** (`/api/avatar`, bearer auth):
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/loadout` | The user's saved loadout, or the server default (`{"base_body": "base_human_male_01", "slots": {}}`, `is_default: true`) if they have never saved one. |
|
||||||
|
| `PUT` | `/loadout` | Replace the user's loadout (full document, not a patch). |
|
||||||
|
|
||||||
|
**Defaults.** A brand-new account has no row; `GET` returns
|
||||||
|
`models.avatar_loadout.DEFAULT_LOADOUT` with `is_default: true` so the client
|
||||||
|
can distinguish "never customised" from "customised back to the base look".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 10. Summary
|
## 10. Summary
|
||||||
|
|
||||||
| Concern | Decision |
|
| Concern | Decision |
|
||||||
|
|||||||
@@ -227,18 +227,33 @@ An account has an **age verification status**:
|
|||||||
|
|
||||||
### 5.2 Tier 1 — Self-Declaration Flow
|
### 5.2 Tier 1 — Self-Declaration Flow
|
||||||
|
|
||||||
|
> **Implemented, PC3 Phase 3.** `POST /api/auth/register` now takes a real
|
||||||
|
> `date_of_birth` (ISO `YYYY-MM-DD`), not the throwaway `over_18` boolean it
|
||||||
|
> had before. The server computes age (`auth.calculate_age`) and rejects
|
||||||
|
> anyone under `auth.MIN_AGE` (18) with a **403** — this platform is
|
||||||
|
> adults-only, so there is no "minor, restricted" tier here (the 13+ rows
|
||||||
|
> below are the general design, not the current gate). A future-in-the-past
|
||||||
|
> or nonsensical date is a **400**. The DOB is stored on the user row and
|
||||||
|
> surfaced only back to that user (`/api/auth/me`), never to other players.
|
||||||
|
>
|
||||||
|
> **Limitation, stated plainly:** this is still self-attested — a determined
|
||||||
|
> user can enter a false DOB. It is a concrete, stored, legally-meaningful
|
||||||
|
> data point (matching how most platforms gate before adding hard checks)
|
||||||
|
> and the seam for Tier 2. Encryption-at-rest for the DOB column and the
|
||||||
|
> Tier 2 ID-check hook below are **not yet implemented**.
|
||||||
|
|
||||||
```
|
```
|
||||||
User submits { date_of_birth }
|
User submits { date_of_birth }
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Server validates format & plausibility (age 13–120, not in future)
|
Server validates format & plausibility (real date, not in future)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Compute age from DOB
|
Compute age from DOB
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
age >= 18 ──► status = self_declared (adult)
|
age >= 18 ──► status = self_declared (adult) ← the current gate
|
||||||
age >= 13 ──► status = self_declared (minor, restricted)
|
age >= 13 ──► status = self_declared (minor, restricted) ← design only
|
||||||
age < 13 ──► status = rejected (below minimum platform age)
|
age < 13 ──► status = rejected (below minimum platform age)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,62 @@ snapshot_state:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7a. Server Orchestration (implemented, PC3 Phase 2)
|
||||||
|
|
||||||
|
**One dedicated server process per world**, spawned on demand.
|
||||||
|
|
||||||
|
```
|
||||||
|
client backend (single worker) host
|
||||||
|
│ POST /api/worlds/{id}/join (bearer JWT) │
|
||||||
|
│ ───────────────────────────▶ GameServerSupervisor │
|
||||||
|
│ ensure_server(world_id): │
|
||||||
|
│ if none running for world ─── spawn:
|
||||||
|
│ godot --headless
|
||||||
|
│ res://scenes/server.tscn --
|
||||||
|
│ --port <p> --world <id>
|
||||||
|
│ --status-file <path>
|
||||||
|
│ ◀─────────────────────────── { host, port, world_id } │
|
||||||
|
│ │
|
||||||
|
│ ENet connect host:port ───────────────────────────────────▶ dedicated server
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Supervisor** (`backend/app/services/game_servers.py`): in-process singleton,
|
||||||
|
so the backend MUST run single-worker. Keys servers by `world_id`. Allocates
|
||||||
|
ports from `GAME_SERVER_PORT_MIN..MAX`. A background reaper kills a world's
|
||||||
|
server once its **status file** (`{players, tick, updated}`, rewritten by the
|
||||||
|
server ~1×/s) reports `players == 0` for `GAME_SERVER_IDLE_TIMEOUT_SECONDS`.
|
||||||
|
No crash-restart — the next join respawns.
|
||||||
|
- **Enable flag:** `GAME_SERVERS_ENABLED` (default off). The checkpoint/playtest
|
||||||
|
launcher (`pm/checkpoint_ops.py`) turns it on, passes `GODOT_BIN`,
|
||||||
|
`GAME_PROJECT_DIR`, and `GAME_SERVER_HOST` (the LAN IP baked into the `.exe`).
|
||||||
|
A backend with it off returns **503** from `/join`, and the client falls back
|
||||||
|
to local single-player.
|
||||||
|
- **Per-world content:** all worlds currently load the same test level (M2/M7
|
||||||
|
work). `world_id` still scopes a distinct process + session.
|
||||||
|
|
||||||
|
### 7b. Client play path
|
||||||
|
|
||||||
|
`world_list.gd` → `POST /join` → `Session.begin(world_id, host, port)` →
|
||||||
|
load `game.tscn`. `game.gd` sees `Session.active` and connects a
|
||||||
|
`NetworkClient`:
|
||||||
|
|
||||||
|
- **Local player** keeps predicting with its own collision-aware
|
||||||
|
`CharacterBody3D` (`player.gd`) — that *is* the prediction. Its input frame
|
||||||
|
(`player.net_input`) is forwarded to the server every physics tick.
|
||||||
|
- **Correction backstop:** if the local position drifts from the server's by
|
||||||
|
more than `CORRECTION_THRESHOLD` (1.5 m — generous; catches teleport / a wall
|
||||||
|
walked through / real desync, not prediction noise) it snaps.
|
||||||
|
- **Remote players:** rendered from snapshots, position lerped. *Appearance sync
|
||||||
|
is a follow-up* — remotes currently show as labelled capsules; the server
|
||||||
|
does not yet replicate avatar loadouts.
|
||||||
|
- **Disconnect** → status message → back to the world list.
|
||||||
|
|
||||||
|
The `NetworkClient`'s own flat-plane prediction + input-replay reconciliation
|
||||||
|
(§5) is exercised by `smoke_test_network_client.gd`; the collision-aware game
|
||||||
|
path above uses the lighter `send_input()` + drift-snap instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 8. Security & Anti-Cheat
|
## 8. Security & Anti-Cheat
|
||||||
|
|
||||||
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# PC2 Test Level & Player Spawn — Design Note
|
||||||
|
|
||||||
|
> **Status:** Decided (PC2)
|
||||||
|
> **Owner:** Architecture
|
||||||
|
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||||
|
> (2) build the player & spawn it. Both must be able to follow this note
|
||||||
|
> without further design decisions.
|
||||||
|
|
||||||
|
## 1. Decision: static scene, not chunk streaming
|
||||||
|
|
||||||
|
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||||
|
scene.** It is **not** built through the chunk streaming system
|
||||||
|
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||||
|
`chunk_data_decoder.gd`).
|
||||||
|
|
||||||
|
This is a deliberate decision, not a default:
|
||||||
|
|
||||||
|
- **The streaming system has never been exercised with real content.** It is a
|
||||||
|
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||||
|
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||||
|
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||||
|
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||||
|
chunk authoring/streaming endpoint to integrate against.
|
||||||
|
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||||
|
streaming is server-authoritative streaming around a moving player across a
|
||||||
|
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||||
|
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||||
|
clearly out of checkpoint scope.
|
||||||
|
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||||
|
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||||
|
|
||||||
|
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||||
|
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||||
|
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||||
|
of the *world data* half.
|
||||||
|
|
||||||
|
## 2. Where things live
|
||||||
|
|
||||||
|
| Artifact | Path | Owned by |
|
||||||
|
|---|---|---|
|
||||||
|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||||
|
| Player scene | `scenes/player.tscn` | player task |
|
||||||
|
| Player controller script | `scripts/player.gd` | player task |
|
||||||
|
| Game composition scene | `scenes/game.tscn` | player task |
|
||||||
|
|
||||||
|
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||||
|
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||||
|
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||||
|
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||||
|
|
||||||
|
## 3. Player spawn convention
|
||||||
|
|
||||||
|
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||||
|
That node's world transform is the spawn point.
|
||||||
|
|
||||||
|
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||||
|
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||||
|
hint." (Confirmed present in Godot 4.6.2.)
|
||||||
|
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||||
|
player (spawn yaw is the player's concern, default forward is fine).
|
||||||
|
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||||
|
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||||
|
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||||
|
the floor and get popped out by the physics solver.
|
||||||
|
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||||
|
multiple spawns, that is a later convention — not PC2.)
|
||||||
|
|
||||||
|
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# in player.gd _ready()
|
||||||
|
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||||
|
if spawn is Node3D:
|
||||||
|
global_position = spawn.global_position
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||||
|
```
|
||||||
|
|
||||||
|
The lookup runs from the current scene root so it finds the marker whether it is
|
||||||
|
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||||
|
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||||
|
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||||
|
on it (see §6).
|
||||||
|
|
||||||
|
## 4. Composition (who puts level + player together)
|
||||||
|
|
||||||
|
`scenes/game.tscn` is the PC2 playable entry point:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game (Node3D)
|
||||||
|
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||||
|
└── Player (instance of res://scenes/player.tscn)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||||
|
instantiation, not code, so there is nothing to keep in sync.
|
||||||
|
- The **level scene is self-contained and has zero dependency on the player** —
|
||||||
|
it loads and is smoke-testable on its own. The level task does not instance or
|
||||||
|
reference `player.tscn`.
|
||||||
|
- The **player scene is self-contained and has zero dependency on any specific
|
||||||
|
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||||
|
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||||
|
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||||
|
|
||||||
|
## 5. Level content requirements (level task)
|
||||||
|
|
||||||
|
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||||
|
children:
|
||||||
|
|
||||||
|
| Node | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||||
|
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||||
|
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||||
|
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||||
|
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||||
|
behavior belongs to the player (or a future game-mode script), not the level.
|
||||||
|
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||||
|
coordinates, no thin walls the capsule can tunnel through.
|
||||||
|
|
||||||
|
## 6. Player contract (player task)
|
||||||
|
|
||||||
|
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||||
|
|
||||||
|
Minimum for PC2:
|
||||||
|
|
||||||
|
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||||
|
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||||
|
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||||
|
the lowest-risk choice; third-person is fine too but is more work.
|
||||||
|
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||||
|
gracefully.
|
||||||
|
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||||
|
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||||
|
may be split into its own task if the controller grows.
|
||||||
|
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||||
|
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||||
|
|
||||||
|
The player must work when instanced under `Game` alongside a level; it must not
|
||||||
|
assume it is the only node in the scene.
|
||||||
|
|
||||||
|
## 7. Entry point / how a human plays it
|
||||||
|
|
||||||
|
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||||
|
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||||
|
`application/run/main_scene` to it.
|
||||||
|
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||||
|
for this note** and out of scope for the level/player tasks. The current menu
|
||||||
|
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||||
|
the test level, that navigation wiring is its own small task — do not fold it
|
||||||
|
into level or player.
|
||||||
|
|
||||||
|
## 8. Validation (both tasks must run this before "done")
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time, first checkout only — builds the class/import cache
|
||||||
|
godot --headless --path . --import
|
||||||
|
|
||||||
|
# then, to catch parse/load errors:
|
||||||
|
godot --headless --path . --quit
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||||
|
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||||
|
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||||
|
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||||
|
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||||
|
and asserts the player's global position equals the `PlayerSpawn` position
|
||||||
|
(within a small epsilon). This is the check that proves the spawn convention
|
||||||
|
actually wired through — not just "the scene loads."
|
||||||
|
|
||||||
|
## 9. Explicitly out of scope for PC2
|
||||||
|
|
||||||
|
- Networking / multiplayer / prediction (single-player, local-only).
|
||||||
|
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||||
|
- Avatar customization UI and glTF avatar loading.
|
||||||
|
- Join-a-world navigation from the world list.
|
||||||
|
- Any content moderation / age-gating logic in the level.
|
||||||
|
|
||||||
|
## 10. Summary
|
||||||
|
|
||||||
|
| Concern | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||||
|
| Level location | `scenes/worlds/test_level.tscn` |
|
||||||
|
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||||
|
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||||
|
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||||
|
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Package Checkpoint 1 (PC1)
|
||||||
|
|
||||||
|
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
|
||||||
|
clone (not a reused agent workspace).
|
||||||
|
|
||||||
|
## Automated validation
|
||||||
|
|
||||||
|
Run with `scripts/run_checkpoint_tests.py` — see
|
||||||
|
`docs/testing/test_inventory.md` for exactly what each suite covers.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Alembic migrations, fresh DB | PASS |
|
||||||
|
| Backend pytest | 68/68 PASS |
|
||||||
|
| Godot smoke tests (4 files) | ALL PASS |
|
||||||
|
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
|
||||||
|
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
|
||||||
|
|
||||||
|
## Problems found during this checkpoint (fixed, tracked as tasks)
|
||||||
|
|
||||||
|
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
|
||||||
|
done — a historical data gap (a review-feedback follow-up task's
|
||||||
|
supersession link was never recorded, predating when that field started
|
||||||
|
being tracked). Backfilled in the PM database; confirmed green.
|
||||||
|
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
|
||||||
|
actually 68; said no integration tests existed, 4 real ones had landed).
|
||||||
|
Fixed via task `398bf1a22a2d` / PR #40.
|
||||||
|
|
||||||
|
## What's actually testable right now
|
||||||
|
|
||||||
|
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
|
||||||
|
the real scope of PC1; don't expect more when playtesting.
|
||||||
|
|
||||||
|
- Register a new account (18+ only, rejected otherwise)
|
||||||
|
- Log out, log back in with the same credentials
|
||||||
|
- Play → Browse Worlds — loads a (currently empty) world list with no errors
|
||||||
|
|
||||||
|
## Manual playtest: backend on cloudbison, client on pinkbison
|
||||||
|
|
||||||
|
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
|
||||||
|
env vars, no editing files. Foolproof enough to hand to someone else.
|
||||||
|
|
||||||
|
### 1. On cloudbison — the AGP dashboard
|
||||||
|
|
||||||
|
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
|
||||||
|
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
|
||||||
|
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
|
||||||
|
the API server, all in one step. The page shows the LAN address it's
|
||||||
|
reachable at once it's up (under a minute).
|
||||||
|
|
||||||
|
When you're done playtesting, click **Stop & Clean Up** on the same page —
|
||||||
|
it tears down everything that step started (API server, Postgres
|
||||||
|
container, its volume and network). Nothing is left running or lying
|
||||||
|
around afterward, even if the dashboard itself gets restarted in between.
|
||||||
|
|
||||||
|
### 2. On pinkbison — download and run the .exe
|
||||||
|
|
||||||
|
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
|
||||||
|
pick up newer changes) — this pulls the latest `main` and exports a Windows
|
||||||
|
binary with cloudbison's backend address already baked in. Click
|
||||||
|
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
|
||||||
|
environment variable, no config file, no build step on pinkbison at all.
|
||||||
|
|
||||||
|
### 3. Tear down when done
|
||||||
|
|
||||||
|
**Stop & Clean Up** on the dashboard page. That's it.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
|
||||||
|
restricts agent claiming to the 5 PC1-scope features. Agents will not move
|
||||||
|
on to post-PC1 work until that's widened.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Package Checkpoint 3 (PC3)
|
||||||
|
|
||||||
|
> **Status: partial.** PC3's planned scope was M4-avatars + M5-multiplayer +
|
||||||
|
> Foundation Hardening + M6–M10 (see `docs/build-plan-v1.md`). As of
|
||||||
|
> 2026-09-05, **M4, M5, and most of Foundation Hardening are done and
|
||||||
|
> checkpoint-verified**; M6–M10 have not been started. This doc records what
|
||||||
|
> is real. Whether to call this "PC3" (redefined scope, M6–M10 → PC4) or keep
|
||||||
|
> PC3 open is a user decision.
|
||||||
|
|
||||||
|
Built by Claude directly (fleet offline), branch `pc3` → `main`.
|
||||||
|
|
||||||
|
## What's real and verified (`scripts/run_checkpoint_tests.py`, all green)
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Alembic migrations, fresh DB | PASS |
|
||||||
|
| Backend `pytest` | 77/77 PASS (hermetic — auto-starts an ephemeral Postgres) |
|
||||||
|
| Godot smoke tests (~16 files) | ALL PASS |
|
||||||
|
| `smoke_test_network_client` (real dedicated server) | 4/4 PASS |
|
||||||
|
| 2-player multiplayer (`run_multiplayer_2p_test.sh`) | PASS |
|
||||||
|
| Fresh-clone boot (`verify_fresh_clone.sh`) | PASS |
|
||||||
|
| Linux + Windows exports | Build as real ELF64 / PE32+ binaries |
|
||||||
|
|
||||||
|
### M4 — Avatars in the game
|
||||||
|
|
||||||
|
- Placeholder avatar pack (14 GLBs, rigged 24-bone base body + attachments)
|
||||||
|
generated by `scripts/tools/gen_placeholder_avatars.gd`. Real art is a
|
||||||
|
content swap — catalog paths are stable.
|
||||||
|
- The avatar **loader was fixed** — it had never run against a real skeleton
|
||||||
|
(`Skeleton3D.has_bone()` isn't a Godot 4 method; the bone-copy dropped the
|
||||||
|
skin binding; attachments were parsed then discarded).
|
||||||
|
- `avatar_visualizer.gd` **rewritten** — the old one referenced a loader API
|
||||||
|
that never existed and didn't compile.
|
||||||
|
- Persistence: `avatar_loadouts` table + `GET/PUT /api/avatar/loadout`
|
||||||
|
(loadout stored as one JSON doc; server validates the envelope only).
|
||||||
|
- Wired into gameplay: the in-world player renders as the assembled avatar
|
||||||
|
(not a capsule); the loadout is fetched at login; "Customize Avatar" from
|
||||||
|
the main menu saves through to the backend.
|
||||||
|
- **Follow-up:** camera is still first-person, so the local player doesn't
|
||||||
|
see their own avatar much (third-person is a polish item); remote players'
|
||||||
|
avatar appearance is not replicated yet (they show as labelled capsules).
|
||||||
|
|
||||||
|
### M5 — Real multiplayer
|
||||||
|
|
||||||
|
- `server.gd` **actually runs a sim now** — loads the world level, spawns a
|
||||||
|
server player per peer, processes each peer's input queue in seq order at a
|
||||||
|
fixed 30 Hz, broadcasts full snapshots with a per-peer `last_seq`. (The
|
||||||
|
input queue was a no-op before.)
|
||||||
|
- `network_client.gd` reconciliation **replays in-flight inputs** from the
|
||||||
|
authoritative state (drops acked ones), snaps only on real drift.
|
||||||
|
- **Server orchestration** (`backend/app/services/game_servers.py`): one
|
||||||
|
headless Godot dedicated-server process per world, spawned on demand by
|
||||||
|
`POST /api/worlds/{id}/join`, port-allocated, with a status-file idle
|
||||||
|
reaper. Off by default; the playtest launcher (`checkpoint_ops.py`)
|
||||||
|
enables it.
|
||||||
|
- **Client play path**: world browser → `/join` → `Session` → `game.tscn`
|
||||||
|
connects → local player predicts with collision, input forwarded, server
|
||||||
|
position is a drift backstop, remote players render from snapshots,
|
||||||
|
disconnect returns to the world list. Falls back to local single-player
|
||||||
|
when the backend has no orchestration.
|
||||||
|
- **Verified**: two real client processes each `register → /join → play`,
|
||||||
|
hosted on an orchestrated server, see and move relative to each other
|
||||||
|
(`run_game_multiplayer_test.sh`).
|
||||||
|
|
||||||
|
### Foundation Hardening
|
||||||
|
|
||||||
|
- **Hermetic backend tests** — `cd backend && pytest` works from a clean
|
||||||
|
checkout (auto ephemeral Postgres + migrations, torn down after); skips
|
||||||
|
cleanly with a message if there's no Docker.
|
||||||
|
- **Fresh-clone boot check** — `verify_fresh_clone.sh` exports a pristine
|
||||||
|
tree, imports from scratch, boots headless, fails on any script error.
|
||||||
|
Also fixed the root cause: a stale `.godot/global_script_class_cache.cfg`
|
||||||
|
was committed to the repo.
|
||||||
|
- **Real age verification** — `/register` takes a date of birth (not a
|
||||||
|
checkbox), server computes age, 403 under 18. Still self-attested; the
|
||||||
|
limitation and the ID-check hook are documented
|
||||||
|
(`content_moderation.md` 5.2).
|
||||||
|
- **Auth rate limiting** — 10 attempts / min / IP on `/register` + `/login`.
|
||||||
|
- **CI workflow** — `.forgejo/workflows/checkpoint.yml` written, **not
|
||||||
|
running yet**: needs the operator to enable Forgejo Actions + register a
|
||||||
|
`host` runner (one-time; see `docs/ci.md`). Interim gate is
|
||||||
|
`run_checkpoint_tests.py` by hand / the dashboard.
|
||||||
|
|
||||||
|
## Not in PC3 (planned but not started)
|
||||||
|
|
||||||
|
- **M2** server-side world persistence
|
||||||
|
- **M6** scripting sandbox
|
||||||
|
- **M7** in-game editor
|
||||||
|
- **M8** social / chat
|
||||||
|
- **M9** economy
|
||||||
|
- **M10** discovery
|
||||||
|
|
||||||
|
## Playtest
|
||||||
|
|
||||||
|
Same as PC2 — grab the exe from the dashboard's `/checkpoints`, backend
|
||||||
|
pointed at it. New: with multiple people in the same world you now actually
|
||||||
|
see each other move.
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# CI
|
||||||
|
|
||||||
|
`.gitea/workflows/checkpoint.yml` runs the full checkpoint suite
|
||||||
|
(`scripts/run_checkpoint_tests.py`: migrations on a fresh DB, backend
|
||||||
|
`pytest`, all Godot smoke tests, the 2-player multiplayer test, and the
|
||||||
|
fresh-clone boot check) on **every push to `main` and every PR**.
|
||||||
|
|
||||||
|
## Status: live
|
||||||
|
|
||||||
|
Set up 2026-09-05:
|
||||||
|
|
||||||
|
- Gitea Actions enabled (`[actions] ENABLED = true` in
|
||||||
|
`/DATA/AppData/forgejo/gitea/conf/app.ini`, container restarted; a
|
||||||
|
timestamped `.bak` is next to it).
|
||||||
|
- `act_runner` v0.2.13 registered as **`agp-host`** with label **`host`**
|
||||||
|
(host execution mode — jobs run directly on the AGP box, which already
|
||||||
|
has Docker, `~/godot/godot`, and Python). Lives in `~/act-runner/`.
|
||||||
|
- Green on the first successful run (`d76bf4d`).
|
||||||
|
|
||||||
|
The workflow uses **no JavaScript actions** (no `actions/checkout`), so the
|
||||||
|
host needs no Node: checkout is a shallow `git fetch` from the local Gitea
|
||||||
|
using the injected `${{ github.token }}`, and the suite runs in a venv the
|
||||||
|
job builds (host Python is PEP-668 externally-managed).
|
||||||
|
|
||||||
|
## Make the runner permanent (one command, needs sudo)
|
||||||
|
|
||||||
|
The runner currently runs as a plain background process — it won't survive a
|
||||||
|
reboot. A unit file is ready at `~/act-runner/act-runner.service`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp ~/act-runner/act-runner.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
pkill -f 'act_runner daemon' # stop the temporary background one
|
||||||
|
sudo systemctl enable --now act-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interim / local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/run_checkpoint_tests.py
|
||||||
|
```
|
||||||
|
|
||||||
|
— also what `/checkpoint/<PC>` on the dashboard runs before a playtest build.
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
A host-mode runner executes whatever a workflow says, directly on the box,
|
||||||
|
as `daddy` (in the `docker` group). Fine for a single-maintainer private
|
||||||
|
repo; revisit if the repo gets outside contributors (switch to a
|
||||||
|
docker-mode runner or an isolated VM).
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# MVP Vertical Slice: Client-Server Wiring — Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Prove the real Godot client and the real backend can talk: **a user can register/log in
|
||||||
|
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
|
||||||
|
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
|
||||||
|
explicitly out of scope.
|
||||||
|
|
||||||
|
## Verified backend contract (as of this writing)
|
||||||
|
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
|
||||||
|
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
|
||||||
|
Rejects under-18 with `400`.
|
||||||
|
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
|
||||||
|
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
|
||||||
|
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
|
||||||
|
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
|
||||||
|
public-worlds path, so the worlds list screen can only be shown after login/register.
|
||||||
|
- `GET /api/health` — unauthenticated, `{status, app}`.
|
||||||
|
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
|
||||||
|
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
|
||||||
|
|
||||||
|
## Task breakdown
|
||||||
|
| # | Type | Task | Depends on |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||||
|
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
|
||||||
|
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
|
||||||
|
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
|
||||||
|
|
||||||
|
## Design decisions captured (resolved in task 1)
|
||||||
|
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||||
|
open but not required yet (no re-login on restart in this slice).
|
||||||
|
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
|
||||||
|
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
||||||
|
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
||||||
|
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
|
||||||
|
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
|
||||||
|
- The world-list screen also needs to be **built** (a new scene), not just wired.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Testing at Package Checkpoints
|
||||||
|
|
||||||
|
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
|
||||||
|
real milestones, the client and server builds get packaged together and
|
||||||
|
independently reviewed before anyone calls anything actually done. See
|
||||||
|
`project_agp` in Claude's memory, or ask, for the full definition; this
|
||||||
|
doc is just the mechanical "how do I run the tests" part of that review.
|
||||||
|
|
||||||
|
## Running the suite
|
||||||
|
|
||||||
|
```
|
||||||
|
python scripts/run_checkpoint_tests.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
|
||||||
|
backend pytest suite, starts the backend server, and runs every Godot
|
||||||
|
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
|
||||||
|
Postgres container down afterward (`--keep-postgres` to skip that if you
|
||||||
|
want to poke at the DB after a failure).
|
||||||
|
|
||||||
|
If port 5432 is already taken on the machine, pass `--postgres-port` with
|
||||||
|
something else.
|
||||||
|
|
||||||
|
Exit code is 0 only if everything that actually ran passed — suites with
|
||||||
|
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
|
||||||
|
reported SKIPPED, not treated as a failure.
|
||||||
|
|
||||||
|
## What this does and doesn't prove
|
||||||
|
|
||||||
|
Passing this script means: migrations apply cleanly to a genuinely empty
|
||||||
|
database, the backend's unit-level logic is correct, and whatever Godot
|
||||||
|
smoke tests exist actually round-trip against a live backend. It does
|
||||||
|
**not** mean full endpoint/integration coverage exists — see
|
||||||
|
`test_inventory.md` for the current, honest list of what's tested and what
|
||||||
|
isn't. A checkpoint review should still involve reading the actual diffs
|
||||||
|
for anything this script's current coverage doesn't reach, not just running
|
||||||
|
green and calling it done.
|
||||||
|
|
||||||
|
## Growing this over time
|
||||||
|
|
||||||
|
As real integration tests get added (endpoint tests hitting the FastAPI app,
|
||||||
|
more Godot smoke coverage beyond `health_check`), they should land under
|
||||||
|
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
|
||||||
|
picks up new files automatically, nothing to register by hand. Update
|
||||||
|
`test_inventory.md`'s tables when you do, so the inventory stays a source
|
||||||
|
of truth instead of quietly going stale.
|
||||||
|
|
||||||
|
Eventual goal (not implemented yet): gate merges to `main` on this script
|
||||||
|
passing, the same way a real CI pipeline would. For now it's a manual step
|
||||||
|
run at checkpoint review time.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Test Inventory
|
||||||
|
|
||||||
|
What automated coverage actually exists, what it actually verifies, and what
|
||||||
|
doesn't have coverage yet. Keep this current as tests are added — it's the
|
||||||
|
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
|
||||||
|
to know what "run the tests" actually checked.
|
||||||
|
|
||||||
|
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||||
|
|
||||||
|
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
|
||||||
|
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||||
|
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||||
|
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||||
|
playable world + character + movement) landed 2026-08-20/21 — see
|
||||||
|
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||||
|
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||||
|
`smoke_test_enter_world.gd`) for what's actually verified. The avatar
|
||||||
|
loader/customization system (`smoke_test_avatar_system.gd`) also landed
|
||||||
|
2026-08-21 — real glTF-based loading with graceful failure on missing
|
||||||
|
assets, verified since `assets/avatars/*.glb` model files don't exist yet.
|
||||||
|
|
||||||
|
## Backend — `backend/tests/` (pytest)
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||||
|
reachable Postgres — see the runner script).
|
||||||
|
|
||||||
|
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
||||||
|
|
||||||
|
| File | Tests | What it covers |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
|
||||||
|
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
||||||
|
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||||
|
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||||
|
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||||
|
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||||
|
|
||||||
|
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
|
||||||
|
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
|
||||||
|
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
|
||||||
|
that hit the FastAPI app against a real Postgres database via `TestClient`.
|
||||||
|
The integration tests exercise the full stack: Pydantic validation → DB →
|
||||||
|
JWT → response. The suite is also re-runnable without manual cleanup —
|
||||||
|
`backend/tests/conftest.py` has a session-scoped autouse fixture that
|
||||||
|
truncates all app tables once before the suite runs.
|
||||||
|
|
||||||
|
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||||
|
these yet):
|
||||||
|
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
|
||||||
|
world/asset CRUD routes — only auth endpoints have integration coverage
|
||||||
|
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
|
||||||
|
- No tests for the moderation schema/migration beyond "the migration runs."
|
||||||
|
- No tests for the `GET /api/auth/me` endpoint's response body (only its
|
||||||
|
auth-header validation is covered via expired-token tests).
|
||||||
|
- No tests for session management (create/validate/revoke sessions).
|
||||||
|
|
||||||
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
|
**12 smoke tests merged into `main`.** Ten need a live backend, one
|
||||||
|
(`smoke_test_network_client.gd`) needs a live dedicated server instead,
|
||||||
|
and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises
|
||||||
|
local catalog/loadout/loader logic. Backend-dependent tests read
|
||||||
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
|
`application/agp/api_base_url` project setting (default
|
||||||
|
`http://127.0.0.1:8000`) if unset.
|
||||||
|
|
||||||
|
| File | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||||
|
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||||
|
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||||
|
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||||
|
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||||
|
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||||
|
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||||
|
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
|
||||||
|
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
|
||||||
|
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
|
||||||
|
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
|
||||||
|
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
|
||||||
|
|
||||||
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
|
then for each smoke test: `godot --headless --path . --script
|
||||||
|
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||||
|
their awaited HTTP calls finish.
|
||||||
|
|
||||||
|
**Known gaps:**
|
||||||
|
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||||
|
- No smoke tests for dynamic/static object nodes.
|
||||||
|
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||||
|
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
|
||||||
|
ones that exercise a real `.tscn` scene instantiation — others only test
|
||||||
|
the `ApiClient` autoload directly.
|
||||||
|
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
|
||||||
|
has no smoke coverage yet.
|
||||||
|
- The Login path (as opposed to Register) through `main_menu.gd` isn't
|
||||||
|
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
|
||||||
|
`_on_auth_complete()` code path already exercised for Register, but that's
|
||||||
|
inference, not a direct check.
|
||||||
|
|
||||||
|
## Client export
|
||||||
|
|
||||||
|
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
|
||||||
|
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
|
||||||
|
exports complete clean from a fresh checkout and produce valid binaries
|
||||||
|
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
|
||||||
|
export is exercised by an automated GUI test (headless Godot can't drive a
|
||||||
|
window) — actually launching and clicking through the exported binary is a
|
||||||
|
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
|
||||||
|
|
||||||
|
## What "full coverage" should eventually mean here
|
||||||
|
|
||||||
|
Not defined precisely yet — the honest target as of 2026-08-20 is
|
||||||
|
"integration tests for every endpoint + smoke tests for every major scene."
|
||||||
|
As real integration/endpoint tests get added, update the tables above
|
||||||
|
rather than letting this doc drift stale; a wrong inventory is worse than
|
||||||
|
no inventory.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
[preset.0]
|
||||||
|
|
||||||
|
name="Linux"
|
||||||
|
platform="Linux/X11"
|
||||||
|
runnable=true
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter="*.json"
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="export/linux/agp-game.x86_64"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.0.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_wrapper=1
|
||||||
|
binary_format/embed_pck=true
|
||||||
|
texture_format/bptc=true
|
||||||
|
texture_format/s3tc=true
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
ssh_remote_deploy/enabled=false
|
||||||
|
|
||||||
|
[preset.1]
|
||||||
|
|
||||||
|
name="Windows Desktop"
|
||||||
|
platform="Windows Desktop"
|
||||||
|
runnable=true
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter="*.json"
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="export/windows/agp-game.exe"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.1.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_wrapper=1
|
||||||
|
binary_format/embed_pck=true
|
||||||
|
texture_format/bptc=true
|
||||||
|
texture_format/s3tc=true
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
codesign/enable=false
|
||||||
|
application/icon=""
|
||||||
|
application/console_wrapper_icon=""
|
||||||
|
application/icon_interpolation=4
|
||||||
|
application/file_version=""
|
||||||
|
application/product_version=""
|
||||||
|
application/company_name=""
|
||||||
|
application/product_name=""
|
||||||
|
application/file_description=""
|
||||||
|
application/copyright=""
|
||||||
|
application/trademarks=""
|
||||||
|
application/export_angle=0
|
||||||
|
application/export_d3d12=0
|
||||||
|
d3d12/agility_sdk_multizip=0
|
||||||
@@ -9,15 +9,54 @@ config_version=5
|
|||||||
config/name="AGP-Game"
|
config/name="AGP-Game"
|
||||||
run/main_scene="res://scenes/main_menu.tscn"
|
run/main_scene="res://scenes/main_menu.tscn"
|
||||||
config/features=PackedStringArray("4.6.2")
|
config/features=PackedStringArray("4.6.2")
|
||||||
|
agp/api_base_url="http://127.0.0.1:8000"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
ApiClient="*res://scripts/api_client.gd"
|
||||||
|
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||||
|
Session="*res://scripts/session.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=1920
|
||||||
window/size/viewport_height=1080
|
window/size/viewport_height=1080
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
|
|
||||||
|
[input]
|
||||||
|
|
||||||
|
move_forward={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_left={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_right={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_down={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
interact={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
[physics]
|
||||||
|
|
||||||
|
; Fixed 30 Hz sim -- the multiplayer server, client prediction, and snapshot
|
||||||
|
; rate all key off this. See docs/architecture/networking.md 3.1.
|
||||||
|
common/physics_ticks_per_second=30
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://avatar_customization"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/avatar_customization_screen.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="AvatarCustomization" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://c0avpre7dev0rig"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/tools/avatar_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="AvatarPreview" type="Node3D"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://bqs8xk2rig0dev"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/tools/screenshot_rig.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="ScreenshotRig" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene load_steps=4 format=3 uid="uid://game_scene_pc2"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/game.gd" id="0_game"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
|
||||||
|
|
||||||
|
[node name="Game" type="Node3D"]
|
||||||
|
script = ExtResource("0_game")
|
||||||
|
|
||||||
|
[node name="Level" parent="." instance=ExtResource("1_level")]
|
||||||
|
|
||||||
|
[node name="Player" parent="." instance=ExtResource("2_player")]
|
||||||
+91
-1
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
[gd_scene load_steps=2 format=3 uid="uid://main_menu_scene"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
|
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
|
||||||
|
|
||||||
@@ -29,15 +29,105 @@ text = "AGP Game"
|
|||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
theme_override_font_sizes/font_size = 72
|
theme_override_font_sizes/font_size = 72
|
||||||
|
|
||||||
|
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Login"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Register"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/VBoxContainer/AuthPanel"]
|
||||||
|
layout_mode = 1
|
||||||
|
theme_override_constants/margin_left = 24
|
||||||
|
theme_override_constants/margin_top = 24
|
||||||
|
theme_override_constants/margin_right = 24
|
||||||
|
theme_override_constants/margin_bottom = 24
|
||||||
|
|
||||||
|
[node name="AuthVBox" type="VBoxContainer" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="AuthModeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Login"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 24
|
||||||
|
|
||||||
|
[node name="UsernameEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
placeholder_text = "Username"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="PasswordEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
placeholder_text = "Password"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
echo_password = true
|
||||||
|
|
||||||
|
[node name="EmailEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
visible = false
|
||||||
|
placeholder_text = "Email"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
visible = false
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
placeholder_text = "Date of birth (YYYY-MM-DD)"
|
||||||
|
|
||||||
|
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Login"
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="AuthStatusLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = ""
|
||||||
|
horizontal_alignment = 1
|
||||||
|
autowrap_mode = 2
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
[node name="WelcomeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
|
layout_mode = 1
|
||||||
|
visible = false
|
||||||
|
text = ""
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Play"
|
text = "Play"
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Customize Avatar"
|
||||||
|
size_flags_horizontal = 4
|
||||||
|
disabled = true
|
||||||
|
|
||||||
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Quit"
|
text = "Quit"
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||||
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||||
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[gd_scene load_steps=5 format=3 uid="uid://player_scene_001"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
|
||||||
|
[ext_resource type="Script" path="res://scripts/scripting/player_inventory.gd" id="2_inventory"]
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape1"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[node name="Player" type="CharacterBody3D"]
|
||||||
|
collision_layer = 2
|
||||||
|
collision_mask = 1
|
||||||
|
script = ExtResource("1_player")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
shape = SubResource("CapsuleShape1")
|
||||||
|
|
||||||
|
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
|
||||||
|
mesh = SubResource("CapsuleMesh1")
|
||||||
|
|
||||||
|
[node name="CameraPivot" type="Node3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
|
||||||
|
|
||||||
|
[node name="SpringArm3D" type="SpringArm3D" parent="CameraPivot"]
|
||||||
|
spring_length = 3.5
|
||||||
|
margin = 0.2
|
||||||
|
|
||||||
|
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
|
||||||
|
current = true
|
||||||
|
|
||||||
|
[node name="Inventory" type="Node" parent="."]
|
||||||
|
script = ExtResource("2_inventory")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/server.gd" id="1_server"]
|
||||||
|
|
||||||
|
[node name="Server" type="Node"]
|
||||||
|
script = ExtResource("1_server")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape_server_player"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[node name="Player" type="CharacterBody3D"]
|
||||||
|
collision_layer = 2
|
||||||
|
collision_mask = 1
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
shape = SubResource("CapsuleShape_server_player")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/world_list.gd" id="1_wl"]
|
||||||
|
|
||||||
|
[node name="WorldList" type="Control"]
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_wl")
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
|
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "< Back"
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "Browse Worlds"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_font_sizes/font_size = 28
|
||||||
|
|
||||||
|
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "Refresh"
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
|
||||||
|
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_mode = 2
|
||||||
|
vertical_mode = 2
|
||||||
|
|
||||||
|
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = "Loading worlds..."
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
|
||||||
|
[node name="ErrorLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = ""
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
|
||||||
|
[node name="EmptyLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
text = ""
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
|
||||||
|
[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"]
|
||||||
|
[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://c49a36fc7db_test_level"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/scripting/script_interpreter.gd" id="1_scripted_chest"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="CylinderShape3D" id="CylinderShape3D_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
|
||||||
|
[sub_resource type="CylinderMesh" id="CylinderMesh_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
bottom_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
subdivisions_top_radius = 8
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
subdivisions_ring = 12
|
||||||
|
subdivisions_height = 12
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
subdivisions_ring = 8
|
||||||
|
subdivisions_height = 8
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_chest"]
|
||||||
|
size = Vector3(1, 0.8, 0.6)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_chest"]
|
||||||
|
size = Vector3(1, 0.8, 0.6)
|
||||||
|
|
||||||
|
[sub_resource type="Environment" id="WorldEnvironment_env"]
|
||||||
|
background_mode = 1
|
||||||
|
background_color = Color(0.45, 0.62, 0.82, 1)
|
||||||
|
ambient_light_source = 2
|
||||||
|
ambient_light_color = Color(0.7, 0.75, 0.82, 1)
|
||||||
|
ambient_light_energy = 0.8
|
||||||
|
|
||||||
|
[node name="TestLevel" type="Node3D"]
|
||||||
|
|
||||||
|
[node name="Ground" type="StaticBody3D" parent="."]
|
||||||
|
position = Vector3(0, -0.5, 0)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||||
|
shape = SubResource("BoxShape3D_ground")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||||
|
mesh = SubResource("BoxMesh_ground")
|
||||||
|
|
||||||
|
[node name="Props" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Prop1_Box" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(8, 1.5, 8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop1_Box"]
|
||||||
|
shape = SubResource("BoxShape3D_prop1")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop1_Box"]
|
||||||
|
mesh = SubResource("BoxMesh_prop1")
|
||||||
|
|
||||||
|
[node name="Prop2_Pedestal" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-8, 0.5, 6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
shape = SubResource("BoxShape3D_prop2")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
mesh = SubResource("BoxMesh_prop2")
|
||||||
|
|
||||||
|
[node name="Prop3_Column" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(6, 2.0, -8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop3_Column"]
|
||||||
|
shape = SubResource("CylinderShape3D_prop3")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop3_Column"]
|
||||||
|
mesh = SubResource("CylinderMesh_prop3")
|
||||||
|
|
||||||
|
[node name="Prop4_Sphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-6, 1.0, -6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop4_Sphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop4")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop4_Sphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop4")
|
||||||
|
|
||||||
|
[node name="Prop5_SmallSphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(0, 0.5, 12)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop5")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop5")
|
||||||
|
|
||||||
|
[node name="ScriptedChest" type="StaticBody3D" parent="."]
|
||||||
|
position = Vector3(0, 1.0, 1.5)
|
||||||
|
script = ExtResource("1_scripted_chest")
|
||||||
|
behavior_path = "res://assets/scripting/behaviors/demo_chest.json"
|
||||||
|
interact_radius = 2.5
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="ScriptedChest"]
|
||||||
|
shape = SubResource("BoxShape3D_chest")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="ScriptedChest"]
|
||||||
|
mesh = SubResource("BoxMesh_chest")
|
||||||
|
|
||||||
|
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||||
|
rotation = Vector3(-0.95, -0.5, 0)
|
||||||
|
light_energy = 1.3
|
||||||
|
shadow_enabled = true
|
||||||
|
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
|
environment = SubResource("WorldEnvironment_env")
|
||||||
|
|
||||||
|
[node name="PlayerSpawn" type="Marker3D" parent="."]
|
||||||
|
position = Vector3(0, 1.5, 0)
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
extends Node
|
||||||
|
## Central HTTP client singleton (autoload name: "ApiClient").
|
||||||
|
##
|
||||||
|
## Every network call in the game flows through this class. Callers use the
|
||||||
|
## high-level helpers ([method login], [method register], [method get_worlds],
|
||||||
|
## [method health_check], [method get_me]) or the generic [method request]
|
||||||
|
## primitive. All results conform to the result-dictionary contract below.
|
||||||
|
##
|
||||||
|
## [b]Result Dictionary Contract[/b]
|
||||||
|
## Every public method returns (after `await`) a Dictionary with this shape:
|
||||||
|
##
|
||||||
|
## {
|
||||||
|
## "ok": bool, # true for HTTP 2xx, false otherwise
|
||||||
|
## "status": int, # HTTP status code (0 on network failure)
|
||||||
|
## "data": Variant, # parsed JSON body on success (null otherwise)
|
||||||
|
## "detail": String, # human-readable message
|
||||||
|
## }
|
||||||
|
##
|
||||||
|
## Network failures (timeout / DNS / refused connection) return `status == 0`
|
||||||
|
## with a `detail` describing the transport failure. Non-2xx responses return
|
||||||
|
## the real HTTP status and the backend's `detail` field from the JSON error
|
||||||
|
## body when present. No caller can crash on a failed request.
|
||||||
|
##
|
||||||
|
## [b]Token storage[/b]
|
||||||
|
## JWTs are kept in memory. [method set_token] is the single seam for future
|
||||||
|
## disk persistence (override it to save to `user://`).
|
||||||
|
##
|
||||||
|
## [b]Usage[/b]
|
||||||
|
## var result: Dictionary = await ApiClient.login("alice", "secret")
|
||||||
|
## if result.ok:
|
||||||
|
## print("Logged in!")
|
||||||
|
## else:
|
||||||
|
## print("Error: ", result.detail)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token storage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _access_token: String = ""
|
||||||
|
var _token_type: String = "Bearer"
|
||||||
|
|
||||||
|
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
|
||||||
|
var bearer_token: String:
|
||||||
|
get:
|
||||||
|
return _token_type + " " + _access_token if _access_token != "" else ""
|
||||||
|
|
||||||
|
|
||||||
|
## Whether an access token is currently stored.
|
||||||
|
func has_token() -> bool:
|
||||||
|
return _access_token != ""
|
||||||
|
|
||||||
|
|
||||||
|
## Set the access token (called after login / register / token refresh).
|
||||||
|
## This is the persistence seam: override to also write to disk.
|
||||||
|
func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
||||||
|
_access_token = access_token
|
||||||
|
_token_type = token_type
|
||||||
|
|
||||||
|
|
||||||
|
## Clear stored tokens.
|
||||||
|
func clear_token() -> void:
|
||||||
|
_access_token = ""
|
||||||
|
_token_type = "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _base_url: String = "http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
## Request timeout in seconds. 0 disables the timeout.
|
||||||
|
var timeout_seconds: float = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Environment variable override (e.g. for headless smoke tests against a
|
||||||
|
# backend on a non-default port). Falls back to the ProjectSetting, then
|
||||||
|
# to the documented default.
|
||||||
|
var env_url := OS.get_environment("AGP_API_BASE_URL")
|
||||||
|
if env_url != "":
|
||||||
|
_base_url = env_url.rstrip("/")
|
||||||
|
else:
|
||||||
|
_base_url = str(ProjectSettings.get_setting(
|
||||||
|
"application/agp/api_base_url",
|
||||||
|
"http://127.0.0.1:8000"
|
||||||
|
)).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core request primitive
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Generic request primitive. Awaitable via `await`.
|
||||||
|
##
|
||||||
|
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
|
||||||
|
## cross-talk, and so the result maps 1:1 to the awaiting caller.
|
||||||
|
##
|
||||||
|
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
|
||||||
|
## @param path URL path, e.g. "/api/health".
|
||||||
|
## @param body Request body string (JSON or form-encoded).
|
||||||
|
## @param content_type "application/json" or "application/x-www-form-urlencoded".
|
||||||
|
## @param require_auth If true, attach the stored bearer token when present.
|
||||||
|
## @return Result dictionary conforming to the contract.
|
||||||
|
func request(
|
||||||
|
method: HTTPClient.Method,
|
||||||
|
path: String,
|
||||||
|
body: String = "",
|
||||||
|
content_type: String = "application/json",
|
||||||
|
require_auth: bool = false
|
||||||
|
) -> Dictionary:
|
||||||
|
var http := HTTPRequest.new()
|
||||||
|
http.timeout = timeout_seconds
|
||||||
|
http.max_redirects = 8
|
||||||
|
http.accept_gzip = true
|
||||||
|
add_child(http)
|
||||||
|
|
||||||
|
var headers := PackedStringArray()
|
||||||
|
headers.append("Accept: application/json")
|
||||||
|
if content_type != "":
|
||||||
|
headers.append("Content-Type: " + content_type)
|
||||||
|
if require_auth and has_token():
|
||||||
|
headers.append("Authorization: " + bearer_token)
|
||||||
|
|
||||||
|
var url := _base_url + path
|
||||||
|
var err := http.request(url, headers, method, body)
|
||||||
|
if err != OK:
|
||||||
|
http.queue_free()
|
||||||
|
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
|
||||||
|
|
||||||
|
# request_completed emits (result, response_code, headers, body).
|
||||||
|
var args: Array = await http.request_completed
|
||||||
|
var result_code: int = args[0]
|
||||||
|
var response_code: int = args[1]
|
||||||
|
var response_body: PackedByteArray = args[3]
|
||||||
|
http.queue_free()
|
||||||
|
|
||||||
|
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
|
||||||
|
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
return _make_result(false, 0, null, _transport_error_detail(result_code))
|
||||||
|
|
||||||
|
# Server accepted the connection but returned no HTTP status line.
|
||||||
|
if response_code == 0:
|
||||||
|
return _make_result(false, 0, null, "No response received from server.")
|
||||||
|
|
||||||
|
var json_string := response_body.get_string_from_utf8()
|
||||||
|
var ok := response_code >= 200 and response_code < 300
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
var data: Variant = _parse_json(json_string)
|
||||||
|
return _make_result(true, response_code, data, "OK")
|
||||||
|
|
||||||
|
# Non-2xx: surface the backend's "detail" message when available.
|
||||||
|
var detail := "HTTP error %d" % response_code
|
||||||
|
var parsed: Variant = _parse_json(json_string)
|
||||||
|
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
||||||
|
detail = str((parsed as Dictionary)["detail"])
|
||||||
|
return _make_result(false, response_code, null, detail)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# High-level helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Log in with username/password (OAuth2PasswordRequestForm).
|
||||||
|
## On success stores the returned access token.
|
||||||
|
func login(username: String, password: String) -> Dictionary:
|
||||||
|
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
|
||||||
|
var result := await request(
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
"/api/auth/login",
|
||||||
|
form,
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
_store_token_from_result(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Register a new user. On success stores the returned access token.
|
||||||
|
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
|
||||||
|
## and rejects under-18s (403).
|
||||||
|
func register(
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
date_of_birth: String
|
||||||
|
) -> Dictionary:
|
||||||
|
var payload := {
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"date_of_birth": date_of_birth,
|
||||||
|
}
|
||||||
|
var result := await request(
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
"/api/auth/register",
|
||||||
|
JSON.stringify(payload),
|
||||||
|
"application/json",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
_store_token_from_result(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## List worlds visible to the current user (requires auth).
|
||||||
|
## @param visibility Optional filter: "public", "private", "friends_only".
|
||||||
|
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
||||||
|
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
|
||||||
|
if visibility != "":
|
||||||
|
path += "&visibility=" + visibility.uri_encode()
|
||||||
|
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Ping the backend health endpoint (no auth).
|
||||||
|
func health_check() -> Dictionary:
|
||||||
|
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
||||||
|
|
||||||
|
|
||||||
|
## Fetch the current user's profile from the stored JWT (requires auth).
|
||||||
|
func get_me() -> Dictionary:
|
||||||
|
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Fetch the current user's saved avatar loadout (requires auth).
|
||||||
|
## On success, result.data is {"data": <loadout dict>, "is_default": bool} --
|
||||||
|
## `is_default` true means the user has never customised and this is the
|
||||||
|
## server default.
|
||||||
|
func get_avatar_loadout() -> Dictionary:
|
||||||
|
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Ask the backend for a dedicated server to host [param world_id] and return
|
||||||
|
## its address. On success result.data is {"world_id", "host", "port"}.
|
||||||
|
## 503 if this backend has no game-server orchestration.
|
||||||
|
func join_world(world_id: int) -> Dictionary:
|
||||||
|
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Save (replace) the current user's avatar loadout (requires auth).
|
||||||
|
## @param loadout A dict in the AvatarLoadout.to_json() shape
|
||||||
|
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
|
||||||
|
func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
|
||||||
|
var body := JSON.stringify({"data": loadout})
|
||||||
|
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _store_token_from_result(result: Dictionary) -> void:
|
||||||
|
if result.ok and result.data is Dictionary:
|
||||||
|
var token: String = str((result.data as Dictionary).get("access_token", ""))
|
||||||
|
if token != "":
|
||||||
|
set_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
func _parse_json(text: String) -> Variant:
|
||||||
|
if text == "":
|
||||||
|
return null
|
||||||
|
var parsed: Variant = JSON.parse_string(text)
|
||||||
|
if parsed == null:
|
||||||
|
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
|
||||||
|
# the raw string so callers never lose the body entirely.
|
||||||
|
return text if text != "null" else null
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"status": status,
|
||||||
|
"data": data,
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _transport_error_detail(result_code: int) -> String:
|
||||||
|
match result_code:
|
||||||
|
HTTPRequest.RESULT_CANT_CONNECT:
|
||||||
|
return "Could not connect to server. Is it running?"
|
||||||
|
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||||
|
return "Could not resolve server hostname."
|
||||||
|
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||||
|
return "Connection error."
|
||||||
|
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||||
|
return "TLS handshake failed."
|
||||||
|
HTTPRequest.RESULT_NO_RESPONSE:
|
||||||
|
return "No response from server."
|
||||||
|
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
||||||
|
return "Response body too large."
|
||||||
|
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
||||||
|
return "Failed to decompress response body."
|
||||||
|
HTTPRequest.RESULT_REQUEST_FAILED:
|
||||||
|
return "Request failed."
|
||||||
|
HTTPRequest.RESULT_TIMEOUT:
|
||||||
|
return "Request timed out."
|
||||||
|
_:
|
||||||
|
return "Network error (code %d)." % result_code
|
||||||
|
|
||||||
|
|
||||||
|
func _error_string(err_code: int) -> String:
|
||||||
|
match err_code:
|
||||||
|
OK:
|
||||||
|
return "OK"
|
||||||
|
ERR_UNCONFIGURED:
|
||||||
|
return "HTTPRequest not configured (not in tree)."
|
||||||
|
ERR_BUSY:
|
||||||
|
return "HTTPRequest is busy with a previous request."
|
||||||
|
ERR_INVALID_PARAMETER:
|
||||||
|
return "Invalid URL format."
|
||||||
|
ERR_CANT_CONNECT:
|
||||||
|
return "Cannot connect to host."
|
||||||
|
_:
|
||||||
|
return "Unknown error (%d)." % err_code
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cjd0uhll7iylj
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
## AvatarCatalog - Asset catalog management.
|
||||||
|
##
|
||||||
|
## Manages the mapping of asset IDs to their metadata and resource paths.
|
||||||
|
## Loads the catalog manifest from disk and provides lookup by ID and slot.
|
||||||
|
##
|
||||||
|
## The catalog is loaded from `res://assets/avatars/catalog.json` which follows
|
||||||
|
## the format specified in avatar.md §7.2.
|
||||||
|
|
||||||
|
class_name AvatarCatalog
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
|
|
||||||
|
|
||||||
|
## Emitted when the catalog is loaded successfully.
|
||||||
|
signal catalog_loaded
|
||||||
|
|
||||||
|
## Emitted when the catalog fails to load.
|
||||||
|
signal catalog_load_failed(error_message: String)
|
||||||
|
|
||||||
|
## Catalog manifest structure:
|
||||||
|
## { "assets": { "id": { "type", "slot", "path", "version", ... } } }
|
||||||
|
var _catalog: Dictionary = {}
|
||||||
|
var _loaded: bool = false
|
||||||
|
var _catalog_path: String = "res://assets/avatars/catalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Load the catalog from disk. Call this before any lookups.
|
||||||
|
func load_catalog(path: String = _catalog_path) -> Error:
|
||||||
|
_catalog_path = path
|
||||||
|
_catalog = {}
|
||||||
|
|
||||||
|
var file = FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
var err = FileAccess.get_open_error()
|
||||||
|
catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err])
|
||||||
|
return err
|
||||||
|
|
||||||
|
var json_text: String = file.get_as_text()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
var result: Variant = JSON.parse_string(json_text)
|
||||||
|
if result == null or not result is Dictionary:
|
||||||
|
catalog_load_failed.emit("Invalid JSON in catalog: %s" % path)
|
||||||
|
return ERR_PARSE_ERROR
|
||||||
|
|
||||||
|
var json: Dictionary = result as Dictionary
|
||||||
|
|
||||||
|
if not json.has("assets"):
|
||||||
|
catalog_load_failed.emit("Catalog missing 'assets' key")
|
||||||
|
return ERR_INVALID_DATA
|
||||||
|
|
||||||
|
_catalog = json["assets"]
|
||||||
|
_loaded = true
|
||||||
|
catalog_loaded.emit()
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
## Check if the catalog has been loaded.
|
||||||
|
func is_loaded() -> bool:
|
||||||
|
return _loaded
|
||||||
|
|
||||||
|
|
||||||
|
## Resolve an asset by its ID.
|
||||||
|
## Returns a Dictionary with keys: type, slot, path, version, etc.
|
||||||
|
## Returns null if the asset ID is not found.
|
||||||
|
func resolve(id: String) -> Dictionary:
|
||||||
|
if not _loaded:
|
||||||
|
return {}
|
||||||
|
return _catalog.get(id, {})
|
||||||
|
|
||||||
|
|
||||||
|
## Check if an asset ID exists in the catalog.
|
||||||
|
func has_asset(id: String) -> bool:
|
||||||
|
if not _loaded:
|
||||||
|
return false
|
||||||
|
return _catalog.has(id)
|
||||||
|
|
||||||
|
|
||||||
|
## Get all assets that belong to a given slot.
|
||||||
|
## Returns an array of asset ID strings.
|
||||||
|
func get_assets_by_slot(slot: String) -> Array[String]:
|
||||||
|
if not _loaded:
|
||||||
|
return []
|
||||||
|
var result: Array[String] = []
|
||||||
|
for id in _catalog:
|
||||||
|
var asset: Dictionary = _catalog[id]
|
||||||
|
if asset.get("slot") == slot:
|
||||||
|
result.append(id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Get all asset IDs in the catalog.
|
||||||
|
func get_all_ids() -> Array[String]:
|
||||||
|
if not _loaded:
|
||||||
|
return []
|
||||||
|
return _catalog.keys()
|
||||||
|
|
||||||
|
|
||||||
|
## Validate a loadout against the catalog.
|
||||||
|
## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]).
|
||||||
|
func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
|
||||||
|
var errors: Array[String] = []
|
||||||
|
|
||||||
|
# Check base_body
|
||||||
|
if loadout.base_body.is_empty():
|
||||||
|
errors.append("Loadout missing base_body")
|
||||||
|
elif not has_asset(loadout.base_body):
|
||||||
|
errors.append("Unknown base_body asset: %s" % loadout.base_body)
|
||||||
|
else:
|
||||||
|
var base: Dictionary = resolve(loadout.base_body)
|
||||||
|
if base.get("type") != "base_body":
|
||||||
|
errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body)
|
||||||
|
|
||||||
|
# Check each slot
|
||||||
|
for slot_id in loadout.slots:
|
||||||
|
var slot_value = loadout.slots[slot_id]
|
||||||
|
|
||||||
|
if slot_value == null:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if slot_id == "accessory":
|
||||||
|
# Accessory is an array
|
||||||
|
if not slot_value is Array:
|
||||||
|
errors.append("Slot '%s' must be an array" % slot_id)
|
||||||
|
continue
|
||||||
|
for item_id in slot_value:
|
||||||
|
if not has_asset(item_id):
|
||||||
|
errors.append("Unknown accessory asset: %s" % item_id)
|
||||||
|
else:
|
||||||
|
var asset: Dictionary = resolve(item_id)
|
||||||
|
if asset.get("slot") != slot_id:
|
||||||
|
errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [
|
||||||
|
item_id, asset.get("slot"), slot_id
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
# Exclusive slot - single value
|
||||||
|
if not slot_value is String:
|
||||||
|
errors.append("Slot '%s' must be a string or null" % slot_id)
|
||||||
|
continue
|
||||||
|
if not has_asset(slot_value):
|
||||||
|
errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value])
|
||||||
|
else:
|
||||||
|
var asset: Dictionary = resolve(slot_value)
|
||||||
|
if asset.get("slot") != slot_id:
|
||||||
|
errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [
|
||||||
|
slot_value, asset.get("slot"), slot_id
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": errors.is_empty(),
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Compute a hash of the loadout for change detection.
|
||||||
|
## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int
|
||||||
|
## is 64-bit signed, the value is just constrained to 32 bits of range).
|
||||||
|
static func compute_loadout_hash(loadout: AvatarLoadout) -> int:
|
||||||
|
var hasher = HashingContext.new()
|
||||||
|
hasher.start(HashingContext.HASH_SHA256)
|
||||||
|
|
||||||
|
hasher.update(loadout.base_body.to_utf8_buffer())
|
||||||
|
for slot_id in loadout.slots:
|
||||||
|
var slot_value = loadout.slots[slot_id]
|
||||||
|
hasher.update(slot_id.to_utf8_buffer())
|
||||||
|
if slot_value is Array:
|
||||||
|
for item in slot_value:
|
||||||
|
hasher.update(item.to_utf8_buffer())
|
||||||
|
elif slot_value is String:
|
||||||
|
hasher.update(slot_value.to_utf8_buffer())
|
||||||
|
|
||||||
|
var hash_bytes = hasher.finish()
|
||||||
|
return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24)
|
||||||
|
|
||||||
|
|
||||||
|
## Get the catalog path.
|
||||||
|
func get_catalog_path() -> String:
|
||||||
|
return _catalog_path
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ceyugve7rjxqs
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
## AvatarCustomizationUI
|
||||||
|
##
|
||||||
|
## The in-game avatar customization screen. Lets the player:
|
||||||
|
## - See a live preview of their avatar.
|
||||||
|
## - Browse and equip/unequip items per slot.
|
||||||
|
## - Adjust skin tone.
|
||||||
|
## - Save / load loadouts.
|
||||||
|
##
|
||||||
|
## The UI is built programmatically so it works without a pre-authored .tscn.
|
||||||
|
## It is a Control node that can be added to any scene.
|
||||||
|
|
||||||
|
class_name AvatarCustomizationUI
|
||||||
|
extends Control
|
||||||
|
|
||||||
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
||||||
|
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
|
||||||
|
const AvatarVisualizer := preload("res://scripts/avatar/avatar_visualizer.gd")
|
||||||
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Emitted when the player closes the customization screen.
|
||||||
|
signal closed
|
||||||
|
|
||||||
|
## Emitted when the loadout changes (after equip/unequip/skin-tone).
|
||||||
|
signal loadout_changed(loadout: AvatarLoadout)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exports / config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Path to the asset catalog manifest.
|
||||||
|
@export var catalog_path: String = "res://assets/avatars/catalog.json"
|
||||||
|
|
||||||
|
## The default base body to use when none is equipped.
|
||||||
|
@export var default_base_body: String = "base_human_male_01"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The current loadout being edited.
|
||||||
|
var loadout: AvatarLoadout = null
|
||||||
|
|
||||||
|
## The AvatarSystem singleton (set in _ready).
|
||||||
|
var _system: Node = null
|
||||||
|
|
||||||
|
## The catalog (owned by AvatarSystem).
|
||||||
|
var _catalog: AvatarCatalog = null
|
||||||
|
|
||||||
|
## The loader (owned by AvatarSystem).
|
||||||
|
var _loader: AvatarLoader = null
|
||||||
|
|
||||||
|
## The visualizer that renders the preview.
|
||||||
|
var _visualizer: AvatarVisualizer = null
|
||||||
|
|
||||||
|
## The preview viewport container.
|
||||||
|
var _preview_container: SubViewportContainer = null
|
||||||
|
|
||||||
|
## The preview viewport.
|
||||||
|
var _preview_viewport: SubViewport = null
|
||||||
|
|
||||||
|
## The preview camera.
|
||||||
|
var _preview_camera: Camera3D = null
|
||||||
|
|
||||||
|
## The preview avatar root.
|
||||||
|
var _preview_root: Node3D = null
|
||||||
|
|
||||||
|
## Currently selected slot id.
|
||||||
|
var _selected_slot: String = ""
|
||||||
|
|
||||||
|
## Skin: a base colour picked from a swatch, plus a shade multiplier from the
|
||||||
|
## slider (0.5 = the swatch as-is, lower = darker, higher = lighter). The
|
||||||
|
## first swatch is the placeholder default yellow.
|
||||||
|
const SKIN_SWATCHES: Array[Color] = [
|
||||||
|
Color(0.96, 0.80, 0.19), # yellow (default)
|
||||||
|
Color(0.95, 0.78, 0.66), # pale
|
||||||
|
Color(0.83, 0.62, 0.46), # tan
|
||||||
|
Color(0.55, 0.38, 0.27), # brown
|
||||||
|
Color(0.33, 0.21, 0.15), # deep brown
|
||||||
|
Color(0.62, 0.62, 0.64), # grey
|
||||||
|
Color(0.78, 0.24, 0.24), # red
|
||||||
|
Color(0.30, 0.62, 0.34), # green
|
||||||
|
Color(0.26, 0.46, 0.76), # blue
|
||||||
|
Color(0.62, 0.36, 0.72), # purple
|
||||||
|
]
|
||||||
|
var _skin_base: Color = SKIN_SWATCHES[0]
|
||||||
|
|
||||||
|
## Turntable: the preview avatar's Y rotation (radians), whether the player is
|
||||||
|
## currently dragging it, and how long since they last touched it (drives the
|
||||||
|
## slow idle auto-spin). Starts facing the camera.
|
||||||
|
var _turntable_yaw: float = PI
|
||||||
|
var _turntable_dragging: bool = false
|
||||||
|
var _turntable_idle: float = 0.0
|
||||||
|
const _TURNTABLE_IDLE_SPIN_DELAY := 2.5
|
||||||
|
const _TURNTABLE_IDLE_SPIN_SPEED := 0.5
|
||||||
|
const _TURNTABLE_DRAG_SPEED := 0.01
|
||||||
|
|
||||||
|
## UI node references.
|
||||||
|
var _slot_list: VBoxContainer = null
|
||||||
|
var _item_list: VBoxContainer = null
|
||||||
|
var _slot_label: Label = null
|
||||||
|
var _item_label: Label = null
|
||||||
|
var _skin_tone_slider: HSlider = null
|
||||||
|
var _status_label: Label = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Grab the AvatarSystem autoload.
|
||||||
|
_system = get_node_or_null("/root/AvatarSystem")
|
||||||
|
if _system == null:
|
||||||
|
push_error("AvatarCustomizationUI: AvatarSystem autoload not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
_catalog = _system.catalog
|
||||||
|
_loader = _system.loader
|
||||||
|
|
||||||
|
# Load the catalog if not already loaded.
|
||||||
|
if _catalog != null and not _catalog.is_loaded():
|
||||||
|
_catalog.load_catalog(catalog_path)
|
||||||
|
|
||||||
|
# Build the UI.
|
||||||
|
_build_ui()
|
||||||
|
|
||||||
|
# Seed from the player's live loadout (set at login) if there is one, so
|
||||||
|
# the screen opens showing what they're actually wearing; otherwise start
|
||||||
|
# from a bare default.
|
||||||
|
if _system != null and _system.current_loadout != null:
|
||||||
|
loadout = AvatarLoadout.from_json(_system.current_loadout.to_json())
|
||||||
|
if loadout == null or not loadout.validate():
|
||||||
|
loadout = AvatarLoadout.new()
|
||||||
|
loadout.base_body = default_base_body
|
||||||
|
|
||||||
|
# Initialize the visualizer.
|
||||||
|
_visualizer = AvatarVisualizer.new()
|
||||||
|
|
||||||
|
# Populate the slot list.
|
||||||
|
_populate_slots()
|
||||||
|
|
||||||
|
# Select the first slot.
|
||||||
|
if AvatarSlotRegistry.SLOT_IDS.size() > 0:
|
||||||
|
_select_slot(AvatarSlotRegistry.SLOT_IDS[0])
|
||||||
|
|
||||||
|
# Refresh the preview.
|
||||||
|
_refresh_preview()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UI construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Build the entire UI tree programmatically.
|
||||||
|
func _build_ui() -> void:
|
||||||
|
# Root layout: horizontal split (preview | controls).
|
||||||
|
var root_hbox := HBoxContainer.new()
|
||||||
|
root_hbox.name = "RootLayout"
|
||||||
|
root_hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
root_hbox.add_theme_constant_override("separation", 16)
|
||||||
|
add_child(root_hbox)
|
||||||
|
|
||||||
|
# --- Left: preview (takes all the leftover width) ---
|
||||||
|
var preview_panel := PanelContainer.new()
|
||||||
|
preview_panel.name = "PreviewPanel"
|
||||||
|
preview_panel.custom_minimum_size = Vector2(420, 0)
|
||||||
|
preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
root_hbox.add_child(preview_panel)
|
||||||
|
|
||||||
|
var preview_margin := MarginContainer.new()
|
||||||
|
preview_margin.add_theme_constant_override("margin_left", 8)
|
||||||
|
preview_margin.add_theme_constant_override("margin_right", 8)
|
||||||
|
preview_margin.add_theme_constant_override("margin_top", 8)
|
||||||
|
preview_margin.add_theme_constant_override("margin_bottom", 8)
|
||||||
|
preview_panel.add_child(preview_margin)
|
||||||
|
|
||||||
|
var preview_vbox := VBoxContainer.new()
|
||||||
|
preview_vbox.add_theme_constant_override("separation", 6)
|
||||||
|
preview_margin.add_child(preview_vbox)
|
||||||
|
|
||||||
|
_preview_container = SubViewportContainer.new()
|
||||||
|
_preview_container.name = "PreviewViewportContainer"
|
||||||
|
_preview_container.stretch = true
|
||||||
|
_preview_container.custom_minimum_size = Vector2(400, 520)
|
||||||
|
_preview_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_preview_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
_preview_container.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
_preview_container.gui_input.connect(_on_preview_gui_input)
|
||||||
|
preview_vbox.add_child(_preview_container)
|
||||||
|
|
||||||
|
var rotate_hint := Label.new()
|
||||||
|
rotate_hint.text = "drag to rotate"
|
||||||
|
rotate_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
rotate_hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.5))
|
||||||
|
rotate_hint.add_theme_font_size_override("font_size", 12)
|
||||||
|
preview_vbox.add_child(rotate_hint)
|
||||||
|
|
||||||
|
_preview_viewport = SubViewport.new()
|
||||||
|
_preview_viewport.name = "PreviewViewport"
|
||||||
|
_preview_viewport.size = Vector2i(400, 520)
|
||||||
|
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||||
|
_preview_viewport.transparent_bg = true
|
||||||
|
# Its own 3D world -- a SubViewport that shares the (Control-only) main
|
||||||
|
# world has no scenario/environment set up and renders nothing.
|
||||||
|
_preview_viewport.own_world_3d = true
|
||||||
|
_preview_container.add_child(_preview_viewport)
|
||||||
|
|
||||||
|
# Environment: ambient fill so the model reads even from a bad light angle.
|
||||||
|
var world_env := WorldEnvironment.new()
|
||||||
|
world_env.name = "PreviewEnv"
|
||||||
|
var env := Environment.new()
|
||||||
|
env.background_mode = Environment.BG_COLOR
|
||||||
|
env.background_color = Color(0.10, 0.11, 0.14)
|
||||||
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
env.ambient_light_color = Color(0.62, 0.64, 0.7)
|
||||||
|
env.ambient_light_energy = 0.7
|
||||||
|
world_env.environment = env
|
||||||
|
_preview_viewport.add_child(world_env)
|
||||||
|
|
||||||
|
# Camera for the preview -- framed to fit the full ~2 m avatar.
|
||||||
|
_preview_camera = Camera3D.new()
|
||||||
|
_preview_camera.name = "PreviewCamera"
|
||||||
|
# look_at needs a global transform, which only exists once in the tree.
|
||||||
|
_preview_camera.look_at_from_position(
|
||||||
|
Vector3(0, 1.05, 3.5), Vector3(0, 0.98, 0), Vector3.UP)
|
||||||
|
_preview_camera.current = true
|
||||||
|
_preview_viewport.add_child(_preview_camera)
|
||||||
|
|
||||||
|
# Avatar root inside the viewport.
|
||||||
|
_preview_root = Node3D.new()
|
||||||
|
_preview_root.name = "AvatarPreviewRoot"
|
||||||
|
_preview_viewport.add_child(_preview_root)
|
||||||
|
|
||||||
|
# Key + fill lights so the avatar is clearly visible.
|
||||||
|
var key := DirectionalLight3D.new()
|
||||||
|
key.name = "PreviewKeyLight"
|
||||||
|
key.rotation_degrees = Vector3(-35, 35, 0)
|
||||||
|
key.light_energy = 1.0
|
||||||
|
_preview_viewport.add_child(key)
|
||||||
|
var fill := DirectionalLight3D.new()
|
||||||
|
fill.name = "PreviewFillLight"
|
||||||
|
fill.rotation_degrees = Vector3(-20, -140, 0)
|
||||||
|
fill.light_energy = 0.35
|
||||||
|
_preview_viewport.add_child(fill)
|
||||||
|
|
||||||
|
# --- Right: controls ---
|
||||||
|
var controls_panel := PanelContainer.new()
|
||||||
|
controls_panel.name = "ControlsPanel"
|
||||||
|
controls_panel.custom_minimum_size = Vector2(520, 0)
|
||||||
|
root_hbox.add_child(controls_panel)
|
||||||
|
|
||||||
|
var controls_margin := MarginContainer.new()
|
||||||
|
controls_margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
controls_margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
controls_margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
controls_margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
controls_panel.add_child(controls_margin)
|
||||||
|
|
||||||
|
var controls_vbox := VBoxContainer.new()
|
||||||
|
controls_vbox.name = "Controls"
|
||||||
|
controls_vbox.add_theme_constant_override("separation", 8)
|
||||||
|
controls_margin.add_child(controls_vbox)
|
||||||
|
|
||||||
|
# Title.
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "Avatar Customization"
|
||||||
|
title.add_theme_font_size_override("font_size", 24)
|
||||||
|
controls_vbox.add_child(title)
|
||||||
|
|
||||||
|
# Slot selector.
|
||||||
|
var slot_header := Label.new()
|
||||||
|
slot_header.text = "Slots"
|
||||||
|
slot_header.add_theme_font_size_override("font_size", 16)
|
||||||
|
controls_vbox.add_child(slot_header)
|
||||||
|
|
||||||
|
var slot_scroll := ScrollContainer.new()
|
||||||
|
slot_scroll.custom_minimum_size = Vector2(0, 160)
|
||||||
|
controls_vbox.add_child(slot_scroll)
|
||||||
|
|
||||||
|
_slot_list = VBoxContainer.new()
|
||||||
|
_slot_list.name = "SlotList"
|
||||||
|
_slot_list.add_theme_constant_override("separation", 4)
|
||||||
|
slot_scroll.add_child(_slot_list)
|
||||||
|
|
||||||
|
# Item selector for the selected slot.
|
||||||
|
var item_header := Label.new()
|
||||||
|
item_header.text = "Items"
|
||||||
|
item_header.add_theme_font_size_override("font_size", 16)
|
||||||
|
controls_vbox.add_child(item_header)
|
||||||
|
|
||||||
|
var item_scroll := ScrollContainer.new()
|
||||||
|
item_scroll.custom_minimum_size = Vector2(0, 200)
|
||||||
|
controls_vbox.add_child(item_scroll)
|
||||||
|
|
||||||
|
_item_list = VBoxContainer.new()
|
||||||
|
_item_list.name = "ItemList"
|
||||||
|
_item_list.add_theme_constant_override("separation", 4)
|
||||||
|
item_scroll.add_child(_item_list)
|
||||||
|
|
||||||
|
# Skin colour: a row of preset swatches + a shade slider that darkens or
|
||||||
|
# lightens the picked colour. The first swatch is the default yellow.
|
||||||
|
var skin_header := Label.new()
|
||||||
|
skin_header.text = "Skin Colour"
|
||||||
|
skin_header.add_theme_font_size_override("font_size", 16)
|
||||||
|
controls_vbox.add_child(skin_header)
|
||||||
|
|
||||||
|
var swatch_grid := GridContainer.new()
|
||||||
|
swatch_grid.columns = 5
|
||||||
|
swatch_grid.add_theme_constant_override("h_separation", 4)
|
||||||
|
swatch_grid.add_theme_constant_override("v_separation", 4)
|
||||||
|
controls_vbox.add_child(swatch_grid)
|
||||||
|
for c in SKIN_SWATCHES:
|
||||||
|
swatch_grid.add_child(_make_swatch(c))
|
||||||
|
|
||||||
|
var shade_label := Label.new()
|
||||||
|
shade_label.text = "Shade"
|
||||||
|
shade_label.add_theme_font_size_override("font_size", 13)
|
||||||
|
controls_vbox.add_child(shade_label)
|
||||||
|
|
||||||
|
_skin_tone_slider = HSlider.new()
|
||||||
|
_skin_tone_slider.name = "ShadeSlider"
|
||||||
|
_skin_tone_slider.min_value = 0.0
|
||||||
|
_skin_tone_slider.max_value = 1.0
|
||||||
|
_skin_tone_slider.step = 0.01
|
||||||
|
_skin_tone_slider.value = 0.5
|
||||||
|
_skin_tone_slider.custom_minimum_size = Vector2(0, 24)
|
||||||
|
_skin_tone_slider.value_changed.connect(_on_skin_tone_changed)
|
||||||
|
controls_vbox.add_child(_skin_tone_slider)
|
||||||
|
|
||||||
|
# Status / error label.
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.name = "StatusLabel"
|
||||||
|
_status_label.text = ""
|
||||||
|
_status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4))
|
||||||
|
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
controls_vbox.add_child(_status_label)
|
||||||
|
|
||||||
|
# Action buttons.
|
||||||
|
var button_row := HBoxContainer.new()
|
||||||
|
button_row.add_theme_constant_override("separation", 8)
|
||||||
|
controls_vbox.add_child(button_row)
|
||||||
|
|
||||||
|
var save_btn := Button.new()
|
||||||
|
save_btn.text = "Save Loadout"
|
||||||
|
save_btn.pressed.connect(_on_save_pressed)
|
||||||
|
button_row.add_child(save_btn)
|
||||||
|
|
||||||
|
var load_btn := Button.new()
|
||||||
|
load_btn.text = "Load Loadout"
|
||||||
|
load_btn.pressed.connect(_on_load_pressed)
|
||||||
|
button_row.add_child(load_btn)
|
||||||
|
|
||||||
|
var close_btn := Button.new()
|
||||||
|
close_btn.text = "Close"
|
||||||
|
close_btn.pressed.connect(_on_close_pressed)
|
||||||
|
button_row.add_child(close_btn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slot / item population
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Populate the slot list with buttons for each slot.
|
||||||
|
func _populate_slots() -> void:
|
||||||
|
if _slot_list == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear existing children.
|
||||||
|
for child in _slot_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.name = "Slot_%s" % slot_id
|
||||||
|
btn.text = slot_id
|
||||||
|
btn.toggle_mode = true
|
||||||
|
btn.pressed.connect(_on_slot_button_pressed.bind(slot_id))
|
||||||
|
_slot_list.add_child(btn)
|
||||||
|
|
||||||
|
|
||||||
|
## Populate the item list for the currently selected slot.
|
||||||
|
func _populate_items() -> void:
|
||||||
|
if _item_list == null or _selected_slot.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear existing children.
|
||||||
|
for child in _item_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
if _catalog == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get assets for this slot.
|
||||||
|
var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot)
|
||||||
|
|
||||||
|
# "None" button to unequip.
|
||||||
|
var none_btn := Button.new()
|
||||||
|
none_btn.text = "(none)"
|
||||||
|
none_btn.pressed.connect(_on_equip_pressed.bind(""))
|
||||||
|
_item_list.add_child(none_btn)
|
||||||
|
|
||||||
|
for asset_id in assets:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = asset_id
|
||||||
|
btn.pressed.connect(_on_equip_pressed.bind(asset_id))
|
||||||
|
_item_list.add_child(btn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slot selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Handle a slot button press.
|
||||||
|
func _on_slot_button_pressed(slot_id: String) -> void:
|
||||||
|
_select_slot(slot_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Select a slot and refresh the item list.
|
||||||
|
func _select_slot(slot_id: String) -> void:
|
||||||
|
_selected_slot = slot_id
|
||||||
|
|
||||||
|
# Update button toggle states.
|
||||||
|
if _slot_list != null:
|
||||||
|
for child in _slot_list.get_children():
|
||||||
|
if child is Button:
|
||||||
|
var btn := child as Button
|
||||||
|
btn.button_pressed = (btn.name == "Slot_%s" % slot_id)
|
||||||
|
|
||||||
|
_populate_items()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Equip / unequip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Handle an equip/unequip action for the selected slot.
|
||||||
|
func _on_equip_pressed(asset_id: String) -> void:
|
||||||
|
if loadout == null or _selected_slot.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
if _selected_slot == "accessory":
|
||||||
|
if asset_id.is_empty():
|
||||||
|
# Clear all accessories.
|
||||||
|
loadout.slots["accessory"] = []
|
||||||
|
else:
|
||||||
|
loadout.add_accessory(asset_id)
|
||||||
|
else:
|
||||||
|
loadout.set_equipment(_selected_slot, asset_id)
|
||||||
|
|
||||||
|
_refresh_preview()
|
||||||
|
loadout_changed.emit(loadout)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Turntable (drag to rotate the preview, slow auto-spin when idle)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if _preview_root == null:
|
||||||
|
return
|
||||||
|
if _turntable_dragging:
|
||||||
|
_turntable_idle = 0.0
|
||||||
|
else:
|
||||||
|
_turntable_idle += delta
|
||||||
|
if _turntable_idle > _TURNTABLE_IDLE_SPIN_DELAY:
|
||||||
|
_turntable_yaw += delta * _TURNTABLE_IDLE_SPIN_SPEED
|
||||||
|
_preview_root.rotation.y = _turntable_yaw
|
||||||
|
|
||||||
|
|
||||||
|
func _on_preview_gui_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
_turntable_dragging = event.pressed
|
||||||
|
_turntable_idle = 0.0
|
||||||
|
elif event is InputEventMouseMotion and _turntable_dragging:
|
||||||
|
_turntable_yaw -= event.relative.x * _TURNTABLE_DRAG_SPEED
|
||||||
|
_turntable_idle = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview refresh
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Rebuild the avatar preview from the current loadout.
|
||||||
|
func _refresh_preview() -> void:
|
||||||
|
if _visualizer == null or _preview_root == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear the preview root.
|
||||||
|
for child in _preview_root.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
# Validate the loadout.
|
||||||
|
if loadout == null or loadout.base_body.is_empty():
|
||||||
|
_status_label.text = "No base body selected."
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build the render tree.
|
||||||
|
_visualizer.load_loadout(loadout, _preview_root)
|
||||||
|
|
||||||
|
# Show any validation errors.
|
||||||
|
if not loadout.validation_errors.is_empty():
|
||||||
|
_status_label.text = "\n".join(loadout.validation_errors)
|
||||||
|
else:
|
||||||
|
_status_label.text = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Skin tone
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Build one clickable colour swatch.
|
||||||
|
func _make_swatch(color: Color) -> Button:
|
||||||
|
var b := Button.new()
|
||||||
|
b.custom_minimum_size = Vector2(40, 30)
|
||||||
|
b.tooltip_text = "#" + color.to_html(false)
|
||||||
|
for state in ["normal", "hover", "pressed", "focus", "disabled"]:
|
||||||
|
var sb := StyleBoxFlat.new()
|
||||||
|
sb.bg_color = color
|
||||||
|
sb.set_corner_radius_all(3)
|
||||||
|
if state == "hover" or state == "pressed" or state == "focus":
|
||||||
|
sb.set_border_width_all(2)
|
||||||
|
sb.border_color = Color.WHITE
|
||||||
|
b.add_theme_stylebox_override(state, sb)
|
||||||
|
b.pressed.connect(_on_skin_swatch_pressed.bind(color))
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
func _on_skin_swatch_pressed(color: Color) -> void:
|
||||||
|
_skin_base = color
|
||||||
|
_apply_skin_colour()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_skin_tone_changed(_value: float) -> void:
|
||||||
|
_apply_skin_colour()
|
||||||
|
|
||||||
|
|
||||||
|
## Combine the picked swatch with the shade slider and push it to the loadout.
|
||||||
|
func _apply_skin_colour() -> void:
|
||||||
|
if loadout == null:
|
||||||
|
return
|
||||||
|
var shade: float = _skin_tone_slider.value if _skin_tone_slider != null else 0.5
|
||||||
|
var col := _skin_base
|
||||||
|
if shade < 0.5:
|
||||||
|
col = _skin_base.lerp(Color.BLACK, (0.5 - shade) * 1.7)
|
||||||
|
elif shade > 0.5:
|
||||||
|
col = _skin_base.lerp(Color.WHITE, (shade - 0.5) * 1.3)
|
||||||
|
col.a = 1.0
|
||||||
|
loadout.skin_tone = col
|
||||||
|
_refresh_preview()
|
||||||
|
loadout_changed.emit(loadout)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Save / load
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_save_pressed() -> void:
|
||||||
|
if loadout == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Local cache (offline fallback / fast reopen).
|
||||||
|
var json := loadout.to_json()
|
||||||
|
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
|
||||||
|
if file:
|
||||||
|
file.store_string(json)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
# Apply to the live in-world avatar immediately.
|
||||||
|
if _system != null:
|
||||||
|
_system.set_loadout_from_dict(JSON.parse_string(json))
|
||||||
|
|
||||||
|
# Persist to the backend if we're signed in.
|
||||||
|
var api := get_node_or_null("/root/ApiClient")
|
||||||
|
if api != null and api.has_token():
|
||||||
|
_status_label.text = "Saving…"
|
||||||
|
var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json))
|
||||||
|
_status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error")
|
||||||
|
else:
|
||||||
|
_status_label.text = "Saved locally (not signed in)."
|
||||||
|
|
||||||
|
|
||||||
|
func _on_load_pressed() -> void:
|
||||||
|
if not FileAccess.file_exists("user://loadout.json"):
|
||||||
|
_status_label.text = "No saved loadout found."
|
||||||
|
return
|
||||||
|
|
||||||
|
var file := FileAccess.open("user://loadout.json", FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
_status_label.text = "Failed to open saved loadout."
|
||||||
|
return
|
||||||
|
|
||||||
|
var json := file.get_as_text()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
var loaded := AvatarLoadout.from_json(json)
|
||||||
|
if not loaded.validation_errors.is_empty():
|
||||||
|
_status_label.text = "Saved loadout invalid: %s" % "\n".join(loaded.validation_errors)
|
||||||
|
return
|
||||||
|
|
||||||
|
loadout = loaded
|
||||||
|
_refresh_preview()
|
||||||
|
loadout_changed.emit(loadout)
|
||||||
|
_status_label.text = "Loadout loaded."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Close
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_close_pressed() -> void:
|
||||||
|
closed.emit()
|
||||||
|
queue_free()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://crqtef5rifgrv
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
## AvatarLoader - glTF/GLB import, skeleton merge, and skin re-targeting.
|
||||||
|
##
|
||||||
|
## Loads base body and attachment GLB assets, extracts skeletons and meshes,
|
||||||
|
## and re-targets attachment skins to the canonical skeleton by bone name.
|
||||||
|
##
|
||||||
|
## Per the avatar architecture spec (§6.2, §6.3):
|
||||||
|
## - The base body carries the canonical skeleton.
|
||||||
|
## - Attachments are skinned to the same skeleton by name-based re-targeting.
|
||||||
|
## - Static accessories are parented to their mount bone.
|
||||||
|
## - Body-part slots hide the corresponding base mesh.
|
||||||
|
|
||||||
|
class_name AvatarLoader
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Maximum number of attachments allowed in the accessory slot.
|
||||||
|
const MAX_ACCESSORY_COUNT := 8
|
||||||
|
|
||||||
|
## The skin albedo baked into the placeholder base body by
|
||||||
|
## gen_placeholder_avatars.gd. apply_skin_tone() finds the mesh surfaces
|
||||||
|
## painted this colour (head + arms) and retints them.
|
||||||
|
const PLACEHOLDER_SKIN_ALBEDO := Color(0.96, 0.80, 0.19)
|
||||||
|
|
||||||
|
## Skeleton bone names that form the canonical rig (§3.1).
|
||||||
|
const CANONICAL_BONES := [
|
||||||
|
"Root",
|
||||||
|
"Hips",
|
||||||
|
"Spine",
|
||||||
|
"Chest",
|
||||||
|
"Neck",
|
||||||
|
"Head",
|
||||||
|
"HeadTop",
|
||||||
|
"Jaw",
|
||||||
|
"LeftEye",
|
||||||
|
"RightEye",
|
||||||
|
"LeftShoulder",
|
||||||
|
"LeftUpperArm",
|
||||||
|
"LeftLowerArm",
|
||||||
|
"LeftHand",
|
||||||
|
"RightShoulder",
|
||||||
|
"RightUpperArm",
|
||||||
|
"RightLowerArm",
|
||||||
|
"RightHand",
|
||||||
|
"LeftUpperLeg",
|
||||||
|
"LeftLowerLeg",
|
||||||
|
"LeftFoot",
|
||||||
|
"RightUpperLeg",
|
||||||
|
"RightLowerLeg",
|
||||||
|
"RightFoot",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Emitted when a base body has been loaded successfully.
|
||||||
|
signal base_body_loaded(avatar_node: Node3D)
|
||||||
|
|
||||||
|
## Emitted when an attachment has been loaded and merged.
|
||||||
|
signal attachment_loaded(slot_id: String, attachment_node: Node3D)
|
||||||
|
|
||||||
|
## Emitted on error during loading.
|
||||||
|
signal load_error(error_msg: String)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The fully assembled avatar node (Skeleton3D + meshes + attachments).
|
||||||
|
var avatar_node: Node3D = null
|
||||||
|
|
||||||
|
## The Skeleton3D belonging to the avatar.
|
||||||
|
var skeleton: Skeleton3D = null
|
||||||
|
|
||||||
|
## Mapping of bone name -> bone index in the skeleton.
|
||||||
|
var _bone_index_map: Dictionary = {}
|
||||||
|
|
||||||
|
## Loaded base body meshes, keyed by slot id.
|
||||||
|
var _base_meshes: Dictionary = {}
|
||||||
|
|
||||||
|
## Loaded attachment nodes, keyed by slot id.
|
||||||
|
var _attachments: Dictionary = {}
|
||||||
|
|
||||||
|
## Whether the base body has been loaded.
|
||||||
|
var is_base_loaded: bool = false
|
||||||
|
|
||||||
|
## The avatar catalog reference (set by AvatarSystem).
|
||||||
|
var catalog: AvatarCatalog = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Initialize the loader with a reference to the avatar catalog.
|
||||||
|
func init_loader(cat: AvatarCatalog) -> void:
|
||||||
|
catalog = cat
|
||||||
|
|
||||||
|
|
||||||
|
## Load the base body asset identified by [param base_body_id].
|
||||||
|
## Returns the AvatarNode3D on success, or null on failure.
|
||||||
|
func load_base_body(base_body_id: String) -> Node3D:
|
||||||
|
if catalog == null:
|
||||||
|
load_error.emit("AvatarCatalog not set on AvatarLoader")
|
||||||
|
return null
|
||||||
|
|
||||||
|
var asset_meta = catalog.resolve(base_body_id)
|
||||||
|
if asset_meta == null:
|
||||||
|
load_error.emit("Unknown base_body asset: %s" % base_body_id)
|
||||||
|
return null
|
||||||
|
|
||||||
|
if asset_meta.type != "base_body":
|
||||||
|
load_error.emit("Asset '%s' is not a base_body, got '%s'" % [
|
||||||
|
base_body_id, asset_meta.type])
|
||||||
|
return null
|
||||||
|
|
||||||
|
var path: String = asset_meta.path
|
||||||
|
avatar_node = _instance_glb_as_avatar(path)
|
||||||
|
if avatar_node == null:
|
||||||
|
load_error.emit("Failed to load base body scene: %s" % path)
|
||||||
|
return null
|
||||||
|
|
||||||
|
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||||
|
if skeleton == null:
|
||||||
|
load_error.emit("No Skeleton3D found in base body: %s" % path)
|
||||||
|
avatar_node.queue_free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Build bone index map for fast lookups.
|
||||||
|
_bone_index_map.clear()
|
||||||
|
for i in skeleton.get_bone_count():
|
||||||
|
var bone_name: String = skeleton.get_bone_name(i)
|
||||||
|
if not bone_name.is_empty():
|
||||||
|
_bone_index_map[bone_name] = i
|
||||||
|
|
||||||
|
# Extract base body meshes and store them.
|
||||||
|
_extract_base_meshes(avatar_node)
|
||||||
|
|
||||||
|
is_base_loaded = true
|
||||||
|
base_body_loaded.emit(avatar_node)
|
||||||
|
return avatar_node
|
||||||
|
|
||||||
|
|
||||||
|
## Load and merge an attachment into the avatar.
|
||||||
|
## [param slot_id] is the slot to equip into.
|
||||||
|
## [param asset_id] is the catalog id of the attachment.
|
||||||
|
## Returns the attachment Node3D on success, or null on failure.
|
||||||
|
func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
||||||
|
if not is_base_loaded:
|
||||||
|
load_error.emit("Cannot load attachment: base body not loaded")
|
||||||
|
return null
|
||||||
|
|
||||||
|
if catalog == null:
|
||||||
|
load_error.emit("AvatarCatalog not set on AvatarLoader")
|
||||||
|
return null
|
||||||
|
|
||||||
|
var asset_meta = catalog.resolve(asset_id)
|
||||||
|
if asset_meta == null:
|
||||||
|
load_error.emit("Unknown attachment asset: %s" % asset_id)
|
||||||
|
return null
|
||||||
|
|
||||||
|
if asset_meta.type != "attachment":
|
||||||
|
load_error.emit("Asset '%s' is not an attachment, got '%s'" % [
|
||||||
|
asset_id, asset_meta.type])
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Check slot exclusivity for accessory slot.
|
||||||
|
if slot_id == "accessory":
|
||||||
|
var current_accessories: Array = _attachments.get("accessory", [])
|
||||||
|
if current_accessories.size() >= MAX_ACCESSORY_COUNT:
|
||||||
|
load_error.emit("Accessory slot full (%d/%d)" % [
|
||||||
|
MAX_ACCESSORY_COUNT, MAX_ACCESSORY_COUNT])
|
||||||
|
return null
|
||||||
|
|
||||||
|
var path: String = asset_meta.path
|
||||||
|
var attachment_node = _instance_glb_as_attachment(path, slot_id)
|
||||||
|
if attachment_node == null:
|
||||||
|
load_error.emit("Failed to load attachment scene: %s" % path)
|
||||||
|
return null
|
||||||
|
|
||||||
|
# If the attachment carries its own skin, retarget it to the canonical
|
||||||
|
# skeleton; static props (the common case) are unaffected.
|
||||||
|
_retarget_skin(attachment_node)
|
||||||
|
|
||||||
|
# Mount to the appropriate bone via a BoneAttachment3D so it follows the
|
||||||
|
# skeleton. Falls back to a plain skeleton child if the mount bone is
|
||||||
|
# missing (a bad catalog entry) rather than dropping the attachment.
|
||||||
|
var mount_bone: String = asset_meta.get("mount_bone", "")
|
||||||
|
if mount_bone.is_empty():
|
||||||
|
mount_bone = _slot_to_mount_bone(slot_id)
|
||||||
|
var mount_node: Node3D
|
||||||
|
if not mount_bone.is_empty() and skeleton.find_bone(mount_bone) != -1:
|
||||||
|
var ba := BoneAttachment3D.new()
|
||||||
|
ba.name = "Mount_%s" % slot_id if slot_id != "accessory" else "Mount_accessory_%s" % asset_id
|
||||||
|
ba.bone_name = mount_bone
|
||||||
|
skeleton.add_child(ba)
|
||||||
|
ba.add_child(attachment_node)
|
||||||
|
mount_node = ba
|
||||||
|
else:
|
||||||
|
skeleton.add_child(attachment_node)
|
||||||
|
mount_node = attachment_node
|
||||||
|
|
||||||
|
# Store the top-level mount node so unload can free the whole unit.
|
||||||
|
if slot_id == "accessory":
|
||||||
|
var list: Array = _attachments.get("accessory", [])
|
||||||
|
list.append(mount_node)
|
||||||
|
_attachments["accessory"] = list
|
||||||
|
else:
|
||||||
|
if _attachments.has(slot_id) and is_instance_valid(_attachments[slot_id]):
|
||||||
|
_attachments[slot_id].queue_free()
|
||||||
|
_attachments[slot_id] = mount_node
|
||||||
|
|
||||||
|
attachment_loaded.emit(slot_id, attachment_node)
|
||||||
|
return attachment_node
|
||||||
|
|
||||||
|
|
||||||
|
## Remove an attachment from the given slot.
|
||||||
|
func unload_attachment(slot_id: String) -> void:
|
||||||
|
if slot_id == "accessory":
|
||||||
|
for node in _attachments.get("accessory", []):
|
||||||
|
if is_instance_valid(node):
|
||||||
|
node.queue_free()
|
||||||
|
_attachments.erase("accessory")
|
||||||
|
else:
|
||||||
|
var node = _attachments.get(slot_id)
|
||||||
|
if is_instance_valid(node):
|
||||||
|
node.queue_free()
|
||||||
|
_attachments.erase(slot_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Hide or show the base mesh for a given body-part slot.
|
||||||
|
func set_base_mesh_visible(slot_id: String, visible: bool) -> void:
|
||||||
|
var mesh = _base_meshes.get(slot_id)
|
||||||
|
if mesh and mesh is Node3D:
|
||||||
|
mesh.visible = visible
|
||||||
|
|
||||||
|
|
||||||
|
## Retint the base body's skin surfaces (the ones the generator painted
|
||||||
|
## PLACEHOLDER_SKIN_ALBEDO -- head + arms) to [param color]. A no-op if the
|
||||||
|
## colour is unset (transparent). Real segmented bodies will do this per mesh.
|
||||||
|
func apply_skin_tone(color: Color) -> void:
|
||||||
|
if color.a <= 0.0:
|
||||||
|
return
|
||||||
|
if avatar_node == null or not is_instance_valid(avatar_node):
|
||||||
|
return
|
||||||
|
var skel := avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||||
|
if skel == null:
|
||||||
|
return
|
||||||
|
for child in skel.get_children():
|
||||||
|
if not (child is MeshInstance3D):
|
||||||
|
continue
|
||||||
|
var mi := child as MeshInstance3D
|
||||||
|
if mi.mesh == null:
|
||||||
|
continue
|
||||||
|
for s in mi.mesh.get_surface_count():
|
||||||
|
var mat := mi.get_active_material(s)
|
||||||
|
if mat is BaseMaterial3D and \
|
||||||
|
(mat as BaseMaterial3D).albedo_color.is_equal_approx(PLACEHOLDER_SKIN_ALBEDO):
|
||||||
|
var tinted := StandardMaterial3D.new()
|
||||||
|
tinted.albedo_color = color
|
||||||
|
tinted.roughness = 0.9
|
||||||
|
mi.set_surface_override_material(s, tinted)
|
||||||
|
|
||||||
|
|
||||||
|
## Get the current loadout as a dictionary (for serialization).
|
||||||
|
func get_current_loadout() -> Dictionary:
|
||||||
|
var loadout: Dictionary = {
|
||||||
|
"base_body": "",
|
||||||
|
"slots": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine base body id from catalog.
|
||||||
|
if is_base_loaded and catalog != null:
|
||||||
|
# Walk through catalog to find which base_body loaded.
|
||||||
|
for id in catalog.get_all_ids():
|
||||||
|
var meta = catalog.resolve(id)
|
||||||
|
if meta and meta.type == "base_body":
|
||||||
|
loadout.base_body = id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Collect equipped attachments.
|
||||||
|
for slot_id in _attachments:
|
||||||
|
if slot_id == "accessory":
|
||||||
|
loadout.slots[slot_id] = _attachments[slot_id] as Array
|
||||||
|
else:
|
||||||
|
var node = _attachments[slot_id]
|
||||||
|
if node is Node3D:
|
||||||
|
# Find the asset id by matching the node's name.
|
||||||
|
var aid: String = node.name
|
||||||
|
loadout.slots[slot_id] = aid
|
||||||
|
|
||||||
|
return loadout
|
||||||
|
|
||||||
|
|
||||||
|
## Reset the avatar to an unloaded state.
|
||||||
|
func reset() -> void:
|
||||||
|
if avatar_node and avatar_node is Node:
|
||||||
|
avatar_node.queue_free()
|
||||||
|
|
||||||
|
avatar_node = null
|
||||||
|
skeleton = null
|
||||||
|
_bone_index_map.clear()
|
||||||
|
_base_meshes.clear()
|
||||||
|
_attachments.clear()
|
||||||
|
is_base_loaded = false
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: GLB Parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Instantiate a base-body .glb (a Godot-imported PackedScene) into an avatar
|
||||||
|
## structure:
|
||||||
|
## Avatar (Node3D)
|
||||||
|
## Skeleton3D -- the canonical rig + skin-bound body meshes
|
||||||
|
## BodyMeshes (Node3D) -- unused container, kept for compatibility
|
||||||
|
## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment)
|
||||||
|
##
|
||||||
|
## Uses load().instantiate() rather than the runtime GLTFDocument path: the
|
||||||
|
## imported .scn is the one that actually ends up in an exported build, with
|
||||||
|
## the skeleton + skin set up by Godot's import pipeline. (The raw-bytes
|
||||||
|
## GLTFDocument path -- for future user-uploaded models -- misbehaved in a
|
||||||
|
## release build: no Skeleton3D produced, so the player fell back to a capsule.)
|
||||||
|
func _instance_glb_as_avatar(path: String) -> Node3D:
|
||||||
|
var packed = load(path)
|
||||||
|
if not (packed is PackedScene):
|
||||||
|
push_error("Base body is not an importable scene: %s" % path)
|
||||||
|
return null
|
||||||
|
var scene_root: Node = packed.instantiate()
|
||||||
|
if not (scene_root is Node3D):
|
||||||
|
push_error("Base body scene root is not a Node3D: %s" % path)
|
||||||
|
scene_root.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
var src_skel := _find_first_skeleton(scene_root)
|
||||||
|
if src_skel == null:
|
||||||
|
push_error("No Skeleton3D in base body scene: %s" % path)
|
||||||
|
scene_root.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
var avatar := Node3D.new()
|
||||||
|
avatar.name = "Avatar"
|
||||||
|
|
||||||
|
src_skel.get_parent().remove_child(src_skel)
|
||||||
|
src_skel.name = "Skeleton3D"
|
||||||
|
src_skel.transform = Transform3D.IDENTITY
|
||||||
|
_clear_owner_recursive(src_skel)
|
||||||
|
avatar.add_child(src_skel)
|
||||||
|
scene_root.free()
|
||||||
|
|
||||||
|
var body_meshes := Node3D.new()
|
||||||
|
body_meshes.name = "BodyMeshes"
|
||||||
|
avatar.add_child(body_meshes)
|
||||||
|
|
||||||
|
var attachments_container := Node3D.new()
|
||||||
|
attachments_container.name = "Attachments"
|
||||||
|
avatar.add_child(attachments_container)
|
||||||
|
|
||||||
|
return avatar
|
||||||
|
|
||||||
|
|
||||||
|
## Depth-first search for the first Skeleton3D under [param node].
|
||||||
|
func _find_first_skeleton(node: Node) -> Skeleton3D:
|
||||||
|
if node is Skeleton3D:
|
||||||
|
return node as Skeleton3D
|
||||||
|
for child in node.get_children():
|
||||||
|
var found := _find_first_skeleton(child)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Instantiate an attachment .glb (imported PackedScene) into a single named
|
||||||
|
## Node3D the caller can mount to a bone as one unit.
|
||||||
|
func _instance_glb_as_attachment(path: String, slot_id: String) -> Node3D:
|
||||||
|
var packed = load(path)
|
||||||
|
if not (packed is PackedScene):
|
||||||
|
push_error("Attachment is not an importable scene: %s" % path)
|
||||||
|
return null
|
||||||
|
var scene_root: Node = packed.instantiate()
|
||||||
|
if not (scene_root is Node3D):
|
||||||
|
push_error("Attachment scene root is not a Node3D: %s" % path)
|
||||||
|
scene_root.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
var attachment := Node3D.new()
|
||||||
|
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
|
||||||
|
for child in scene_root.get_children().duplicate():
|
||||||
|
scene_root.remove_child(child)
|
||||||
|
_clear_owner_recursive(child)
|
||||||
|
attachment.add_child(child)
|
||||||
|
scene_root.free()
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
|
## generate_scene() stamps every node with the GLB root as `owner`; that
|
||||||
|
## becomes inconsistent (and noisy) once we reparent the node elsewhere.
|
||||||
|
func _clear_owner_recursive(node: Node) -> void:
|
||||||
|
node.owner = null
|
||||||
|
for child in node.get_children():
|
||||||
|
_clear_owner_recursive(child)
|
||||||
|
|
||||||
|
|
||||||
|
## Index the base body's skinned meshes by slot so equipping a body-part
|
||||||
|
## attachment can hide the corresponding base mesh. generate_scene() parents
|
||||||
|
## skinned meshes under the Skeleton3D, so scan there.
|
||||||
|
func _extract_base_meshes(avatar: Node3D) -> void:
|
||||||
|
var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||||
|
if skel == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
for child in skel.get_children():
|
||||||
|
if child is MeshInstance3D:
|
||||||
|
# The single "Body" placeholder mesh is registered only under
|
||||||
|
# "torso" -- mapping it to every body-part slot would make it
|
||||||
|
# vanish the moment any one body-part item is equipped. A real
|
||||||
|
# multi-mesh body registers each mesh under its own slot.
|
||||||
|
if child.name == "Body":
|
||||||
|
_base_meshes["torso"] = child
|
||||||
|
else:
|
||||||
|
var slot_id := _mesh_name_to_slot(child.name)
|
||||||
|
if slot_id != "":
|
||||||
|
_base_meshes[slot_id] = child
|
||||||
|
|
||||||
|
|
||||||
|
## Map a mesh name to a slot id based on naming conventions (§4.3).
|
||||||
|
func _mesh_name_to_slot(mesh_name: String) -> String:
|
||||||
|
if mesh_name == "Body":
|
||||||
|
# Generic body mesh - covers torso, legs, arms.
|
||||||
|
return "torso"
|
||||||
|
if mesh_name == "head_Mesh":
|
||||||
|
return "head"
|
||||||
|
if mesh_name == "torso_Mesh":
|
||||||
|
return "torso"
|
||||||
|
if mesh_name == "legs_Mesh":
|
||||||
|
return "legs"
|
||||||
|
if mesh_name == "left_arm_Mesh":
|
||||||
|
return "left_arm"
|
||||||
|
if mesh_name == "right_arm_Mesh":
|
||||||
|
return "right_arm"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: Skin Retargeting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Retarget the skin weights of an attachment node to the canonical skeleton.
|
||||||
|
## Walks all MeshInstance3D children and remaps joint indices by bone name.
|
||||||
|
func _retarget_skin(attachment_node: Node3D) -> void:
|
||||||
|
if skeleton == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
for child in attachment_node.get_children():
|
||||||
|
if child is MeshInstance3D:
|
||||||
|
var mesh_inst := child as MeshInstance3D
|
||||||
|
var mesh := mesh_inst.mesh
|
||||||
|
if mesh == null:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Note: MeshInstance3D.mesh is always a Mesh, never a MultiMesh —
|
||||||
|
# MultiMesh skinning goes through MultiMeshInstance3D.multimesh,
|
||||||
|
# a completely separate node/property, not reachable here.
|
||||||
|
if mesh is ArrayMesh:
|
||||||
|
var arr_mesh := mesh as ArrayMesh
|
||||||
|
var surface_count := arr_mesh.get_surface_count()
|
||||||
|
|
||||||
|
for surface_idx in surface_count:
|
||||||
|
var arrays := arr_mesh.surface_get_arrays(surface_idx)
|
||||||
|
# Static props (the common attachment) carry no bone/weight
|
||||||
|
# arrays at all -- nothing to retarget.
|
||||||
|
if arrays[Mesh.ARRAY_BONES] == null or arrays[Mesh.ARRAY_WEIGHTS] == null:
|
||||||
|
continue
|
||||||
|
var joints_array: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||||
|
var weights_array: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||||
|
|
||||||
|
if joints_array.is_empty():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remap joint indices by bone name.
|
||||||
|
var new_joints := PackedInt32Array()
|
||||||
|
for i in range(0, joints_array.size(), 4):
|
||||||
|
for j in 4:
|
||||||
|
var old_joint := joints_array[i + j]
|
||||||
|
if old_joint < 0:
|
||||||
|
new_joints.append(0)
|
||||||
|
else:
|
||||||
|
var bone_name := skeleton.get_bone_name(old_joint)
|
||||||
|
var new_idx: int = _bone_index_map.get(bone_name, 0)
|
||||||
|
new_joints.append(new_idx)
|
||||||
|
|
||||||
|
arrays[Mesh.ARRAY_BONES] = new_joints
|
||||||
|
arr_mesh.surface_set_arrays(surface_idx, arrays)
|
||||||
|
|
||||||
|
|
||||||
|
## Parent an attachment node to its mount bone in the skeleton.
|
||||||
|
func _parent_attachment(attachment_node: Node3D, slot_id: String,
|
||||||
|
asset_meta: Dictionary) -> void:
|
||||||
|
|
||||||
|
if skeleton == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var mount_bone: String = asset_meta.get("mount_bone", "")
|
||||||
|
if mount_bone.is_empty():
|
||||||
|
# Try to infer from slot.
|
||||||
|
mount_bone = _slot_to_mount_bone(slot_id)
|
||||||
|
|
||||||
|
if mount_bone.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var bone_idx := skeleton.find_bone(mount_bone)
|
||||||
|
if bone_idx < 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the world transform of the bone at the time of attachment.
|
||||||
|
var bone_pose := skeleton.get_bone_pose(bone_idx)
|
||||||
|
var bone_transform := skeleton.global_transform * bone_pose
|
||||||
|
|
||||||
|
# Position the attachment at the bone's location.
|
||||||
|
attachment_node.global_transform = bone_transform
|
||||||
|
|
||||||
|
|
||||||
|
## Convert a slot id to its canonical mount bone name (§3.2).
|
||||||
|
func _slot_to_mount_bone(slot_id: String) -> String:
|
||||||
|
match slot_id:
|
||||||
|
"head": return "Head"
|
||||||
|
"hair": return "HeadTop"
|
||||||
|
"face": return "Jaw"
|
||||||
|
"torso", "chest", "back": return "Chest"
|
||||||
|
"left_arm": return "LeftUpperArm"
|
||||||
|
"right_arm": return "RightUpperArm"
|
||||||
|
"left_hand": return "LeftHand"
|
||||||
|
"right_hand": return "RightHand"
|
||||||
|
"legs": return "Hips"
|
||||||
|
"pants": return "Hips"
|
||||||
|
"left_foot": return "LeftFoot"
|
||||||
|
"right_foot": return "RightFoot"
|
||||||
|
"accessory": return "Root"
|
||||||
|
_: return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: File Loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Load a file from the given path as a PackedByteArray.
|
||||||
|
func _load_file(path: String) -> PackedByteArray:
|
||||||
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return PackedByteArray()
|
||||||
|
var data := file.get_buffer(file.get_length())
|
||||||
|
file.close()
|
||||||
|
return data
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user