Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26092f36db | |||
| 1b31d4bf30 | |||
| c1e7a44db9 | |||
| 549c05f1c0 | |||
| b0c35e1dca | |||
| 355fdaff13 |
@@ -1,39 +0,0 @@
|
|||||||
name: checkpoint
|
|
||||||
|
|
||||||
# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke
|
|
||||||
# tests, multiplayer, fresh-clone boot) on every push and PR.
|
|
||||||
#
|
|
||||||
# Runs on a `host`-label act_runner on the AGP box: Docker, ~/godot/godot,
|
|
||||||
# and Python 3 are already there. Deliberately uses no JavaScript actions
|
|
||||||
# (actions/checkout etc.) so the host needs no Node install -- checkout is a
|
|
||||||
# plain git clone from the local Gitea. See docs/ci.md.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
checkpoint:
|
|
||||||
runs-on: host
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
rm -rf repo && mkdir repo && cd repo
|
|
||||||
git init -q
|
|
||||||
git remote add origin "http://x:${{ github.token }}@localhost:3000/${{ github.repository }}.git"
|
|
||||||
git fetch -q --depth 1 origin "${{ github.sha }}"
|
|
||||||
git checkout -q FETCH_HEAD
|
|
||||||
|
|
||||||
- name: Python env
|
|
||||||
working-directory: repo
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install --quiet --upgrade pip
|
|
||||||
.venv/bin/pip install --quiet -r backend/requirements.txt
|
|
||||||
|
|
||||||
- name: Checkpoint suite
|
|
||||||
working-directory: repo
|
|
||||||
run: .venv/bin/python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
# Godot 4 editor cache/import directory
|
|
||||||
.godot/
|
|
||||||
|
|
||||||
# Godot export artifacts
|
|
||||||
export/
|
|
||||||
*.tmp
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Godot UID files (regenerated from path)
|
|
||||||
*.uid
|
|
||||||
assets/build_info.json
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://bm01jsuomi50i"
|
|
||||||
path="res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/accessory_belt_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://dvou487niwnjt"
|
|
||||||
path="res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/accessory_necklace_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://dknbtq33pssf4"
|
|
||||||
path="res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/back_backpack_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://brfp6g6pcbbw7"
|
|
||||||
path="res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/base_human_male_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
{
|
|
||||||
"assets": {
|
|
||||||
"base_human_male_01": {
|
|
||||||
"type": "base_body",
|
|
||||||
"slot": "base_body",
|
|
||||||
"path": "res://assets/avatars/base_human_male_01.glb",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"display_name": "Human Male Base"
|
|
||||||
},
|
|
||||||
"head_cyber_01": {
|
|
||||||
"type": "attachment",
|
|
||||||
"slot": "head",
|
|
||||||
"path": "res://assets/avatars/head_cyber_01.glb",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"display_name": "Cyber Head",
|
|
||||||
"mount_bone": "Head"
|
|
||||||
},
|
|
||||||
"hair_mohawk_01": {
|
|
||||||
"type": "attachment",
|
|
||||||
"slot": "hair",
|
|
||||||
"path": "res://assets/avatars/hair_mohawk_01.glb",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"display_name": "Mohawk",
|
|
||||||
"mount_bone": "HeadTop"
|
|
||||||
},
|
|
||||||
"hair_ponytail_01": {
|
|
||||||
"type": "attachment",
|
|
||||||
"slot": "hair",
|
|
||||||
"path": "res://assets/avatars/hair_ponytail_01.glb",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"display_name": "Ponytail",
|
|
||||||
"mount_bone": "HeadTop"
|
|
||||||
},
|
|
||||||
"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.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://bva2etlpa8act"
|
|
||||||
path="res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/chest_bandit_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://ctwcxf552y325"
|
|
||||||
path="res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/face_glasses_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://j4dc6l3l0j6f"
|
|
||||||
path="res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/hair_mohawk_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://dp4vqp2trrank"
|
|
||||||
path="res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/hair_ponytail_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://bkp35fnns36xb"
|
|
||||||
path="res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/head_cyber_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://bgfjpdu8kg0aj"
|
|
||||||
path="res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/left_foot_boot_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://dtq7tgio1iymd"
|
|
||||||
path="res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/left_hand_glove_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://yo204q1m6hs3"
|
|
||||||
path="res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/pants_cargo_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://30ij0eapm6ti"
|
|
||||||
path="res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/avatars/right_foot_boot_01.glb"
|
|
||||||
dest_files=["res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/embedded_image_handling=1
|
|
||||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"content_flags": {
|
|
||||||
"violence": "none",
|
|
||||||
"mature": false
|
|
||||||
},
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"trigger": {"type": "on_interact"},
|
|
||||||
"conditions": [],
|
|
||||||
"actions": [
|
|
||||||
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
|
|
||||||
{"verb": "play_sound", "sound_id": "chest_open"},
|
|
||||||
{"verb": "rotate", "target": "self", "degrees": 90, "axis": "y", "duration": 0.4}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"templates": {
|
|
||||||
"small_crate": {
|
|
||||||
"asset_id": "props/crate_small.glb",
|
|
||||||
"display_name": "Small Crate"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-15
@@ -1,23 +1,14 @@
|
|||||||
# Database (async driver)
|
# Database
|
||||||
DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
DATABASE_URL=postgresql://agp_user:agp_password@localhost:5432/agp_db
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# JWT / Auth
|
# JWT
|
||||||
ALGORITHM=RS256
|
SECRET_KEY=change-me-to-a-secure-random-string
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
ALGORITHM=HS256
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
# Directory where the RSA keypair is loaded/generated at runtime.
|
|
||||||
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
|
|
||||||
RSA_KEY_DIR=~/.agp/keys
|
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_NAME=AGP Backend
|
|
||||||
APP_VERSION=0.1.0
|
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
CORS_ORIGINS=http://localhost:8080,http://localhost:3000
|
CORS_ORIGINS=http://localhost:8080,http://localhost:3000
|
||||||
|
|
||||||
# File uploads
|
|
||||||
UPLOAD_DIR=uploads
|
|
||||||
MAX_UPLOAD_SIZE_MB=100
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# Alembic configuration file.
|
|
||||||
# Run: alembic upgrade head (to apply migrations)
|
|
||||||
# Run: alembic revision --autogenerate -m "description" (to generate migration)
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
script_location = alembic
|
|
||||||
prepend_sys_path = .
|
|
||||||
sqlalchemy.url = postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Alembic environment configuration."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import Base
|
|
||||||
from app.models import user, session, world, asset, avatar_loadout # noqa: F401
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
"""Run migrations in 'offline' mode."""
|
|
||||||
url = settings.DATABASE_URL
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={"paramstyle": "named"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection):
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_async_migrations():
|
|
||||||
"""Run migrations in 'online' mode with async engine."""
|
|
||||||
connectable = create_async_engine(settings.DATABASE_URL, echo=False)
|
|
||||||
|
|
||||||
async with connectable.connect() as connection:
|
|
||||||
await connection.run_sync(do_run_migrations)
|
|
||||||
|
|
||||||
await connectable.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
"""Run migrations in 'online' mode."""
|
|
||||||
asyncio.run(run_async_migrations())
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
|
||||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade database schema."""
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade database schema."""
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"""initial schema
|
|
||||||
|
|
||||||
Revision ID: 0001_initial
|
|
||||||
Revises:
|
|
||||||
Create Date: 2024-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 = "0001_initial"
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade database schema."""
|
|
||||||
# Create ENUM types
|
|
||||||
world_visibility = postgresql.ENUM(
|
|
||||||
"public", "private", "friends_only", name="world_visibility"
|
|
||||||
)
|
|
||||||
world_visibility.create(op.get_bind(), checkfirst=True)
|
|
||||||
|
|
||||||
asset_type = postgresql.ENUM(
|
|
||||||
"image", "audio", "video", "document", "model_3d", "other", name="asset_type"
|
|
||||||
)
|
|
||||||
asset_type.create(op.get_bind(), checkfirst=True)
|
|
||||||
|
|
||||||
# Create users table
|
|
||||||
op.create_table(
|
|
||||||
"users",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
|
||||||
sa.Column("username", sa.String(length=50), nullable=False),
|
|
||||||
sa.Column("email", sa.String(length=120), nullable=False),
|
|
||||||
sa.Column("hashed_password", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
|
||||||
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
|
|
||||||
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
|
||||||
|
|
||||||
# Create sessions table
|
|
||||||
op.create_table(
|
|
||||||
"sessions",
|
|
||||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
|
||||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("token", sa.String(), nullable=False),
|
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_sessions_token"), "sessions", ["token"], unique=True)
|
|
||||||
|
|
||||||
# Create worlds table
|
|
||||||
op.create_table(
|
|
||||||
"worlds",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("description", sa.Text(), nullable=True),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"visibility",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"public", "private", "friends_only", name="world_visibility",
|
|
||||||
create_type=False,
|
|
||||||
),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_worlds_id"), "worlds", ["id"], unique=False)
|
|
||||||
|
|
||||||
# Create assets table
|
|
||||||
op.create_table(
|
|
||||||
"assets",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("description", sa.Text(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"asset_type",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"image", "audio", "video", "document", "model_3d", "other",
|
|
||||||
name="asset_type", create_type=False,
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("file_path", sa.String(length=512), nullable=False),
|
|
||||||
sa.Column("file_size", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("mime_type", sa.String(length=128), nullable=True),
|
|
||||||
sa.Column("metadata_json", sa.Text(), nullable=True),
|
|
||||||
sa.Column("world_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("is_public", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
|
||||||
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"]),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_assets_id"), "assets", ["id"], unique=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade database schema."""
|
|
||||||
op.drop_index(op.f("ix_assets_id"), table_name="assets")
|
|
||||||
op.drop_table("assets")
|
|
||||||
op.drop_index(op.f("ix_worlds_id"), table_name="worlds")
|
|
||||||
op.drop_table("worlds")
|
|
||||||
op.drop_index(op.f("ix_sessions_token"), table_name="sessions")
|
|
||||||
op.drop_table("sessions")
|
|
||||||
op.drop_index(op.f("ix_users_username"), table_name="users")
|
|
||||||
op.drop_index(op.f("ix_users_id"), table_name="users")
|
|
||||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
|
||||||
op.drop_table("users")
|
|
||||||
|
|
||||||
# Drop ENUM types
|
|
||||||
postgresql.ENUM(name="asset_type").drop(op.get_bind(), checkfirst=True)
|
|
||||||
postgresql.ENUM(name="world_visibility").drop(op.get_bind(), checkfirst=True)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"""add date_of_birth column to users table
|
|
||||||
|
|
||||||
Revision ID: 0002_add_date_of_birth_to_users
|
|
||||||
Revises: 0001_initial
|
|
||||||
Create Date: 2025-01-01 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "0002_add_date_of_birth_to_users"
|
|
||||||
down_revision: Union[str, None] = "0001_initial"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Add date_of_birth column to users table."""
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("date_of_birth", sa.Date(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Remove date_of_birth column from users table."""
|
|
||||||
op.drop_column("users", "date_of_birth")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""add refresh_tokens table
|
|
||||||
|
|
||||||
Revision ID: 0003_add_refresh_tokens_table
|
|
||||||
Revises: 0002_add_date_of_birth_to_users
|
|
||||||
Create Date: 2025-07-25 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "0003_add_refresh_tokens_table"
|
|
||||||
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Create the refresh_tokens table."""
|
|
||||||
op.create_table(
|
|
||||||
"refresh_tokens",
|
|
||||||
sa.Column("id", sa.String(36), primary_key=True),
|
|
||||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
|
||||||
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
|
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Drop the refresh_tokens table."""
|
|
||||||
op.drop_table("refresh_tokens")
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""add avatar_loadouts table
|
|
||||||
|
|
||||||
Revision ID: 0004_add_avatar_loadouts_table
|
|
||||||
Revises: 0003_add_refresh_tokens_table
|
|
||||||
Create Date: 2026-09-05 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "0004_add_avatar_loadouts_table"
|
|
||||||
down_revision: Union[str, None] = "0003_add_refresh_tokens_table"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Create the avatar_loadouts table (one row per user)."""
|
|
||||||
op.create_table(
|
|
||||||
"avatar_loadouts",
|
|
||||||
sa.Column(
|
|
||||||
"user_id",
|
|
||||||
sa.Integer(),
|
|
||||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
sa.Column("data", postgresql.JSONB(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Drop the avatar_loadouts table."""
|
|
||||||
op.drop_table("avatar_loadouts")
|
|
||||||
+11
-131
@@ -1,152 +1,32 @@
|
|||||||
"""Application configuration via pydantic-settings."""
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
# Database
|
||||||
APP_NAME: str = "AGP Backend"
|
|
||||||
APP_VERSION: str = "0.1.0"
|
|
||||||
DEBUG: bool = True
|
|
||||||
|
|
||||||
# Database (async)
|
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
|
DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
# JWT / Auth
|
# JWT
|
||||||
ALGORITHM: str = "RS256"
|
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
ALGORITHM: str = "HS256"
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
|
||||||
# RS256 key storage. Defaults to a directory outside the repo so the
|
# App
|
||||||
# generated keypair is never accidentally committed.
|
DEBUG: bool = True
|
||||||
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
UPLOAD_DIR: str = "uploads"
|
|
||||||
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(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
case_sensitive=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
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
|
class Config:
|
||||||
def rsa_key_dir(self) -> Path:
|
env_file = ".env"
|
||||||
return Path(self.RSA_KEY_DIR)
|
case_sensitive = True
|
||||||
|
|
||||||
@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:
|
||||||
"""Return cached settings instance."""
|
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
# Convenience singleton
|
|
||||||
settings = get_settings()
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.database_url,
|
||||||
echo=settings.DEBUG,
|
echo=settings.DEBUG,
|
||||||
pool_size=10,
|
pool_size=10,
|
||||||
max_overflow=20,
|
max_overflow=20,
|
||||||
@@ -28,15 +28,11 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncSession:
|
async def get_db() -> AsyncSession:
|
||||||
"""Dependency that yields an async database session.
|
"""Dependency that yields an async database session."""
|
||||||
|
|
||||||
Handlers are responsible for committing or rolling back their own
|
|
||||||
transactions. This avoids the double-commit problem where the
|
|
||||||
session is committed both here and in the handler.
|
|
||||||
"""
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Shared FastAPI dependencies, including JWT-based auth."""
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
token: Annotated[str, Depends(oauth2_scheme)],
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
) -> User:
|
|
||||||
"""Resolve the JWT bearer token to a User, or raise 401."""
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
|
||||||
)
|
|
||||||
user_id: str | None = payload.get("sub")
|
|
||||||
if user_id is None:
|
|
||||||
raise credentials_exception
|
|
||||||
except JWTError:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_id_int = int(user_id)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id_int))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if user is None:
|
|
||||||
raise credentials_exception
|
|
||||||
if not user.is_active:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="User account is inactive",
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
||||||
+6
-20
@@ -3,48 +3,34 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import auth, users, worlds, assets, avatar
|
from app.routers import auth, users, worlds, assets
|
||||||
|
|
||||||
|
|
||||||
from app.services.game_servers import supervisor
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: startup and shutdown events."""
|
"""Application lifespan: startup and shutdown events."""
|
||||||
supervisor.start()
|
# Startup: initialize database, connect to Redis, etc.
|
||||||
yield
|
yield
|
||||||
await supervisor.shutdown()
|
# Shutdown: close connections, cleanup
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.APP_NAME,
|
title=settings.app_name,
|
||||||
version=settings.APP_VERSION,
|
version=settings.app_version,
|
||||||
description="Agentic Game Platform - Backend API",
|
description="Agentic Game Platform - Backend API",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.cors_origins_list,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||||
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
||||||
app.include_router(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")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Simple health check endpoint."""
|
"""Simple health check endpoint."""
|
||||||
return {"status": "ok", "app": settings.APP_NAME}
|
return {"status": "ok", "app": settings.app_name}
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ from app.models.user import User
|
|||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.models.world import World
|
from app.models.world import World
|
||||||
from app.models.asset import Asset
|
from app.models.asset import Asset
|
||||||
from app.models.avatar_loadout import AvatarLoadout
|
|
||||||
|
|
||||||
__all__ = ["User", "Session", "World", "Asset", "AvatarLoadout"]
|
__all__ = ["User", "Session", "World", "Asset"]
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
|
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Enum as SAEnum
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
import enum
|
import enum
|
||||||
@@ -22,17 +21,14 @@ class Asset(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
asset_type = Column(
|
asset_type = Column(SAEnum(AssetType), nullable=False)
|
||||||
ENUM(AssetType, name="asset_type", create_type=False),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
file_path = Column(String(512), nullable=False)
|
file_path = Column(String(512), nullable=False)
|
||||||
file_size = Column(Integer, nullable=True)
|
file_size = Column(Integer, nullable=True)
|
||||||
mime_type = Column(String(128), nullable=True)
|
mime_type = Column(String(128), nullable=True)
|
||||||
metadata_json = Column(Text, nullable=True)
|
metadata_json = Column(Text, nullable=True)
|
||||||
world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True)
|
world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True)
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
is_public = Column(Boolean, default=False, nullable=False)
|
is_public = Column(Integer, default=0, nullable=False)
|
||||||
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), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
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,17 +1,14 @@
|
|||||||
import uuid
|
from sqlalchemy import Column, 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
|
||||||
|
|
||||||
|
|
||||||
class Session(Base):
|
class Session(Base):
|
||||||
__tablename__ = "sessions"
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(String, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
token = Column(String, unique=True, index=True, nullable=False)
|
token = Column(String, unique=True, index=True, nullable=False)
|
||||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
@@ -19,23 +16,3 @@ 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,10 +1,5 @@
|
|||||||
from datetime import date
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -15,30 +10,6 @@ class User(Base):
|
|||||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
email = Column(String(120), unique=True, index=True, nullable=False)
|
email = Column(String(120), unique=True, index=True, nullable=False)
|
||||||
hashed_password = Column(String(255), nullable=False)
|
hashed_password = Column(String(255), nullable=False)
|
||||||
date_of_birth = Column(Date, nullable=True)
|
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=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")
|
|
||||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
worlds = relationship("World", back_populates="owner")
|
|
||||||
assets = relationship("Asset", back_populates="owner")
|
|
||||||
avatar_loadout = relationship(
|
|
||||||
"AvatarLoadout",
|
|
||||||
back_populates="user",
|
|
||||||
uselist=False,
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_adult(self) -> bool:
|
|
||||||
"""Return True if the user is 18 or older based on date_of_birth."""
|
|
||||||
if self.date_of_birth is None:
|
|
||||||
return False
|
|
||||||
today = date.today()
|
|
||||||
age = today.year - self.date_of_birth.year
|
|
||||||
# Adjust if birthday hasn't occurred yet this year
|
|
||||||
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
|
|
||||||
age -= 1
|
|
||||||
return age >= 18
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
import enum
|
import enum
|
||||||
@@ -20,15 +19,7 @@ class World(Base):
|
|||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
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), default=WorldVisibility.PUBLIC)
|
||||||
ENUM(
|
|
||||||
WorldVisibility,
|
|
||||||
name="world_visibility",
|
|
||||||
create_type=False,
|
|
||||||
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
|
||||||
),
|
|
||||||
default=WorldVisibility.PUBLIC,
|
|
||||||
)
|
|
||||||
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())
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
"""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,243 +0,0 @@
|
|||||||
"""Asset management router."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.asset import Asset, AssetType
|
|
||||||
from app.models.world import World
|
|
||||||
from app.services import gltf as gltf_service
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
|
||||||
|
|
||||||
class AssetCreate(BaseModel):
|
|
||||||
"""Schema for creating an asset reference."""
|
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
|
||||||
description: Optional[str] = None
|
|
||||||
asset_type: AssetType
|
|
||||||
world_id: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AssetResponse(BaseModel):
|
|
||||||
"""Schema for asset responses."""
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
description: Optional[str]
|
|
||||||
asset_type: AssetType
|
|
||||||
file_path: str
|
|
||||||
file_size: Optional[int]
|
|
||||||
mime_type: Optional[str]
|
|
||||||
world_id: Optional[int]
|
|
||||||
owner_id: int
|
|
||||||
is_public: bool
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class AssetUpdate(BaseModel):
|
|
||||||
"""Schema for updating an asset."""
|
|
||||||
name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_public: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
|
||||||
|
|
||||||
def _sanitize_filename(filename: str) -> str:
|
|
||||||
"""Strip path components and unsafe characters from a filename."""
|
|
||||||
# Remove any directory components (path traversal protection).
|
|
||||||
base = os.path.basename(filename.replace("\\", "/"))
|
|
||||||
# Keep only safe characters.
|
|
||||||
safe = "".join(c for c in base if c.isalnum() or c in "._-")
|
|
||||||
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 ---
|
|
||||||
|
|
||||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
|
||||||
async def create_asset(
|
|
||||||
payload: AssetCreate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Create a new asset reference."""
|
|
||||||
# Verify world ownership if world_id is provided
|
|
||||||
if payload.world_id is not None:
|
|
||||||
world = await db.get(World, payload.world_id)
|
|
||||||
if world is None:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
if world.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
|
||||||
|
|
||||||
asset = Asset(
|
|
||||||
name=payload.name,
|
|
||||||
description=payload.description,
|
|
||||||
asset_type=payload.asset_type,
|
|
||||||
file_path="", # Will be set after upload
|
|
||||||
owner_id=current_user.id,
|
|
||||||
world_id=payload.world_id,
|
|
||||||
is_public=False,
|
|
||||||
)
|
|
||||||
db.add(asset)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
return asset
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[AssetResponse])
|
|
||||||
async def list_assets(
|
|
||||||
current_user: CurrentUser,
|
|
||||||
world_id: Optional[int] = Query(None, description="Filter by world"),
|
|
||||||
asset_type: Optional[AssetType] = Query(None),
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""List assets with optional filters."""
|
|
||||||
stmt = select(Asset).where(Asset.owner_id == current_user.id)
|
|
||||||
|
|
||||||
if world_id is not None:
|
|
||||||
stmt = stmt.where(Asset.world_id == world_id)
|
|
||||||
if asset_type is not None:
|
|
||||||
stmt = stmt.where(Asset.asset_type == asset_type)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
assets = result.scalars().all()
|
|
||||||
return assets
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
|
||||||
async def get_asset(
|
|
||||||
asset_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Get a single asset by ID."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
return asset
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{asset_id}", response_model=AssetResponse)
|
|
||||||
async def update_asset(
|
|
||||||
asset_id: int,
|
|
||||||
payload: AssetUpdate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Update an asset."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
|
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(asset, field, value)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
return asset
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{asset_id}", status_code=204)
|
|
||||||
async def delete_asset(
|
|
||||||
asset_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Delete an asset."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
|
|
||||||
await db.delete(asset)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{asset_id}/upload", response_model=AssetResponse)
|
|
||||||
async def upload_asset_file(
|
|
||||||
asset_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Upload a file for an existing asset."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
|
|
||||||
# Sanitize filename to prevent path traversal.
|
|
||||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
|
||||||
|
|
||||||
# Read the file content.
|
|
||||||
content = await file.read()
|
|
||||||
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:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=413,
|
|
||||||
detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write the file to the upload directory.
|
|
||||||
upload_dir = Path(settings.UPLOAD_DIR)
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
stored_name = f"{asset_id}_{safe_filename}"
|
|
||||||
file_path = upload_dir / stored_name
|
|
||||||
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.mime_type = file.content_type
|
|
||||||
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.refresh(asset)
|
|
||||||
return asset
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
"""Authentication router - login, register, token refresh."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
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.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.session import RefreshToken
|
|
||||||
from app.models.user import User
|
|
||||||
from app.rate_limit import auth_rate_limit
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
## Minimum age to register. This platform is adults-only.
|
|
||||||
MIN_AGE = 18
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
refresh_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
access_token_expires_minutes: int
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
|
||||||
refresh_token: str
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
# Real date of birth -- the server computes age from it and rejects
|
|
||||||
# anyone under MIN_AGE. Still self-attested (no document check yet), but
|
|
||||||
# a concrete, stored, legally-meaningful data point rather than a
|
|
||||||
# throwaway checkbox, and the seam for a future hard ID-verification
|
|
||||||
# pass. See docs/architecture/content_moderation.md 5.2.
|
|
||||||
date_of_birth: date
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
email: str
|
|
||||||
date_of_birth: date | None = None
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime | None = None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
|
||||||
|
|
||||||
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["jti"] = str(uuid.uuid4())
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
|
||||||
)
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
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:
|
|
||||||
"""Verify a plain password against a hash."""
|
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
|
||||||
"""Hash a password."""
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
|
||||||
"""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:
|
|
||||||
birth_date: The person's date of birth.
|
|
||||||
today: The reference date. Defaults to today's date (UTC).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Age in whole years as of the reference date.
|
|
||||||
"""
|
|
||||||
if today is None:
|
|
||||||
today = datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
age = today.year - birth_date.year
|
|
||||||
# Adjust if birthday hasn't occurred yet this year
|
|
||||||
if (today.month, today.day) < (birth_date.month, birth_date.day):
|
|
||||||
age -= 1
|
|
||||||
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 ---
|
|
||||||
|
|
||||||
@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)):
|
|
||||||
"""Register a new user and return access + refresh tokens.
|
|
||||||
|
|
||||||
Requires a date of birth; the server computes age and rejects anyone
|
|
||||||
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
|
|
||||||
verification design and its self-attestation limitation.
|
|
||||||
"""
|
|
||||||
age = calculate_age(req.date_of_birth)
|
|
||||||
if age < 0 or req.date_of_birth > date.today():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
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
|
|
||||||
result = await db.execute(select(User).where(User.username == req.username))
|
|
||||||
if result.scalar_one_or_none():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Username already registered",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if email already exists
|
|
||||||
result = await db.execute(select(User).where(User.email == req.email))
|
|
||||||
if result.scalar_one_or_none():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Email already registered",
|
|
||||||
)
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
username=req.username,
|
|
||||||
email=req.email,
|
|
||||||
hashed_password=hash_password(req.password),
|
|
||||||
date_of_birth=req.date_of_birth,
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
await db.flush() # flush to get user.id before creating refresh token
|
|
||||||
await db.refresh(user)
|
|
||||||
|
|
||||||
# Create access token
|
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
|
||||||
|
|
||||||
# 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, dependencies=[Depends(auth_rate_limit)])
|
|
||||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Authenticate user and return access + refresh tokens.
|
|
||||||
|
|
||||||
Accepts form-encoded credentials (username/password) as expected by
|
|
||||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
|
||||||
"""
|
|
||||||
result = await db.execute(select(User).where(User.username == form.username))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not user or not verify_password(form.password, user.hashed_password):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Incorrect username or password",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="User account is inactive",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create access token
|
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
async def get_current_user(current_user: CurrentUser):
|
|
||||||
"""Get current user profile from the JWT token."""
|
|
||||||
return current_user
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""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,106 +0,0 @@
|
|||||||
"""User management router."""
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel, EmailStr
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Pydantic schemas ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
email: str
|
|
||||||
is_active: bool
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
email: EmailStr | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
|
||||||
async def get_current_user_profile(
|
|
||||||
current_user: CurrentUser,
|
|
||||||
):
|
|
||||||
"""Get the currently authenticated user's profile."""
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserResponse)
|
|
||||||
async def get_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
):
|
|
||||||
"""Get a user by ID. Only the owner can view their own profile."""
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Only the owner may view their own profile.
|
|
||||||
if user.id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to view this user")
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}", response_model=UserResponse)
|
|
||||||
async def update_user(
|
|
||||||
user_id: int,
|
|
||||||
user_update: UserUpdate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
):
|
|
||||||
"""Update a user. Only the owner can update their own account."""
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Only the owner may update their own account.
|
|
||||||
if user.id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to update this user")
|
|
||||||
|
|
||||||
if user_update.email is not None:
|
|
||||||
user.email = user_update.email
|
|
||||||
if user_update.is_active is not None:
|
|
||||||
user.is_active = user_update.is_active
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
):
|
|
||||||
"""Delete a user. Only the owner can delete their own account."""
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Only the owner may delete their own account.
|
|
||||||
if user.id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to delete this user")
|
|
||||||
|
|
||||||
await db.delete(user)
|
|
||||||
await db.commit()
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
"""Worlds router - CRUD for game worlds."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.world import World, WorldVisibility
|
|
||||||
from app.services.game_servers import supervisor
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
|
||||||
|
|
||||||
class WorldCreate(BaseModel):
|
|
||||||
"""Schema for creating a world."""
|
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
|
||||||
|
|
||||||
|
|
||||||
class WorldUpdate(BaseModel):
|
|
||||||
"""Schema for updating a world."""
|
|
||||||
name: str | None = Field(None, min_length=1, max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
visibility: WorldVisibility | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorldResponse(BaseModel):
|
|
||||||
"""Schema for world responses."""
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
visibility: WorldVisibility
|
|
||||||
owner_id: int
|
|
||||||
created_at: datetime | None = None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorldResponse])
|
|
||||||
async def list_worlds(
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
|
||||||
visibility: WorldVisibility | None = None,
|
|
||||||
):
|
|
||||||
"""List worlds visible to the current user.
|
|
||||||
|
|
||||||
PUBLIC worlds are visible to everyone.
|
|
||||||
PRIVATE/FRIENDS_ONLY worlds are visible to their owner.
|
|
||||||
"""
|
|
||||||
query = select(World).options(selectinload(World.owner))
|
|
||||||
|
|
||||||
# Filter by visibility if specified
|
|
||||||
if visibility:
|
|
||||||
query = query.where(World.visibility == visibility)
|
|
||||||
if visibility != WorldVisibility.PUBLIC:
|
|
||||||
# Requesting a non-public visibility explicitly must not bypass
|
|
||||||
# ownership — otherwise any authenticated user could list every
|
|
||||||
# PRIVATE/FRIENDS_ONLY world in the system just by passing the
|
|
||||||
# filter, defeating the point of the check below.
|
|
||||||
query = query.where(World.owner_id == current_user.id)
|
|
||||||
else:
|
|
||||||
# Show PUBLIC worlds + owner's own worlds
|
|
||||||
query = query.where(
|
|
||||||
(World.visibility == WorldVisibility.PUBLIC)
|
|
||||||
| (World.owner_id == current_user.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
|
|
||||||
result = await db.execute(query)
|
|
||||||
worlds = result.scalars().all()
|
|
||||||
return worlds
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{world_id}", response_model=WorldResponse)
|
|
||||||
async def get_world(
|
|
||||||
world_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Get a single world by ID, respecting visibility."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(World).where(World.id == world_id).options(selectinload(World.owner))
|
|
||||||
)
|
|
||||||
world = result.scalar_one_or_none()
|
|
||||||
if not world:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
|
|
||||||
# Allow access if world is PUBLIC or owned by current user
|
|
||||||
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
|
|
||||||
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)
|
|
||||||
async def create_world(
|
|
||||||
world_data: WorldCreate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Create a new world."""
|
|
||||||
world = World(
|
|
||||||
name=world_data.name,
|
|
||||||
description=world_data.description,
|
|
||||||
owner_id=current_user.id,
|
|
||||||
visibility=world_data.visibility,
|
|
||||||
)
|
|
||||||
db.add(world)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(world)
|
|
||||||
return world
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{world_id}", response_model=WorldResponse)
|
|
||||||
async def update_world(
|
|
||||||
world_id: int,
|
|
||||||
world_data: WorldUpdate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Update an existing world."""
|
|
||||||
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.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
|
||||||
|
|
||||||
update_data = world_data.model_dump(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(world, field, value)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(world)
|
|
||||||
return world
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{world_id}", status_code=204)
|
|
||||||
async def delete_world(
|
|
||||||
world_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Delete a world."""
|
|
||||||
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.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
|
||||||
|
|
||||||
await db.delete(world)
|
|
||||||
await db.commit()
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""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,
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: agp_user
|
|
||||||
POSTGRES_PASSWORD: agp_password
|
|
||||||
POSTGRES_DB: agp_db
|
|
||||||
ports:
|
|
||||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U agp_user -d agp_db"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
ports:
|
|
||||||
- "${REDIS_HOST_PORT:-6379}:6379"
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
redis_data:
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
asyncio_mode = auto
|
|
||||||
testpaths = tests
|
|
||||||
@@ -3,20 +3,11 @@ uvicorn[standard]==0.34.0
|
|||||||
sqlalchemy==2.0.36
|
sqlalchemy==2.0.36
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
asyncpg==0.31.0
|
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
pydantic==2.10.3
|
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
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""Tests for the 18+ age gate on user registration."""
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.routers.auth import calculate_age, MIN_AGE, RegisterRequest
|
|
||||||
|
|
||||||
|
|
||||||
class TestCalculateAge:
|
|
||||||
"""Unit tests for the calculate_age helper."""
|
|
||||||
|
|
||||||
def test_exactly_18(self):
|
|
||||||
today = date(2026, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18)
|
|
||||||
assert calculate_age(dob, today=today) == 18
|
|
||||||
|
|
||||||
def test_just_turned_18(self):
|
|
||||||
today = date(2026, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
|
||||||
assert calculate_age(dob, today=today) == 18
|
|
||||||
|
|
||||||
def test_one_day_short_of_18(self):
|
|
||||||
today = date(2026, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
|
||||||
assert calculate_age(dob, today=today) == 17
|
|
||||||
|
|
||||||
def test_leap_day_birthday_before(self):
|
|
||||||
assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23
|
|
||||||
|
|
||||||
def test_leap_day_birthday_after(self):
|
|
||||||
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterRequestDob:
|
|
||||||
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)
|
|
||||||
|
|
||||||
def test_dob_required(self):
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
RegisterRequest(username="u", email="u@example.com", password="p")
|
|
||||||
|
|
||||||
def test_bad_dob_string_rejected(self):
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
RegisterRequest(username="u", email="u@example.com", password="p",
|
|
||||||
date_of_birth="not-a-date")
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterAgeGate:
|
|
||||||
"""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
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"""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]")
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
# Avatar Component System & glTF Attachment Spec
|
|
||||||
|
|
||||||
> **Status:** Proposed
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Related:** Server authority + client prediction, world chunk data format
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document specifies the **avatar component system** for **agp-game**, a
|
|
||||||
Godot-based multiplayer game. It defines:
|
|
||||||
|
|
||||||
- How an avatar is decomposed into **body parts** and **attachment slots**.
|
|
||||||
- How **cosmetic attachments** (clothing, accessories, gear) mount onto the
|
|
||||||
base body.
|
|
||||||
- The **glTF/GLB format requirements** that all avatar assets must satisfy so
|
|
||||||
they can be loaded, skinned, and networked consistently.
|
|
||||||
|
|
||||||
The goal is a **modular, data-driven avatar pipeline**: a single base body rig
|
|
||||||
plus swappable attachments, all authored in glTF and consumed by a shared Godot
|
|
||||||
runtime. This enables player customization, cosmetic monetization, and
|
|
||||||
server-authoritative validation of what a player is wearing.
|
|
||||||
|
|
||||||
### 1.1 Design Goals
|
|
||||||
|
|
||||||
- **Modularity:** Body parts and attachments are independent assets that can be
|
|
||||||
combined at runtime without re-exporting.
|
|
||||||
- **Consistency:** A single, well-defined skeleton and slot convention so any
|
|
||||||
attachment fits any base body.
|
|
||||||
- **Networkability:** The avatar's composition is expressed as a compact,
|
|
||||||
serializable **loadout** that the server can validate and replicate.
|
|
||||||
- **Performance:** Attachments are skinned to the same skeleton and share
|
|
||||||
materials where possible to minimize draw calls and skinning cost.
|
|
||||||
- **Tooling-friendly:** Assets are authored in standard glTF 2.0 and validated
|
|
||||||
by a build-time checker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Avatar Decomposition
|
|
||||||
|
|
||||||
### 2.1 The Base Body
|
|
||||||
|
|
||||||
Every avatar is built on a **base body** — a single glTF asset containing:
|
|
||||||
|
|
||||||
- A **skeleton** (the canonical rig, see §3).
|
|
||||||
- A **base mesh** for the skin/body (head, torso, arms, legs).
|
|
||||||
- **Attachment points** (empty nodes) that define where accessories mount.
|
|
||||||
|
|
||||||
The base body is the only asset that carries the skeleton. Attachments reference
|
|
||||||
the skeleton by **bone name** and are skinned to it; they never carry their own
|
|
||||||
rig.
|
|
||||||
|
|
||||||
### 2.2 Body Parts vs. Attachments
|
|
||||||
|
|
||||||
Two distinct concepts:
|
|
||||||
|
|
||||||
| Concept | Definition | Example |
|
|
||||||
|----------------|-------------------------------------------------------------------------|----------------------------------|
|
|
||||||
| **Body part** | A mesh that replaces part of the base body's skin (e.g., a different head, a tail, wings). | Alternate head, cybernetic arm |
|
|
||||||
| **Attachment** | A mesh that mounts *on top of* the body without replacing it (clothing, accessories). | Shirt, hat, glasses, backpack |
|
|
||||||
|
|
||||||
The distinction matters for **slot exclusivity** and **layering**:
|
|
||||||
|
|
||||||
- **Body parts** are *exclusive* per slot — only one head can be equipped.
|
|
||||||
- **Attachments** are *layered* — a hat and glasses can coexist, and both sit
|
|
||||||
above the head mesh.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Skeleton & Slot Convention
|
|
||||||
|
|
||||||
### 3.1 Canonical Skeleton
|
|
||||||
|
|
||||||
All avatars share a **canonical skeleton** with a fixed set of bone names. This
|
|
||||||
is the contract that lets any attachment fit any body. The skeleton follows a
|
|
||||||
humanoid convention (Godot's `Humanoid` profile / glTF VRM-style naming).
|
|
||||||
|
|
||||||
Required bones (subset of the full rig):
|
|
||||||
|
|
||||||
```
|
|
||||||
Root
|
|
||||||
├── Hips
|
|
||||||
│ ├── Spine
|
|
||||||
│ │ ├── Chest
|
|
||||||
│ │ │ ├── Neck
|
|
||||||
│ │ │ │ └── Head
|
|
||||||
│ │ │ │ ├── HeadTop # hat / hair mount
|
|
||||||
│ │ │ │ ├── Jaw # face accessories
|
|
||||||
│ │ │ │ └── LeftEye / RightEye
|
|
||||||
│ │ │ ├── LeftShoulder
|
|
||||||
│ │ │ │ └── LeftUpperArm
|
|
||||||
│ │ │ │ └── LeftLowerArm
|
|
||||||
│ │ │ │ └── LeftHand
|
|
||||||
│ │ │ └── RightShoulder
|
|
||||||
│ │ │ └── RightUpperArm
|
|
||||||
│ │ │ └── RightLowerArm
|
|
||||||
│ │ │ └── RightHand
|
|
||||||
│ │ └── (Chest children for torso attachments)
|
|
||||||
│ ├── LeftUpperLeg
|
|
||||||
│ │ └── LeftLowerLeg
|
|
||||||
│ │ └── LeftFoot
|
|
||||||
│ └── RightUpperLeg
|
|
||||||
│ └── RightLowerLeg
|
|
||||||
│ └── RightFoot
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
|
|
||||||
- Bone names are **case-sensitive** and must match exactly.
|
|
||||||
- The skeleton is **T-pose** at rest for consistent skinning.
|
|
||||||
- Attachments must be skinned to **at least** the bones they deform; static
|
|
||||||
accessories may be parented to a single bone (e.g., a hat parented to `Head`).
|
|
||||||
|
|
||||||
### 3.2 Attachment Slots
|
|
||||||
|
|
||||||
Slots are the **logical mount points** for body parts and attachments. Each slot
|
|
||||||
maps to one or more bones and defines what can be equipped there.
|
|
||||||
|
|
||||||
| Slot ID | Category | Mount bone(s) | Exclusivity | Example assets |
|
|
||||||
|----------------|--------------|--------------------|-------------|-------------------------------|
|
|
||||||
| `head` | body part | `Head` | exclusive | alternate head, face |
|
|
||||||
| `hair` | attachment | `HeadTop` | exclusive | hairstyles, hats |
|
|
||||||
| `face` | attachment | `Jaw` | exclusive | glasses, masks, beards |
|
|
||||||
| `torso` | body part | `Chest` | exclusive | alternate torso |
|
|
||||||
| `chest` | attachment | `Chest` | exclusive | shirts, armor, chest gear |
|
|
||||||
| `back` | attachment | `Chest` (back) | exclusive | backpacks, wings, capes |
|
|
||||||
| `left_arm` | body part | `LeftUpperArm` | exclusive | alternate arm |
|
|
||||||
| `right_arm` | body part | `RightUpperArm` | exclusive | alternate arm |
|
|
||||||
| `left_hand` | attachment | `LeftHand` | exclusive | gloves, weapons (held) |
|
|
||||||
| `right_hand` | attachment | `RightHand` | exclusive | gloves, weapons (held) |
|
|
||||||
| `legs` | body part | `Hips` | exclusive | alternate legs |
|
|
||||||
| `pants` | attachment | `Hips` | exclusive | pants, skirts |
|
|
||||||
| `left_foot` | attachment | `LeftFoot` | exclusive | shoes |
|
|
||||||
| `right_foot` | attachment | `RightFoot` | exclusive | shoes |
|
|
||||||
| `accessory` | attachment | `Root` (free) | multiple | belts, jewelry, floating items|
|
|
||||||
|
|
||||||
**Slot semantics:**
|
|
||||||
|
|
||||||
- **`exclusive`** slots allow at most one equipped item at a time.
|
|
||||||
- The **`accessory`** slot is *multi-slot* — multiple accessories may be equipped
|
|
||||||
simultaneously (bounded by a configurable limit, e.g., 8).
|
|
||||||
- **Body-part slots** (`head`, `torso`, `legs`, `left_arm`, `right_arm`) replace
|
|
||||||
the corresponding base mesh. When a body part is equipped, the base mesh for
|
|
||||||
that region is hidden.
|
|
||||||
- **Attachment slots** layer on top of the base mesh and never hide it.
|
|
||||||
|
|
||||||
### 3.3 Slot → Bone Mapping
|
|
||||||
|
|
||||||
Each slot declares a **primary mount bone** and an optional **secondary bone
|
|
||||||
list** for skinning. The runtime uses this to:
|
|
||||||
|
|
||||||
1. Parent static accessories to the mount bone.
|
|
||||||
2. Skin skinned attachments to the union of the slot's bones.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. glTF/GLB Format Requirements
|
|
||||||
|
|
||||||
All avatar assets (base bodies, body parts, attachments) are distributed as
|
|
||||||
**glTF 2.0** (`.gltf` + external buffers/textures) or **GLB** (`.glb`, binary
|
|
||||||
container). GLB is the preferred distribution format for runtime loading.
|
|
||||||
|
|
||||||
### 4.1 Required Conventions
|
|
||||||
|
|
||||||
| Concern | Requirement |
|
|
||||||
|--------------------|------------------------------------------------------------------------------|
|
|
||||||
| **Format** | glTF 2.0 (`.gltf` or `.glb`). GLB preferred for runtime. |
|
|
||||||
| **Coordinate system** | glTF standard: **+Y up, +Z forward, right-handed**. Godot converts to its own convention on import. |
|
|
||||||
| **Units** | Meters. Avatar height ~1.8 m at the `Root` bone. |
|
|
||||||
| **Skeleton** | Single skeleton per asset; bone names per §3.1. |
|
|
||||||
| **Skinning** | `skins` + `nodes` with `JOINTS_0` / `WEIGHTS_0` accessors. |
|
|
||||||
| **Pose** | T-pose at rest. |
|
|
||||||
| **Materials** | PBR metallic-roughness. Use `KHR_materials_unlit` for emissive/UI-only items. |
|
|
||||||
| **Textures** | Max 2048×2048; power-of-two preferred. Use `KHR_texture_basisu` (BasisU) for runtime. |
|
|
||||||
| **Animation** | Optional. If present, must not rename or remove canonical bones. |
|
|
||||||
|
|
||||||
### 4.2 Asset Metadata (extras)
|
|
||||||
|
|
||||||
Each asset **must** declare its slot and identity in the glTF `asset.extras`
|
|
||||||
object. This is the machine-readable contract consumed by the build pipeline and
|
|
||||||
runtime.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"asset": {
|
|
||||||
"version": "2.0",
|
|
||||||
"extras": {
|
|
||||||
"agp": {
|
|
||||||
"type": "attachment", // "base_body" | "body_part" | "attachment"
|
|
||||||
"slot": "chest", // slot ID from §3.2
|
|
||||||
"id": "shirt_bandit_01", // globally unique asset id
|
|
||||||
"display_name": "Bandit Shirt",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"mount_bone": "Chest", // primary mount bone
|
|
||||||
"skin_bones": ["Chest", "Spine", "LeftUpperArm", "RightUpperArm"],
|
|
||||||
"exclusive": true, // matches slot exclusivity
|
|
||||||
"tags": ["cloth", "common"],
|
|
||||||
"author": "platform-team"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Validation rules for `extras.agp`:**
|
|
||||||
|
|
||||||
- `type` must be one of `base_body`, `body_part`, `attachment`.
|
|
||||||
- `slot` must be a known slot ID (§3.2).
|
|
||||||
- `mount_bone` must exist in the canonical skeleton.
|
|
||||||
- `skin_bones` must all exist in the canonical skeleton.
|
|
||||||
- `exclusive` must match the slot's declared exclusivity.
|
|
||||||
- `id` must be unique across the asset catalog.
|
|
||||||
|
|
||||||
### 4.3 Node Naming Convention
|
|
||||||
|
|
||||||
To make assets self-describing and mergeable, nodes follow a convention:
|
|
||||||
|
|
||||||
- **Base body:** the mesh node is named `Body`; the skeleton root is `Root`.
|
|
||||||
- **Body part:** the mesh node is named `<SlotID>_Mesh` (e.g., `head_Mesh`).
|
|
||||||
- **Attachment:** the mesh node is named `<SlotID>_<AssetID>` (e.g.,
|
|
||||||
`chest_shirt_bandit_01`).
|
|
||||||
- **Attachment points** (empty nodes) on the base body are named
|
|
||||||
`attach_<slot>` (e.g., `attach_head`, `attach_left_hand`).
|
|
||||||
|
|
||||||
### 4.4 Material & Texture Requirements
|
|
||||||
|
|
||||||
- All materials use the **metallic-roughness** PBR model.
|
|
||||||
- Textures are embedded in the GLB or referenced as external files; external
|
|
||||||
references must be relative paths within the asset package.
|
|
||||||
- **Color customization** is supported via a `baseColorFactor` tint applied at
|
|
||||||
runtime (multiplied over the albedo). Assets should use a neutral albedo
|
|
||||||
(white/gray) where tinting is intended.
|
|
||||||
- **Masked/transparent** materials must set the correct alpha mode
|
|
||||||
(`MASK` or `BLEND`) and `alphaCutoff`.
|
|
||||||
|
|
||||||
### 4.5 Skeleton Merging at Runtime
|
|
||||||
|
|
||||||
When an attachment is loaded, the runtime **merges** it onto the base body:
|
|
||||||
|
|
||||||
1. Load the base body (skeleton + base meshes).
|
|
||||||
2. Load each equipped attachment.
|
|
||||||
3. For each attachment, **re-target its skin** to the base body's skeleton by
|
|
||||||
bone name (no re-export needed).
|
|
||||||
4. Parent static accessories to their `mount_bone`.
|
|
||||||
5. Hide base meshes for any equipped body-part slots.
|
|
||||||
6. Apply material tints and merge into the render tree.
|
|
||||||
|
|
||||||
The merge is **name-based** — bone names must match exactly (§3.1). This is the
|
|
||||||
core reason for the strict naming contract.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Loadout Model (Network & Persistence)
|
|
||||||
|
|
||||||
The avatar's composition is serialized as a **loadout** — a compact, ordered
|
|
||||||
list of equipped asset IDs. This is what the server validates, persists, and
|
|
||||||
replicates.
|
|
||||||
|
|
||||||
### 5.1 Loadout JSON
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"base_body": "base_human_male_01",
|
|
||||||
"slots": {
|
|
||||||
"head": "head_cyber_01",
|
|
||||||
"hair": "hair_mohawk_01",
|
|
||||||
"face": null,
|
|
||||||
"torso": null,
|
|
||||||
"chest": "chest_bandit_01",
|
|
||||||
"back": "backpack_01",
|
|
||||||
"left_arm": null,
|
|
||||||
"right_arm": null,
|
|
||||||
"left_hand": "glove_leather_01",
|
|
||||||
"right_hand": "glove_leather_01",
|
|
||||||
"legs": null,
|
|
||||||
"pants": "pants_cargo_01",
|
|
||||||
"left_foot": "shoe_boot_01",
|
|
||||||
"right_foot": "shoe_boot_01",
|
|
||||||
"accessory": ["belt_01", "necklace_01"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
|
|
||||||
- `base_body` is required and must reference a valid `base_body` asset.
|
|
||||||
- Each exclusive slot holds **at most one** asset id (or `null`).
|
|
||||||
- `accessory` is an array (0..N, bounded).
|
|
||||||
- The server validates that every id exists in the catalog and that slot
|
|
||||||
exclusivity is respected.
|
|
||||||
|
|
||||||
### 5.2 Network Replication
|
|
||||||
|
|
||||||
The loadout is replicated as part of the player's spawn/state data:
|
|
||||||
|
|
||||||
```
|
|
||||||
spawn_entity:
|
|
||||||
entity_id: uint32
|
|
||||||
type: player
|
|
||||||
transform: { x, y, rot }
|
|
||||||
props:
|
|
||||||
loadout_hash: uint32 # hash of the loadout for change detection
|
|
||||||
loadout: { ... } # full loadout on spawn / on change
|
|
||||||
```
|
|
||||||
|
|
||||||
- The **full loadout** is sent on spawn and whenever it changes (reliable).
|
|
||||||
- A **`loadout_hash`** lets clients skip re-resolving assets if unchanged.
|
|
||||||
- The server is the **authority** on the loadout; clients send *requests* to
|
|
||||||
change equipment, and the server validates and broadcasts the result.
|
|
||||||
|
|
||||||
### 5.3 Server-Side Validation
|
|
||||||
|
|
||||||
The server rejects a loadout if:
|
|
||||||
|
|
||||||
- `base_body` is missing or unknown.
|
|
||||||
- Any slot references an unknown asset id.
|
|
||||||
- An exclusive slot has more than one item.
|
|
||||||
- An asset's declared `slot` does not match the slot it is placed in.
|
|
||||||
- The accessory list exceeds the configured limit.
|
|
||||||
- The asset is not owned/unlocked by the player (for gated cosmetics).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Godot Runtime Architecture
|
|
||||||
|
|
||||||
### 6.1 Node Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Avatar (Node3D)
|
|
||||||
├── Skeleton3D # canonical skeleton (from base body)
|
|
||||||
├── BodyMeshes # base body meshes (skinned to Skeleton3D)
|
|
||||||
│ ├── head_Mesh
|
|
||||||
│ ├── torso_Mesh
|
|
||||||
│ └── ...
|
|
||||||
├── Attachments # equipped attachments
|
|
||||||
│ ├── chest_shirt_bandit_01
|
|
||||||
│ ├── hair_mohawk_01
|
|
||||||
│ └── ...
|
|
||||||
└── AttachmentPoints # empty nodes for dynamic mounting
|
|
||||||
├── attach_head
|
|
||||||
├── attach_left_hand
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Core Classes
|
|
||||||
|
|
||||||
| Class | Responsibility |
|
|
||||||
|--------------------------|-------------------------------------------------------------|
|
|
||||||
| `AvatarSystem` | Loads base body + attachments, resolves loadouts, owns the avatar node. |
|
|
||||||
| `AvatarLoader` | glTF/GLB import, skeleton merge, skin re-targeting. |
|
|
||||||
| `AvatarSlotRegistry` | Static registry of slot definitions (id, bones, exclusivity).|
|
|
||||||
| `AvatarLoadout` | Serializable loadout model + validation. |
|
|
||||||
| `AvatarCatalog` | Asset catalog (id → asset metadata + resource path). |
|
|
||||||
| `AvatarVisualizer` | Builds the render tree from a loadout. |
|
|
||||||
|
|
||||||
### 6.3 Loading Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
loadout JSON
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
AvatarCatalog.resolve(loadout) # id → asset metadata + GLB path
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
AvatarLoader.load_base_body() # import GLB, extract skeleton + base meshes
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
for each equipped slot:
|
|
||||||
AvatarLoader.load_attachment()# import GLB, re-target skin to skeleton
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
AvatarVisualizer.assemble() # parent, hide replaced parts, apply tints
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Avatar node ready for render
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 Performance Considerations
|
|
||||||
|
|
||||||
- **Shared skeleton:** All meshes skin to one `Skeleton3D`, so skinning is
|
|
||||||
computed once per frame.
|
|
||||||
- **Material batching:** Attachments that share a material are merged into a
|
|
||||||
single mesh where possible (static accessories).
|
|
||||||
- **LOD:** Optional LOD variants per asset; the runtime selects based on
|
|
||||||
distance.
|
|
||||||
- **Caching:** Resolved loadouts and imported GLB resources are cached by
|
|
||||||
`AvatarCatalog` to avoid re-importing on respawn or re-join.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Asset Pipeline & Validation
|
|
||||||
|
|
||||||
### 7.1 Build-Time Validation
|
|
||||||
|
|
||||||
A build-time checker validates every asset before it enters the catalog:
|
|
||||||
|
|
||||||
1. **Format check:** valid glTF 2.0 / GLB.
|
|
||||||
2. **Metadata check:** `extras.agp` present and valid (§4.2).
|
|
||||||
3. **Skeleton check:** all `skin_bones` exist in the canonical skeleton.
|
|
||||||
4. **Slot check:** `slot` is known and `exclusive` matches.
|
|
||||||
5. **Mesh check:** meshes are skinned (or static accessories parented to a bone).
|
|
||||||
6. **Texture check:** sizes, formats, and alpha modes are valid.
|
|
||||||
7. **Uniqueness check:** `id` is unique.
|
|
||||||
|
|
||||||
### 7.2 Catalog Manifest
|
|
||||||
|
|
||||||
The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"assets": {
|
|
||||||
"base_human_male_01": {
|
|
||||||
"type": "base_body",
|
|
||||||
"path": "res://assets/avatars/base_human_male_01.glb",
|
|
||||||
"version": "1.0.0"
|
|
||||||
},
|
|
||||||
"chest_bandit_01": {
|
|
||||||
"type": "attachment",
|
|
||||||
"slot": "chest",
|
|
||||||
"path": "res://assets/avatars/chest_bandit_01.glb",
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Security & Anti-Cheat
|
|
||||||
|
|
||||||
- **Server-authoritative loadouts:** Clients only *request* equipment changes;
|
|
||||||
the server validates against the catalog and the player's owned items.
|
|
||||||
- **No client asset injection:** Clients cannot send arbitrary GLB data; they
|
|
||||||
reference catalog ids only.
|
|
||||||
- **Rate limiting:** Equipment-change requests are rate-limited per peer.
|
|
||||||
- **Hash verification:** The `loadout_hash` lets the server detect tampered or
|
|
||||||
stale loadout state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Open Questions / Future Work
|
|
||||||
|
|
||||||
- **Morph targets / blendshapes:** Support for facial expressions and body
|
|
||||||
morphs (e.g., height, muscle) via glTF morph targets.
|
|
||||||
- **Physics attachments:** Cloth simulation for capes/skirts (Godot cloth).
|
|
||||||
- **Dynamic tinting UI:** A color-picker that writes `baseColorFactor` tints.
|
|
||||||
- **VRM compatibility:** Consider supporting VRM (a glTF extension for
|
|
||||||
humanoid avatars) as an import source.
|
|
||||||
- **Attachment LOD streaming:** Streaming high-res attachments on demand.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
| Concern | Decision |
|
|
||||||
|------------------------|-------------------------------------------------------|
|
|
||||||
| Format | glTF 2.0 / GLB (GLB preferred for runtime) |
|
|
||||||
| Skeleton | Single canonical humanoid skeleton, name-based merge |
|
|
||||||
| Slots | Fixed slot registry; exclusive body parts + layered attachments |
|
|
||||||
| Loadout | Compact JSON; server-authoritative and validated |
|
|
||||||
| Replication | Full loadout on spawn/change + `loadout_hash` |
|
|
||||||
| Runtime | Godot `Skeleton3D` + name-based skin re-targeting |
|
|
||||||
| Validation | Build-time asset checker + runtime catalog validation |
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
# Content Moderation & Age Verification Architecture
|
|
||||||
|
|
||||||
> **Status:** Proposed
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Related:** Server authority + client prediction, avatar component system, backend auth (JWT)
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document specifies the **content moderation** and **age verification**
|
|
||||||
architecture for **agp-game**, a Godot-based multiplayer game built by
|
|
||||||
autonomous agents. It defines:
|
|
||||||
|
|
||||||
- How **content flags** propagate through the system across the three primary
|
|
||||||
content types: **assets**, **rooms**, and **scripts**.
|
|
||||||
- How flags are **evaluated, aggregated, and enforced** at load time, at
|
|
||||||
runtime, and at the network boundary.
|
|
||||||
- The **age verification flow**, combining a lightweight **self-declaration**
|
|
||||||
with an optional **ID check hook** for higher-assurance verification.
|
|
||||||
|
|
||||||
The goal is a **single, consistent moderation pipeline** that applies the same
|
|
||||||
rules to user-generated content (UGC) regardless of whether it is a 3D asset, a
|
|
||||||
room definition, or a script, and that gates access to mature content behind a
|
|
||||||
verifiable age gate.
|
|
||||||
|
|
||||||
### 1.1 Design Goals
|
|
||||||
|
|
||||||
- **Uniformity:** One flag vocabulary and one propagation mechanism across
|
|
||||||
assets, rooms, and scripts.
|
|
||||||
- **Composability:** Flags on a room aggregate the flags of the assets and
|
|
||||||
scripts it contains; flags on a world aggregate the flags of its rooms.
|
|
||||||
- **Enforcement at the boundary:** The server is authoritative for what content
|
|
||||||
a client may load or receive, mirroring the server-authoritative networking
|
|
||||||
model.
|
|
||||||
- **Privacy-preserving age checks:** Self-declaration requires no PII; the ID
|
|
||||||
check hook is optional, pluggable, and never stores raw identity documents.
|
|
||||||
- **Auditability:** Every flag decision is logged with a reason, an actor, and
|
|
||||||
a timestamp so moderation actions are reviewable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Content Flag Vocabulary
|
|
||||||
|
|
||||||
All content carries a set of **content flags**. A flag is a typed, immutable
|
|
||||||
label with a **severity** and a **scope**. Flags are the atomic unit of
|
|
||||||
moderation; everything else (aggregation, enforcement, gating) operates on
|
|
||||||
flags.
|
|
||||||
|
|
||||||
### 2.1 Flag Definition
|
|
||||||
|
|
||||||
A flag is a structured record:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|------------|-----------|--------------------------------------------------------------------------|
|
|
||||||
| `id` | `string` | Stable machine-readable identifier, e.g. `violence.gore`. |
|
|
||||||
| `label` | `string` | Human-readable name, e.g. "Graphic Gore". |
|
|
||||||
| `category` | `enum` | One of `violence`, `sexual`, `language`, `substance`, `gambling`, `horror`, `hate`, `custom`. |
|
|
||||||
| `severity` | `enum` | One of `none`, `mild`, `moderate`, `severe`. |
|
|
||||||
| `scope` | `enum` | One of `visual`, `audio`, `text`, `behavior`, `interaction`. |
|
|
||||||
| `mature` | `bool` | Whether this flag triggers the age gate (see §5). |
|
|
||||||
| `reason` | `string` | Free-text justification supplied by the flagger. |
|
|
||||||
| `source` | `enum` | One of `author`, `moderator`, `automated`, `community`. |
|
|
||||||
|
|
||||||
### 2.2 Canonical Flag Set
|
|
||||||
|
|
||||||
The platform defines a **canonical registry** of flags so that different
|
|
||||||
content types and different flaggers use the same vocabulary. Examples:
|
|
||||||
|
|
||||||
| id | category | severity | scope | mature |
|
|
||||||
|-----------------------------|------------|-----------|--------------|--------|
|
|
||||||
| `violence.none` | violence | none | visual | false |
|
|
||||||
| `violence.mild` | violence | mild | visual | false |
|
|
||||||
| `violence.gore` | violence | severe | visual | true |
|
|
||||||
| `sexual.suggestive` | sexual | mild | visual | true |
|
|
||||||
| `sexual.explicit` | sexual | severe | visual | true |
|
|
||||||
| `language.mild` | language | mild | text | false |
|
|
||||||
| `language.profanity` | language | moderate | text | false |
|
|
||||||
| `substance.references` | substance | mild | text | false |
|
|
||||||
| `gambling.simulated` | gambling | moderate | behavior | true |
|
|
||||||
| `horror.jumpscare` | horror | mild | audio/visual | false |
|
|
||||||
| `hate.hateful` | hate | severe | text | true |
|
|
||||||
|
|
||||||
The registry is **data-driven** (a JSON/YAML manifest shipped with the server)
|
|
||||||
so new flags can be added without code changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Flag Propagation Model
|
|
||||||
|
|
||||||
Flags propagate through the system along the **content dependency graph**. The
|
|
||||||
three content types form a hierarchy:
|
|
||||||
|
|
||||||
```
|
|
||||||
World
|
|
||||||
└── Room
|
|
||||||
├── Asset (mesh, texture, audio, animation, material)
|
|
||||||
└── Script (behavior, dialogue, event logic)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.1 Propagation Rules
|
|
||||||
|
|
||||||
1. **Explicit flags** are set directly on a content item by its author or a
|
|
||||||
moderator.
|
|
||||||
2. **Inherited flags** flow upward: a room's effective flag set is the **union**
|
|
||||||
of its own explicit flags and the flags of every asset and script it
|
|
||||||
references.
|
|
||||||
3. **Aggregation** uses the **maximum severity** per category across all
|
|
||||||
contributing items. A room containing one `violence.gore` asset and one
|
|
||||||
`violence.none` asset is `violence.severe`.
|
|
||||||
4. **Maturity is monotonic:** if any contributing item is `mature`, the
|
|
||||||
containing room and world are `mature`.
|
|
||||||
5. **Cycles are impossible** by construction: content references form a DAG
|
|
||||||
(an asset cannot contain a room; a room cannot contain itself).
|
|
||||||
|
|
||||||
### 3.2 Propagation Through the Pipeline
|
|
||||||
|
|
||||||
Flags are attached at **authoring time** and carried through every stage:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authoring ──► Upload ──► Validation ──► Storage ──► Distribution ──► Runtime
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ flag │ flag │ automated │ flag │ flag │ flag
|
|
||||||
│ declared │ preserved │ scan adds │ indexed │ re-checked │ enforced
|
|
||||||
│ │ │ flags │ │ │
|
|
||||||
```
|
|
||||||
|
|
||||||
| Stage | Responsibility |
|
|
||||||
|--------------|-------------------------------------------------------------------------|
|
|
||||||
| **Authoring**| Author declares flags in the editor/upload UI. |
|
|
||||||
| **Upload** | Flags are validated against the canonical registry; unknown flags rejected. |
|
|
||||||
| **Validation**| Automated scanner (hash-based + heuristic) adds flags for detected content. |
|
|
||||||
| **Storage** | Flags are stored alongside the content record and indexed for queries. |
|
|
||||||
| **Distribution**| Flags are re-checked against the current registry before serving. |
|
|
||||||
| **Runtime** | The client enforces flags before loading content (see §4). |
|
|
||||||
|
|
||||||
### 3.3 Data Model
|
|
||||||
|
|
||||||
Flags are stored as a **normalized join table** so they can be queried and
|
|
||||||
aggregated efficiently:
|
|
||||||
|
|
||||||
```
|
|
||||||
content_item (id, type, owner_id, ...)
|
|
||||||
content_flag (content_id, flag_id, source, reason, created_at, actor_id)
|
|
||||||
flag_registry (id, category, severity, scope, mature, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Aggregation is computed by a SQL view / query that joins a content item's
|
|
||||||
transitive dependencies. Because the graph is a DAG, this is a bounded
|
|
||||||
recursive query (PostgreSQL `WITH RECURSIVE`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Enforcement
|
|
||||||
|
|
||||||
Enforcement happens at three layers, in order:
|
|
||||||
|
|
||||||
### 4.1 Load-Time Enforcement (Server)
|
|
||||||
|
|
||||||
Before a client is allowed to **request** content, the server evaluates the
|
|
||||||
content's effective flag set against the requesting account's **content
|
|
||||||
preferences** and **age verification status**:
|
|
||||||
|
|
||||||
- If the content is `mature` and the account is **not age-verified**, the
|
|
||||||
request is **rejected** with a `403 MATURE_CONTENT_REQUIRES_VERIFICATION`.
|
|
||||||
- If the content's flags exceed the account's **content filter** (a per-account
|
|
||||||
severity threshold per category), the request is rejected or the offending
|
|
||||||
content is **stripped** (e.g., audio muted, mesh hidden, script not loaded).
|
|
||||||
|
|
||||||
### 4.2 Runtime Enforcement (Client)
|
|
||||||
|
|
||||||
The client receives a **manifest** of allowed content from the server. It never
|
|
||||||
loads content that is not in the manifest. This prevents a malicious or buggy
|
|
||||||
client from bypassing the server's filter by loading content directly.
|
|
||||||
|
|
||||||
- Assets not in the manifest are **not instantiated**.
|
|
||||||
- Scripts not in the manifest are **not executed**.
|
|
||||||
- Rooms not in the manifest are **not entered**.
|
|
||||||
|
|
||||||
### 4.3 Network Boundary Enforcement
|
|
||||||
|
|
||||||
The server **filters replication** based on flags:
|
|
||||||
|
|
||||||
- Mature or filtered content is **not replicated** to clients that cannot
|
|
||||||
receive it.
|
|
||||||
- A room's flag set is broadcast in the room metadata so clients can show a
|
|
||||||
warning before entry.
|
|
||||||
|
|
||||||
### 4.4 Enforcement Decision Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Client requests content C for account A
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Compute effective flags F(C) [union + max-severity aggregation]
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Is A age-verified? ──no──► Is F(C).mature? ──yes──► REJECT (403)
|
|
||||||
│ yes │ no
|
|
||||||
▼ ▼
|
|
||||||
Does F(C) exceed A's filter? ──► yes ──► REJECT or STRIP
|
|
||||||
│ no
|
|
||||||
▼
|
|
||||||
Serve content + manifest
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Age Verification Flow
|
|
||||||
|
|
||||||
Age verification is a **two-tier** system:
|
|
||||||
|
|
||||||
- **Tier 1 — Self-declaration:** The user asserts their date of birth. This is
|
|
||||||
fast, requires no PII beyond a birthdate, and is sufficient for most content.
|
|
||||||
- **Tier 2 — ID check (optional hook):** A pluggable identity-verification
|
|
||||||
provider confirms the user is an adult. This raises the account's assurance
|
|
||||||
level and unlocks the highest maturity tier.
|
|
||||||
|
|
||||||
### 5.1 Verification States
|
|
||||||
|
|
||||||
An account has an **age verification status**:
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
|-------------------|----------------------------------------------------------------------|
|
|
||||||
| `unverified` | No declaration on file. |
|
|
||||||
| `self_declared` | User declared a birthdate; **not** independently confirmed. |
|
|
||||||
| `id_verified` | User passed the ID check hook; independently confirmed adult. |
|
|
||||||
| `rejected` | ID check failed or declaration was inconsistent. |
|
|
||||||
|
|
||||||
### 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 }
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Server validates format & plausibility (real date, not in future)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Compute age from DOB
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
age >= 18 ──► status = self_declared (adult) ← the current gate
|
|
||||||
age >= 13 ──► status = self_declared (minor, restricted) ← design only
|
|
||||||
age < 13 ──► status = rejected (below minimum platform age)
|
|
||||||
```
|
|
||||||
|
|
||||||
- The DOB is stored **encrypted at rest** and is **never exposed** to other
|
|
||||||
players.
|
|
||||||
- A user may **upgrade** from `self_declared` to `id_verified` at any time via
|
|
||||||
Tier 2.
|
|
||||||
|
|
||||||
### 5.3 Tier 2 — ID Check Hook
|
|
||||||
|
|
||||||
The ID check is an **optional, pluggable hook**. The platform defines an
|
|
||||||
interface; concrete providers (e.g., a third-party KYC service) implement it.
|
|
||||||
|
|
||||||
```
|
|
||||||
User requests ID verification
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Server creates a verification session (idempotent, expiring)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Server calls provider hook: verify(identity_token) -> { adult: bool, ref: string }
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
adult == true ──► status = id_verified, store provider ref (not raw doc)
|
|
||||||
adult == false ─► status = rejected
|
|
||||||
provider error ──► status unchanged, retry allowed
|
|
||||||
```
|
|
||||||
|
|
||||||
**Privacy guarantees:**
|
|
||||||
|
|
||||||
- The platform **never stores** the raw identity document or image.
|
|
||||||
- Only the provider's **opaque reference** and the boolean `adult` result are
|
|
||||||
persisted.
|
|
||||||
- The hook is **asynchronous and idempotent**; a session can be retried without
|
|
||||||
double-charging or double-verifying.
|
|
||||||
|
|
||||||
### 5.4 Hook Interface
|
|
||||||
|
|
||||||
The ID check hook is defined as a backend interface (FastAPI dependency /
|
|
||||||
abstract service):
|
|
||||||
|
|
||||||
```python
|
|
||||||
class IdentityVerificationProvider(Protocol):
|
|
||||||
def create_session(self, user_id: str) -> VerificationSession: ...
|
|
||||||
def verify(self, session_id: str, identity_token: str) -> VerificationResult: ...
|
|
||||||
|
|
||||||
class VerificationResult:
|
|
||||||
adult: bool
|
|
||||||
provider_ref: str | None
|
|
||||||
error: str | None
|
|
||||||
```
|
|
||||||
|
|
||||||
A default **no-op provider** returns `adult=False` with a clear error, so the
|
|
||||||
system runs without any third-party integration until one is configured.
|
|
||||||
|
|
||||||
### 5.5 Age Gate Enforcement
|
|
||||||
|
|
||||||
The age gate is enforced at **load time** (see §4.1):
|
|
||||||
|
|
||||||
| Verification status | Can access `mature` content? |
|
|
||||||
|---------------------|------------------------------|
|
|
||||||
| `unverified` | No |
|
|
||||||
| `self_declared` | Yes (adult) / No (minor) |
|
|
||||||
| `id_verified` | Yes |
|
|
||||||
| `rejected` | No |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Moderation Workflow
|
|
||||||
|
|
||||||
### 6.1 Flagging Sources
|
|
||||||
|
|
||||||
Content can be flagged by:
|
|
||||||
|
|
||||||
- **Author** (self-declared at upload).
|
|
||||||
- **Automated scanner** (hash-based detection, heuristic analysis).
|
|
||||||
- **Community** (players report content; reports become flags after review).
|
|
||||||
- **Moderator** (manual review and override).
|
|
||||||
|
|
||||||
### 6.2 Review Queue
|
|
||||||
|
|
||||||
Community and automated flags enter a **review queue**. A moderator either:
|
|
||||||
|
|
||||||
- **Approves** the flag (it becomes authoritative), or
|
|
||||||
- **Rejects** the flag (it is discarded), or
|
|
||||||
- **Escalates** to a senior moderator for severe/hate flags.
|
|
||||||
|
|
||||||
### 6.3 Actions
|
|
||||||
|
|
||||||
Once a flag is authoritative, a moderator can take actions:
|
|
||||||
|
|
||||||
| Action | Effect |
|
|
||||||
|-----------------|---------------------------------------------------------------|
|
|
||||||
| `warn` | Notify the author; content remains. |
|
|
||||||
| `restrict` | Content hidden from users below a filter threshold. |
|
|
||||||
| `age_gate` | Content marked `mature`; requires age verification. |
|
|
||||||
| `remove` | Content taken down; not served. |
|
|
||||||
| `ban_author` | Author's upload privileges suspended. |
|
|
||||||
|
|
||||||
### 6.4 Audit Log
|
|
||||||
|
|
||||||
Every flag and action is written to an **audit log**:
|
|
||||||
|
|
||||||
```
|
|
||||||
audit_event (id, content_id, actor_id, action, flag_id, reason, created_at)
|
|
||||||
```
|
|
||||||
|
|
||||||
This log is append-only and used for transparency and dispute resolution.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Client-Side Integration (Godot)
|
|
||||||
|
|
||||||
### 7.1 Content Manifest
|
|
||||||
|
|
||||||
The server sends each client a **content manifest** — the set of content the
|
|
||||||
client is permitted to load, derived from the account's verification status and
|
|
||||||
filter. The Godot client:
|
|
||||||
|
|
||||||
- Loads only manifest-listed assets.
|
|
||||||
- Executes only manifest-listed scripts.
|
|
||||||
- Shows a **maturity warning** dialog before entering a room whose effective
|
|
||||||
flags include `mature` content.
|
|
||||||
|
|
||||||
### 7.2 Flag Metadata on Content
|
|
||||||
|
|
||||||
Each content resource carries a `content_flags` metadata field (a Godot
|
|
||||||
`Dictionary` or custom `Resource`). The client uses this for local UI decisions
|
|
||||||
(e.g., showing a warning icon) but **never** as the authority for whether to
|
|
||||||
load — the server manifest is authoritative.
|
|
||||||
|
|
||||||
### 7.3 Age Verification UI
|
|
||||||
|
|
||||||
The client exposes an **age verification screen**:
|
|
||||||
|
|
||||||
- Tier 1: a date-of-birth picker.
|
|
||||||
- Tier 2: a button that opens the ID check provider flow (deep link / webview).
|
|
||||||
|
|
||||||
The client calls the backend endpoints (§8) and reflects the resulting status.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Backend API Surface
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|-----------------------------------|------------------------------------------------|
|
|
||||||
| `POST` | `/v1/age/declare` | Submit self-declared DOB (Tier 1). |
|
|
||||||
| `POST` | `/v1/age/verify/session` | Create an ID-check session (Tier 2). |
|
|
||||||
| `POST` | `/v1/age/verify/confirm` | Confirm an ID-check session with a token. |
|
|
||||||
| `GET` | `/v1/age/status` | Return the account's verification status. |
|
|
||||||
| `GET` | `/v1/content/{id}/flags` | Return a content item's effective flags. |
|
|
||||||
| `POST` | `/v1/content/{id}/flags` | Add a flag (author/community). |
|
|
||||||
| `POST` | `/v1/moderation/review/{flag_id}` | Moderator approve/reject/escalate. |
|
|
||||||
| `POST` | `/v1/moderation/action` | Apply a moderation action. |
|
|
||||||
| `GET` | `/v1/manifest` | Return the client's permitted content manifest.|
|
|
||||||
|
|
||||||
All endpoints require a valid **JWT** (matching the existing auth stack). The
|
|
||||||
age endpoints additionally require the account to be in a non-`rejected` state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Security & Privacy Considerations
|
|
||||||
|
|
||||||
- **DOB encryption:** Self-declared birthdates are encrypted at rest; only the
|
|
||||||
derived age and verification status are used in authorization decisions.
|
|
||||||
- **No raw identity storage:** The ID check hook persists only a provider
|
|
||||||
reference and a boolean result.
|
|
||||||
- **Server authority:** The client manifest prevents bypass; the client never
|
|
||||||
trusts its own flag metadata for access decisions.
|
|
||||||
- **Rate limiting:** Age-declaration and ID-check endpoints are rate-limited to
|
|
||||||
prevent abuse and enumeration.
|
|
||||||
- **Auditability:** All flag and verification events are logged append-only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions / Future Work
|
|
||||||
|
|
||||||
- **Regional age thresholds:** Different jurisdictions may require different
|
|
||||||
minimum ages; the threshold should be configurable per region.
|
|
||||||
- **Parental controls:** A future feature may let a parent set a content filter
|
|
||||||
on a minor's account.
|
|
||||||
- **Automated scanner accuracy:** The heuristic scanner may produce false
|
|
||||||
positives; a confidence score should be attached to automated flags.
|
|
||||||
- **Offline / local content:** How flags apply to locally-authored content that
|
|
||||||
is never uploaded remains to be defined.
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
# Editor Architecture: Godot Editor Plugin vs. In-Game Editor
|
|
||||||
|
|
||||||
> **Status:** Proposed
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Related:** World chunk data format & persistence, server authority + client
|
|
||||||
> prediction, content moderation & age verification, avatar component system
|
|
||||||
> **Godot version:** 4.6.2 stable
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document evaluates the two primary ways to build the **world/level
|
|
||||||
authoring tool** for **agp-game**:
|
|
||||||
|
|
||||||
- **(a) Godot editor plugin** — extend the Godot editor itself with custom
|
|
||||||
panels, dock widgets, and tools that operate on the project's scenes and
|
|
||||||
resources.
|
|
||||||
- **(b) In-game editor** — a fully custom editor built as game UI (Godot
|
|
||||||
`Control` nodes) that runs inside the running game, editing the game's own
|
|
||||||
world data.
|
|
||||||
|
|
||||||
The goal is to pick the approach that best fits the platform's constraints:
|
|
||||||
a **server-authoritative** multiplayer game where the **world is stored as
|
|
||||||
versioned chunks** (see `world.md`), content is **moderation-flagged** (see
|
|
||||||
`content_moderation.md`), and the client is a **shipped binary** that players
|
|
||||||
run (not a dev tool).
|
|
||||||
|
|
||||||
This document is a **decision record**: it lays out the trade-offs, gives a
|
|
||||||
clear recommendation, and defines the resulting architecture boundaries.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Context & Constraints
|
|
||||||
|
|
||||||
Before comparing the two approaches, we must fix the constraints that drive the
|
|
||||||
decision. These come from the existing architecture docs.
|
|
||||||
|
|
||||||
### 2.1 The World Is Data, Not Scenes
|
|
||||||
|
|
||||||
`world.md` defines the world as **chunk data** (JSON for authoring, binary for
|
|
||||||
runtime/network), persisted in SQLite on the server. A chunk is the atomic unit
|
|
||||||
of load, save, and stream. The **server is the only writer** to the store;
|
|
||||||
clients submit **deltas** against a `base_revision` and the server validates and
|
|
||||||
applies them.
|
|
||||||
|
|
||||||
**Implication:** The editor's output is **chunk payloads**, not `.tscn` scene
|
|
||||||
files. Whatever editor we build must serialize to the chunk contract, not to
|
|
||||||
Godot scene resources.
|
|
||||||
|
|
||||||
### 2.2 Server-Authoritative & Moderation-Aware
|
|
||||||
|
|
||||||
- The server validates every edit (schema, bounds, moderation) before
|
|
||||||
persisting (`world.md` §5.4).
|
|
||||||
- Content carries **content flags** aggregated from referenced assets/scripts
|
|
||||||
(`content_moderation.md` §3). Mature content is gated behind age verification.
|
|
||||||
- The client receives a **content manifest** and never loads content not in it.
|
|
||||||
|
|
||||||
**Implication:** An editor that runs on a **player's machine** cannot be the
|
|
||||||
authority on what gets published. Editing must go through the server's
|
|
||||||
validation pipeline, and the editor must surface moderation flags.
|
|
||||||
|
|
||||||
### 2.3 The Client Is a Shipped Binary
|
|
||||||
|
|
||||||
The game is a multiplayer product. Players run a **release build**. The Godot
|
|
||||||
editor is a **development tool** that players do not have.
|
|
||||||
|
|
||||||
**Implication:** If authoring is a first-class *player* feature (user-generated
|
|
||||||
content), the editor must ship inside the game binary. If authoring is a
|
|
||||||
*developer/platform* feature, it can live in the editor.
|
|
||||||
|
|
||||||
### 2.4 Two Distinct Audiences
|
|
||||||
|
|
||||||
The evaluation must separate two audiences, because they have opposite needs:
|
|
||||||
|
|
||||||
| Audience | Who | Where they run | What they build |
|
|
||||||
|----------|-----|----------------|-----------------|
|
|
||||||
| **Platform/dev** | The team building the game | Godot editor (dev machines) | Base worlds, rooms, templates, test levels |
|
|
||||||
| **Players (UGC)** | End users | The shipped game | Their own rooms/worlds, published to the server |
|
|
||||||
|
|
||||||
The two approaches map cleanly onto these audiences, and the right answer is
|
|
||||||
likely **both** — but with a clear primary recommendation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Approach (a): Godot Editor Plugin
|
|
||||||
|
|
||||||
### 3.1 What It Is
|
|
||||||
|
|
||||||
A Godot editor plugin is a set of scripts (and optionally scenes) that extend
|
|
||||||
the editor via the `EditorPlugin` API. It can add:
|
|
||||||
|
|
||||||
- **Dock panels** (`add_control_to_dock`) — e.g., a chunk/tile palette panel.
|
|
||||||
- **Toolbar buttons** (`add_control_to_container`).
|
|
||||||
- **Inspector plugins** (`EditorInspectorPlugin`) — custom property editors.
|
|
||||||
- **Import plugins** (`EditorImportPlugin`) — custom asset importers.
|
|
||||||
- **Scene/Node plugins** (`EditorNode3DGizmoPlugin`) — custom gizmos.
|
|
||||||
- **Menu entries** (`add_tool_menu_item`).
|
|
||||||
|
|
||||||
Plugins are enabled per-project via `project.godot` (`[editor_plugins]`).
|
|
||||||
|
|
||||||
### 3.2 Pros
|
|
||||||
|
|
||||||
- **Full editor power for free:** You get the scene tree, inspector, viewport
|
|
||||||
navigation, undo/redo, gizmos, and asset pipeline — all battle-tested.
|
|
||||||
- **Native Godot editing:** Editing `.tscn` scenes, materials, and nodes is
|
|
||||||
exactly what the editor is built for. Great for authoring **base content**
|
|
||||||
(rooms, templates, avatar base bodies).
|
|
||||||
- **Fast iteration for devs:** The platform team can build and test levels
|
|
||||||
directly in the tool they already use.
|
|
||||||
- **Undo/redo, selection, snapping** come from the editor's `EditorUndoRedoManager`.
|
|
||||||
- **No runtime footprint:** Editor-only code is stripped from export builds
|
|
||||||
(`@tool` scripts and editor plugins are not shipped to players).
|
|
||||||
|
|
||||||
### 3.3 Cons
|
|
||||||
|
|
||||||
- **Not available to players.** Players don't run the Godot editor. Any
|
|
||||||
player-facing UGC authoring cannot use this approach.
|
|
||||||
- **Tied to the editor's UX.** You inherit the editor's look and interaction
|
|
||||||
model; deep customization is limited by what the editor API exposes.
|
|
||||||
- **Editor API churn.** `EditorPlugin` APIs change between Godot versions;
|
|
||||||
plugins are more brittle across upgrades than game code.
|
|
||||||
- **Scene-centric, not data-centric.** The editor is optimized for scenes and
|
|
||||||
resources. Our world is **chunk data** (`world.md`). We'd have to build a
|
|
||||||
translation layer (scene ↔ chunk) and keep it in sync — a real source of
|
|
||||||
drift.
|
|
||||||
- **Single-user, local.** The editor edits local files. It does not natively
|
|
||||||
talk to our server-authoritative chunk store or the moderation pipeline.
|
|
||||||
- **Headless/CI friction.** Validating plugin behavior in CI requires running
|
|
||||||
the editor headless, which is heavier than testing game code.
|
|
||||||
|
|
||||||
### 3.4 Fit for Our Constraints
|
|
||||||
|
|
||||||
- **Platform/dev authoring:** Excellent. This is the natural home for building
|
|
||||||
base worlds, rooms, and templates.
|
|
||||||
- **Player UGC:** Not applicable. Players cannot run the editor.
|
|
||||||
- **Server-authoritative chunk store:** Poor fit natively. The editor edits
|
|
||||||
local scenes; we'd bolt on a chunk export/import and a server sync layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Approach (b): In-Game Editor
|
|
||||||
|
|
||||||
### 4.1 What It Is
|
|
||||||
|
|
||||||
A fully custom editor built as **game UI** — Godot `Control` nodes (panels,
|
|
||||||
buttons, toolbars) that run inside the running game. It edits the game's own
|
|
||||||
world data model (chunks) and submits edits through the normal server pipeline.
|
|
||||||
|
|
||||||
### 4.2 Pros
|
|
||||||
|
|
||||||
- **Ships to players.** The editor is part of the game binary, so **player UGC
|
|
||||||
authoring is possible** — the core requirement for a user-generated-content
|
|
||||||
platform.
|
|
||||||
- **Data-native.** The editor operates directly on the **chunk model**
|
|
||||||
(`world.md`), so there is no scene↔chunk translation layer and no drift.
|
|
||||||
- **Server-authoritative by construction.** Edits go through the same
|
|
||||||
`request_chunk_edit` RPCs as gameplay, so validation, revisioning, and
|
|
||||||
moderation apply uniformly. The editor cannot bypass the server.
|
|
||||||
- **Moderation-aware.** Because edits flow through the server, content flags are
|
|
||||||
computed and enforced at the boundary (`content_moderation.md` §4). The editor
|
|
||||||
can surface flags and gate mature content.
|
|
||||||
- **One codebase, one UX.** The editor uses the same UI toolkit and art style as
|
|
||||||
the game; no separate editor theme to maintain.
|
|
||||||
- **Testable in CI.** The editor is ordinary game code; it can be exercised
|
|
||||||
headlessly and unit-tested like any other system.
|
|
||||||
|
|
||||||
### 4.3 Cons
|
|
||||||
|
|
||||||
- **We build everything.** No free scene tree, inspector, gizmos, or undo/redo.
|
|
||||||
We must implement selection, snapping, undo/redo, viewport navigation, and
|
|
||||||
property editing ourselves.
|
|
||||||
- **Runtime footprint.** Editor UI ships in the binary (though it can be gated
|
|
||||||
behind a feature flag / separate scene that is only instantiated on demand).
|
|
||||||
- **More upfront work.** A usable in-game editor is a large feature; it needs
|
|
||||||
its own architecture (tool modes, gizmos, palette, inspector).
|
|
||||||
- **Not ideal for deep asset authoring.** Modeling a mesh or authoring a
|
|
||||||
material is better done in the Godot editor / DCC tools. The in-game editor
|
|
||||||
should focus on **world composition** (placing tiles and objects), not asset
|
|
||||||
creation.
|
|
||||||
|
|
||||||
### 4.4 Fit for Our Constraints
|
|
||||||
|
|
||||||
- **Platform/dev authoring:** Workable but not ideal for deep asset work.
|
|
||||||
- **Player UGC:** The **only** viable approach. Players can only author through
|
|
||||||
the shipped game.
|
|
||||||
- **Server-authoritative chunk store:** Excellent fit. The editor is a client
|
|
||||||
that submits validated deltas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Comparison Matrix
|
|
||||||
|
|
||||||
| Criterion | (a) Editor plugin | (b) In-game editor |
|
|
||||||
|-----------|-------------------|--------------------|
|
|
||||||
| Ships to players | ❌ No | ✅ Yes |
|
|
||||||
| Player UGC authoring | ❌ Impossible | ✅ Core capability |
|
|
||||||
| World data model fit | ⚠️ Scene-centric; needs translation layer | ✅ Chunk-native |
|
|
||||||
| Server-authoritative edits | ⚠️ Bolted on | ✅ Native (same RPCs) |
|
|
||||||
| Moderation enforcement | ⚠️ Manual | ✅ At the boundary |
|
|
||||||
| Free editor features (undo, gizmos, inspector) | ✅ Rich | ❌ Build ourselves |
|
|
||||||
| Runtime footprint | ✅ None (stripped from export) | ⚠️ Ships in binary (gated) |
|
|
||||||
| Dev iteration speed | ✅ Fast | ⚠️ Slower to build |
|
|
||||||
| Deep asset authoring (mesh/material) | ✅ Native | ❌ Out of scope |
|
|
||||||
| CI testability | ⚠️ Heavier (headless editor) | ✅ Ordinary game code |
|
|
||||||
| Editor API churn risk | ⚠️ Higher | ✅ None (game code) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Recommendation
|
|
||||||
|
|
||||||
**Primary recommendation: build the in-game editor (b) as the platform's
|
|
||||||
authoring surface, and use a lightweight Godot editor plugin (a) only as an
|
|
||||||
internal dev convenience.**
|
|
||||||
|
|
||||||
### 6.1 Rationale
|
|
||||||
|
|
||||||
1. **Player UGC is the deciding factor.** The platform's value proposition is
|
|
||||||
user-generated content (rooms, worlds, avatars). Players cannot run the Godot
|
|
||||||
editor, so **only an in-game editor can deliver player authoring**. This is a
|
|
||||||
hard requirement that the editor-plugin approach cannot meet.
|
|
||||||
|
|
||||||
2. **The world is data, and the in-game editor is data-native.** `world.md`
|
|
||||||
defines chunks as the atomic unit. An in-game editor edits chunks directly
|
|
||||||
and submits them through the server pipeline. An editor plugin would force a
|
|
||||||
scene↔chunk translation layer that is a constant source of drift and bugs.
|
|
||||||
|
|
||||||
3. **Server authority and moderation fall out naturally.** Because in-game
|
|
||||||
edits use the same `request_chunk_edit` RPCs as gameplay, validation,
|
|
||||||
revisioning, optimistic concurrency, and content-flag enforcement
|
|
||||||
(`content_moderation.md`) apply uniformly. The editor cannot bypass the
|
|
||||||
server — which is exactly what a moderation-aware UGC platform needs.
|
|
||||||
|
|
||||||
4. **The editor-plugin's strengths are not what we need.** Its free features
|
|
||||||
(scene tree, inspector, gizmos) are for editing Godot scenes and resources.
|
|
||||||
Our authoring is world composition against a chunk contract, not scene
|
|
||||||
editing. The plugin's main win — deep asset authoring — is better served by
|
|
||||||
DCC tools and the Godot editor for the *platform team*, not by a plugin we
|
|
||||||
must maintain.
|
|
||||||
|
|
||||||
### 6.2 The Role of the Editor Plugin (Dev Convenience Only)
|
|
||||||
|
|
||||||
We still recommend a **small, optional** editor plugin for the **platform team**
|
|
||||||
to author **base content** (base rooms, templates, avatar base bodies, test
|
|
||||||
levels) directly in the Godot editor. This is a dev-time convenience, not a
|
|
||||||
product feature:
|
|
||||||
|
|
||||||
- It can export a scene/resource as a **chunk** (reusing the same encoder as the
|
|
||||||
in-game editor) so dev-authored content flows into the same pipeline.
|
|
||||||
- It is **not shipped** to players and is **not** the authoring surface for UGC.
|
|
||||||
- It should be kept thin to minimize editor-API churn risk.
|
|
||||||
|
|
||||||
### 6.3 Scope Boundary
|
|
||||||
|
|
||||||
To keep the in-game editor tractable, define its scope clearly:
|
|
||||||
|
|
||||||
- **In scope (world composition):** placing tiles, placing static/dynamic
|
|
||||||
objects, setting transforms, editing object properties, saving/loading chunks,
|
|
||||||
submitting edits to the server, showing moderation flags.
|
|
||||||
- **Out of scope (asset creation):** modeling meshes, authoring materials,
|
|
||||||
rigging avatars. These are done in DCC tools / the Godot editor and imported
|
|
||||||
as catalog assets (see `avatar.md` §7). The in-game editor **references**
|
|
||||||
catalog assets by id; it does not create them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Resulting Architecture
|
|
||||||
|
|
||||||
### 7.1 Shared Editor Core
|
|
||||||
|
|
||||||
Both the in-game editor and the (optional) dev plugin should share a single
|
|
||||||
**editor core** that operates on the chunk model. This avoids two divergent
|
|
||||||
implementations.
|
|
||||||
|
|
||||||
```
|
|
||||||
EditorCore (shared, data-native)
|
|
||||||
├── ChunkEditorModel # in-memory chunk being edited (tiles, objects, flags)
|
|
||||||
├── ChunkEncoder # JSON/binary encode/decode (world.md §3)
|
|
||||||
├── EditSession # base_revision tracking, delta generation
|
|
||||||
├── EditSubmitter # request_chunk_edit RPC wrapper + conflict handling
|
|
||||||
├── UndoRedoStack # command pattern over chunk mutations
|
|
||||||
├── SelectionManager # selected tiles/objects
|
|
||||||
├── ToolRegistry # brush, object-place, select, erase, etc.
|
|
||||||
└── ModerationView # surfaces content flags; gates mature content
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 In-Game Editor (Primary)
|
|
||||||
|
|
||||||
```
|
|
||||||
InGameEditor (Control-based UI, shipped in binary, gated by feature flag)
|
|
||||||
├── EditorViewport # custom 2D/3D viewport with camera navigation
|
|
||||||
├── Toolbar # tool mode switcher
|
|
||||||
├── PalettePanel # tile/object palette (from catalog)
|
|
||||||
├── InspectorPanel # property editor for selected object
|
|
||||||
├── ChunkBrowser # load/save chunks, list rooms
|
|
||||||
└── EditorCore # shared core (above)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Instantiated only when the player enters "build mode" (a separate scene /
|
|
||||||
feature flag), so the runtime footprint is negligible during normal play.
|
|
||||||
- Submits edits via `request_chunk_edit`; handles `chunk_edit_result` conflicts
|
|
||||||
by re-fetching and re-applying (`world.md` §5.4).
|
|
||||||
|
|
||||||
### 7.3 Dev Editor Plugin (Optional, Thin)
|
|
||||||
|
|
||||||
```
|
|
||||||
DevChunkPlugin (EditorPlugin, dev-only, not shipped)
|
|
||||||
├── Dock panel: "Chunk Export"
|
|
||||||
├── Reads the current scene / selected nodes
|
|
||||||
├── Maps scene content → chunk model (via EditorCore.ChunkEncoder)
|
|
||||||
└── Exports a .chunk.json / submits to a dev server
|
|
||||||
```
|
|
||||||
|
|
||||||
Kept thin to minimize editor-API churn. Its only job is to let the platform team
|
|
||||||
author base content in the editor and push it into the same chunk pipeline.
|
|
||||||
|
|
||||||
### 7.4 Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
[In-game editor] --request_chunk_edit(delta, base_revision)--> [Server]
|
|
||||||
^ | validate
|
|
||||||
| | apply
|
|
||||||
+--chunk_edit_result(ok/new_revision | conflict)-----------| persist
|
|
||||||
v
|
|
||||||
SQLite (world.db)
|
|
||||||
|
|
||||||
[Dev plugin] --export chunk JSON--> [EditorCore.ChunkEncoder] --> [Server]
|
|
||||||
```
|
|
||||||
|
|
||||||
Both paths converge on the **same server validation pipeline**, so moderation
|
|
||||||
and revisioning are uniform regardless of authoring surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|------|------------|
|
|
||||||
| In-game editor is a large build | Scope it to world composition (§6.3); reuse EditorCore; ship behind a feature flag |
|
|
||||||
| Custom viewport/gizmos are hard | Start with a 2D tile editor (matches `CHUNK_SIZE_Z=1` default); add 3D later |
|
|
||||||
| Undo/redo complexity | Use a command pattern over chunk mutations; test headlessly |
|
|
||||||
| Editor-plugin API churn | Keep the plugin thin; treat it as dev-only convenience, not a product surface |
|
|
||||||
| Drift between dev plugin and in-game editor | Both use the same `EditorCore`/`ChunkEncoder` |
|
|
||||||
| Players bypass moderation | Impossible by construction: edits go through server validation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Open Questions / Future Work
|
|
||||||
|
|
||||||
- **3D editing:** The default world is flat (`CHUNK_SIZE_Z=1`). A 3D in-game
|
|
||||||
editor (volumetric chunks) is future work (`world.md` §8).
|
|
||||||
- **Collaborative editing:** Multiple players editing the same chunk concurrently
|
|
||||||
— the optimistic-concurrency model already supports it, but the UX (live
|
|
||||||
cursors, conflict resolution) is unspecified.
|
|
||||||
- **Template library:** A curated set of room templates players can start from.
|
|
||||||
- **Publish/review flow:** How a player's authored room enters the moderation
|
|
||||||
review queue (`content_moderation.md` §6) and becomes publicly joinable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Summary
|
|
||||||
|
|
||||||
| Concern | Decision |
|
|
||||||
|---------|----------|
|
|
||||||
| Primary authoring surface | **In-game editor** (ships to players, enables UGC) |
|
|
||||||
| Dev authoring convenience | Thin, optional **editor plugin** (not shipped) |
|
|
||||||
| World data model | Both operate on the **chunk model** via a shared `EditorCore` |
|
|
||||||
| Server authority | Edits go through `request_chunk_edit`; server validates |
|
|
||||||
| Moderation | Enforced at the server boundary for both paths |
|
|
||||||
| In-game editor scope | World composition only; asset creation is out of scope |
|
|
||||||
| Runtime footprint | Editor gated behind a feature flag / separate scene |
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
# Multiplayer Networking Architecture
|
|
||||||
|
|
||||||
> **Status:** Proposed
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Related:** Server authority + client prediction
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document specifies the multiplayer networking architecture for **agp-game**,
|
|
||||||
a Godot-based game built by autonomous agents. The architecture follows a
|
|
||||||
**server-authoritative model with client-side prediction and reconciliation**,
|
|
||||||
which is the industry standard for fast-paced, competitive multiplayer games.
|
|
||||||
|
|
||||||
The goals of this architecture are:
|
|
||||||
|
|
||||||
- **Fairness & anti-cheat:** The server is the single source of truth for all
|
|
||||||
gameplay state. Clients never directly mutate world state.
|
|
||||||
- **Responsiveness:** Client-side prediction hides network latency so local input
|
|
||||||
feels immediate.
|
|
||||||
- **Determinism:** The server runs a fixed-timestep simulation so that state is
|
|
||||||
reproducible and testable.
|
|
||||||
- **Scalability:** A clear separation between transport, simulation, and
|
|
||||||
replication layers allows the server to be tuned independently.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Transport Choice: ENet over WebSocket
|
|
||||||
|
|
||||||
### 2.1 Decision
|
|
||||||
|
|
||||||
We choose **ENet** as the primary transport for gameplay traffic.
|
|
||||||
|
|
||||||
### 2.2 Justification
|
|
||||||
|
|
||||||
| Criterion | ENet | WebSocket (TCP) |
|
|
||||||
|----------------------|----------------------------------------|-------------------------------------|
|
|
||||||
| **Reliability model**| Reliable + unreliable channels | Reliable only (TCP) |
|
|
||||||
| **Latency** | Low; unreliable packets skip retransmit| Head-of-line blocking on loss |
|
|
||||||
| **Packet ordering** | Per-channel ordering | Global strict ordering |
|
|
||||||
| **Congestion control**| Lightweight, game-oriented | TCP-style (slow start, backoff) |
|
|
||||||
| **Godot support** | First-class (`ENetConnection`, `MultiplayerPeerENet`) | Supported but TCP-based |
|
|
||||||
| **Use case fit** | Real-time action game | Chat, lobby, HTTP-style messaging |
|
|
||||||
|
|
||||||
**Rationale:**
|
|
||||||
|
|
||||||
- **Movement & state replication** are latency-sensitive and tolerate occasional
|
|
||||||
packet loss. ENet's *unreliable* channel lets us send position snapshots at high
|
|
||||||
frequency without head-of-line blocking.
|
|
||||||
- **RPCs that must be delivered** (e.g., interactions, spawns) use ENet's
|
|
||||||
*reliable* channel, giving us exactly the semantics we need without TCP's
|
|
||||||
retransmission stalls.
|
|
||||||
- Godot's `MultiplayerPeerENet` is mature and integrates directly with the
|
|
||||||
high-level multiplayer API (`MultiplayerAPI`, `rpc()`).
|
|
||||||
|
|
||||||
### 2.3 WebSocket as a Secondary Transport
|
|
||||||
|
|
||||||
WebSocket remains available as a **secondary transport** for:
|
|
||||||
|
|
||||||
- **Lobby / matchmaking** messaging (low-frequency, must-be-reliable).
|
|
||||||
- **Browser builds** where raw UDP/ENet is unavailable.
|
|
||||||
- **Admin / telemetry** channels.
|
|
||||||
|
|
||||||
The transport layer is abstracted behind an interface so gameplay code does not
|
|
||||||
depend on the underlying protocol.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Server-Side Game Loop
|
|
||||||
|
|
||||||
### 3.1 Tick Rate
|
|
||||||
|
|
||||||
- **Simulation tick rate:** **30 Hz** (33.33 ms per tick).
|
|
||||||
- **Replication / snapshot rate:** **30 Hz**, aligned with the simulation tick.
|
|
||||||
|
|
||||||
**Justification:**
|
|
||||||
|
|
||||||
- 30 Hz is sufficient for smooth movement and interaction in a top-down / 2D
|
|
||||||
action game while keeping CPU and bandwidth costs predictable.
|
|
||||||
- A fixed timestep (rather than delta-time) makes the simulation deterministic
|
|
||||||
and testable.
|
|
||||||
- The tick rate is a **configuration constant** so it can be raised (e.g., to
|
|
||||||
60 Hz) for competitive modes without architectural changes.
|
|
||||||
|
|
||||||
### 3.2 Authority Model
|
|
||||||
|
|
||||||
- **Server is authoritative** for:
|
|
||||||
- Player position, velocity, and physics state.
|
|
||||||
- Health, damage, and death.
|
|
||||||
- Item pickups, inventory, and world-object state.
|
|
||||||
- Spawning and despawning of entities.
|
|
||||||
- Game rules, scoring, and win/lose conditions.
|
|
||||||
- **Client is authoritative** for:
|
|
||||||
- Its own *input* (which keys/buttons are pressed).
|
|
||||||
- Local camera and UI.
|
|
||||||
- Cosmetic / non-gameplay presentation.
|
|
||||||
|
|
||||||
### 3.3 Server Loop Pseudocode
|
|
||||||
|
|
||||||
```
|
|
||||||
while server_running:
|
|
||||||
process_network_inbound() # read client inputs & RPCs
|
|
||||||
for _ in range(ticks_to_advance):
|
|
||||||
simulate_tick() # fixed 30 Hz step
|
|
||||||
broadcast_snapshots() # replicate authoritative state
|
|
||||||
process_network_outbound() # flush reliable/unreliable channels
|
|
||||||
```
|
|
||||||
|
|
||||||
The server accumulates real elapsed time and advances the simulation in fixed
|
|
||||||
`TICK_DURATION` increments, decoupling simulation from frame rate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. RPC Call Patterns
|
|
||||||
|
|
||||||
RPCs are grouped by direction and reliability. All gameplay RPCs are routed
|
|
||||||
through the high-level `MultiplayerAPI` with explicit `rpc_id` targeting.
|
|
||||||
|
|
||||||
### 4.1 Client → Server (Input & Requests)
|
|
||||||
|
|
||||||
| RPC | Channel | Payload | Notes |
|
|
||||||
|------------------------------|-----------|-------------------------------------------|-------|
|
|
||||||
| `request_move_input` | unreliable| `{ seq, input_state, timestamp }` | Batched per tick; drives prediction |
|
|
||||||
| `request_interact` | reliable | `{ target_id }` | Open door, pick up item, use object |
|
|
||||||
| `request_attack` | reliable | `{ target_id, aim_dir }` | Combat action |
|
|
||||||
| `request_join` | reliable | `{ player_name, profile }` | Session setup |
|
|
||||||
| `request_respawn` | reliable | `{}` | After death |
|
|
||||||
|
|
||||||
**Design notes:**
|
|
||||||
|
|
||||||
- **Input is batched** per client tick and sent with a monotonically increasing
|
|
||||||
`seq` so the server can detect gaps and the client can reconcile.
|
|
||||||
- **Interactions are reliable** because they are discrete, must-happen events
|
|
||||||
(opening a door, picking up an item). Losing them would corrupt world state.
|
|
||||||
|
|
||||||
### 4.2 Server → Client (State & Events)
|
|
||||||
|
|
||||||
| RPC | Channel | Payload | Notes |
|
|
||||||
|------------------------------|-----------|-------------------------------------------|-------|
|
|
||||||
| `snapshot_state` | unreliable| `{ tick, entities[], player_states[] }` | Periodic authoritative state |
|
|
||||||
| `spawn_entity` | reliable | `{ entity_id, type, transform, props }` | New entity enters world |
|
|
||||||
| `despawn_entity` | reliable | `{ entity_id, reason }` | Entity removed |
|
|
||||||
| `interact_result` | reliable | `{ request_seq, success, result }` | Acknowledges interaction |
|
|
||||||
| `damage_event` | reliable | `{ target_id, amount, source_id }` | Combat feedback |
|
|
||||||
| `game_event` | reliable | `{ type, payload }` | Score, win, round start/end |
|
|
||||||
|
|
||||||
### 4.3 Broadcast vs. Targeted
|
|
||||||
|
|
||||||
- **Broadcast (all clients):** `snapshot_state`, `spawn_entity`,
|
|
||||||
`despawn_entity`, `game_event`.
|
|
||||||
- **Targeted (single client):** `interact_result`, `damage_event` (to affected
|
|
||||||
players), and any player-private data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Client-Side Prediction & Reconciliation
|
|
||||||
|
|
||||||
### 5.1 Prediction
|
|
||||||
|
|
||||||
- The client runs a **local copy of the movement simulation** using the same
|
|
||||||
physics constants as the server.
|
|
||||||
- When the player provides input, the client:
|
|
||||||
1. Applies the input to its local player immediately.
|
|
||||||
2. Renders the predicted position.
|
|
||||||
3. Sends the input to the server (batched, with `seq`).
|
|
||||||
|
|
||||||
### 5.2 Reconciliation
|
|
||||||
|
|
||||||
- The server sends authoritative `snapshot_state` at 30 Hz containing the
|
|
||||||
server's tick number and authoritative player positions.
|
|
||||||
- The client keeps a **ring buffer of predicted states** keyed by input `seq`.
|
|
||||||
- On receiving a snapshot:
|
|
||||||
1. Find the last predicted state that matches the server's tick.
|
|
||||||
2. Compute the **error** between predicted and authoritative position.
|
|
||||||
3. If error exceeds a threshold, **snap or smoothly interpolate** the local
|
|
||||||
player to the authoritative position (error correction).
|
|
||||||
4. Re-simulate any inputs that occurred after the server's tick (rollback).
|
|
||||||
|
|
||||||
### 5.3 Interpolation of Remote Entities
|
|
||||||
|
|
||||||
- **Local player:** predicted + reconciled (see above).
|
|
||||||
- **Remote players / entities:** rendered with **interpolation delay** of
|
|
||||||
`2 × TICK_DURATION` (≈ 66 ms) to smooth out snapshot jitter.
|
|
||||||
- The client buffers the last N snapshots and interpolates between them.
|
|
||||||
|
|
||||||
### 5.4 Prediction Buffer Sizing
|
|
||||||
|
|
||||||
- Buffer size is adaptive based on measured RTT.
|
|
||||||
- Default: hold up to `ceil(RTT / TICK_DURATION) + 2` predicted states.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Entity & World State Replication
|
|
||||||
|
|
||||||
### 6.1 Entity Model
|
|
||||||
|
|
||||||
Every networked entity has:
|
|
||||||
|
|
||||||
- A globally unique `entity_id` (server-assigned).
|
|
||||||
- An `EntityType` (player, enemy, item, projectile, door, ...).
|
|
||||||
- A `Transform` (position, rotation).
|
|
||||||
- A typed `props` dictionary of gameplay state (health, state machine, etc.).
|
|
||||||
|
|
||||||
### 6.2 Snapshot Format
|
|
||||||
|
|
||||||
```
|
|
||||||
snapshot_state:
|
|
||||||
tick: uint32
|
|
||||||
entities:
|
|
||||||
- entity_id: uint32
|
|
||||||
type: uint8
|
|
||||||
transform: { x, y, rot }
|
|
||||||
props: { ... } # delta-encoded where possible
|
|
||||||
player_states:
|
|
||||||
- peer_id: int
|
|
||||||
entity_id: uint32
|
|
||||||
health: int
|
|
||||||
state: uint8
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Delta Compression
|
|
||||||
|
|
||||||
- Only **changed** entity fields are sent per snapshot (dirty-flag per entity).
|
|
||||||
- Static or unchanged entities are omitted from the snapshot.
|
|
||||||
- This keeps bandwidth proportional to *activity*, not world size.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Connection & Session Lifecycle
|
|
||||||
|
|
||||||
1. **Connect:** Client opens an ENet connection to the server.
|
|
||||||
2. **Authenticate / Join:** Client sends `request_join`; server validates and
|
|
||||||
assigns a `peer_id` and spawns the player entity.
|
|
||||||
3. **Spawn:** Server broadcasts `spawn_entity` for the new player to all clients.
|
|
||||||
4. **Play:** Input → prediction → server simulation → snapshot → reconciliation.
|
|
||||||
5. **Disconnect:** Server removes the player entity, broadcasts `despawn_entity`,
|
|
||||||
and cleans up session 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
|
|
||||||
|
|
||||||
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
|
||||||
*requests*; the server computes all outcomes.
|
|
||||||
- **Input validation:** Server clamps movement speed, rejects out-of-bounds
|
|
||||||
positions, and validates interaction targets.
|
|
||||||
- **Rate limiting:** Per-peer input and RPC rate limits prevent flooding.
|
|
||||||
- **Sequence validation:** Gaps or replays in input `seq` are detected and
|
|
||||||
rejected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Testing & Determinism
|
|
||||||
|
|
||||||
- The simulation is a **pure function of (state, inputs, tick)** so it can be
|
|
||||||
unit-tested headlessly.
|
|
||||||
- A **replay system** records inputs and can re-simulate to verify server and
|
|
||||||
client produce identical results.
|
|
||||||
- Network code is tested with a **mock transport** that can inject latency,
|
|
||||||
jitter, and packet loss.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions / Future Work
|
|
||||||
|
|
||||||
- **Interest management / spatial partitioning:** For large worlds, restrict
|
|
||||||
snapshots to entities near each client (AOI culling).
|
|
||||||
- **Server-side physics:** Decide whether to use Godot's physics server on the
|
|
||||||
host or a custom deterministic physics step.
|
|
||||||
- **Dedicated server vs. listen server:** The architecture supports both; a
|
|
||||||
dedicated headless server is the target for production.
|
|
||||||
- **WebSocket fallback:** Implement the transport abstraction for browser builds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Summary
|
|
||||||
|
|
||||||
| Concern | Decision |
|
|
||||||
|------------------------|-------------------------------------------------|
|
|
||||||
| Transport | ENet (primary), WebSocket (secondary) |
|
|
||||||
| Tick rate | 30 Hz fixed timestep |
|
|
||||||
| Authority | Server-authoritative; client sends input only |
|
|
||||||
| Prediction | Client-side prediction + reconciliation |
|
|
||||||
| Remote interpolation | 2-tick interpolation delay |
|
|
||||||
| Replication | Delta-compressed snapshots at 30 Hz |
|
|
||||||
| Interaction RPCs | Reliable channel |
|
|
||||||
| Movement input | Unreliable channel, batched with sequence |
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# GDScript Sandboxing for User-Written Scripts
|
|
||||||
|
|
||||||
> **Status:** Proposed
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Related:** Content moderation & age verification (scripts are one of the
|
|
||||||
> three moderated content types), avatar component system
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document evaluates how to safely run **user-written scripts** as part of
|
|
||||||
UGC (user-generated content) in **agp-game**, and recommends an approach.
|
|
||||||
|
|
||||||
**Bottom line: Godot 4 has no built-in security sandbox for GDScript, and
|
|
||||||
none of the three options named in the original task hold up under testing.**
|
|
||||||
A script running anywhere inside the Godot process — however it's loaded,
|
|
||||||
parented, or wrapped — runs with full engine privileges: arbitrary process
|
|
||||||
execution, full filesystem read/write, and raw network access. Sandboxing
|
|
||||||
has to happen *outside* the scripting language, not through an in-engine
|
|
||||||
restriction mechanism, because no such mechanism exists.
|
|
||||||
|
|
||||||
This was verified directly against Godot 4.6.2 (`godot --headless`, evidence
|
|
||||||
in `sandbox_probe/` on this branch) rather than assumed from documentation,
|
|
||||||
some of which (see §2.1) describes APIs that don't actually exist in this
|
|
||||||
version.
|
|
||||||
|
|
||||||
## 2. Options Evaluated
|
|
||||||
|
|
||||||
### 2.1 Godot's built-in "ScriptServer" restrictions — does not exist
|
|
||||||
|
|
||||||
There is no `ScriptServer` class in Godot 4.6.2:
|
|
||||||
|
|
||||||
```
|
|
||||||
ClassDB.class_exists("ScriptServer") → false
|
|
||||||
```
|
|
||||||
|
|
||||||
There's also no project setting to disable engine subsystems for scripts —
|
|
||||||
none of `application/run/disable_os`, `disable_file_access`, or
|
|
||||||
`disable_network` exist as settings, and `ProjectSettings.get_property_list()`
|
|
||||||
returns nothing matching "sandbox", "restrict", or "security". Any plan that
|
|
||||||
assumes a flag like this exists needs to be dropped; it was likely conflated
|
|
||||||
with sandboxing features from a different engine/scripting ecosystem, or
|
|
||||||
with **export template stripping** (which controls what ships in a build,
|
|
||||||
not what a script can call at runtime).
|
|
||||||
|
|
||||||
Directly probed capabilities of an ordinary GDScript running as a normal
|
|
||||||
node script, no special setup:
|
|
||||||
|
|
||||||
| Capability | Available to any script? |
|
|
||||||
|---|---|
|
|
||||||
| `OS.execute(...)` — run arbitrary processes | ✅ Yes |
|
|
||||||
| `FileAccess.open(...)` — read/write any path the OS user can reach | ✅ Yes (read `/etc/hostname`, write `/tmp/...` both succeeded) |
|
|
||||||
| `TCPServer` / `HTTPClient` / `HTTPRequest` / `PacketPeerUDP` / `WebSocketPeer` | ✅ All instantiate and work |
|
|
||||||
| `ClassDB.instantiate(...)` — construct any engine class by name | ✅ Yes |
|
|
||||||
| Loading and running a second script from source text at runtime (`GDScript.new()` + `.reload()`) | ✅ Yes |
|
|
||||||
|
|
||||||
**Verdict: rejected.** There is nothing to configure here — the restriction
|
|
||||||
surface this option assumed doesn't exist in the engine.
|
|
||||||
|
|
||||||
### 2.2 Isolated SubViewport with a limited API — doesn't isolate scripts
|
|
||||||
|
|
||||||
`SubViewport` is a **rendering and input isolation** primitive (it gives a
|
|
||||||
scene subtree its own render target and can gate input propagation) — it is
|
|
||||||
not a security or scripting boundary. A script attached to any node inside a
|
|
||||||
`SubViewport` still runs in the same process, on the same GDScript VM, with
|
|
||||||
the exact same access to `OS`, `FileAccess`, networking classes, and
|
|
||||||
`ClassDB` as a script anywhere else in the tree. Nothing about being
|
|
||||||
"inside" a SubViewport changes what API calls are reachable from a script.
|
|
||||||
|
|
||||||
**Verdict: rejected.** This solves a rendering/UI-composition problem, not
|
|
||||||
the security problem the task is asking about. Worth keeping in mind for
|
|
||||||
other purposes (e.g. isolated preview rendering of user avatars), but it
|
|
||||||
provides zero script sandboxing.
|
|
||||||
|
|
||||||
### 2.3 Custom GDScript interpreter with an allowlist — right idea, wrong layer for "custom interpreter"
|
|
||||||
|
|
||||||
Two ways to read this option, tested separately:
|
|
||||||
|
|
||||||
**(a) Write/embed a separate GDScript parser+interpreter.** Not
|
|
||||||
practical — this means re-implementing (and then indefinitely maintaining,
|
|
||||||
in lockstep with every Godot release) a full language interpreter outside
|
|
||||||
the engine. Far more engineering than the problem justifies.
|
|
||||||
|
|
||||||
**(b) Use Godot's built-in `Expression` class as a restricted subset.**
|
|
||||||
`Expression` *looks* like a natural allowlist mechanism — it only evaluates
|
|
||||||
a single expression, no control flow, no arbitrary statements. But it isn't
|
|
||||||
safe by default: it can still reach through to unsafe engine methods if you
|
|
||||||
aren't careful about what object you hand it as a base instance:
|
|
||||||
|
|
||||||
```gdscript
|
|
||||||
var expr = Expression.new()
|
|
||||||
expr.parse("get_environment('HOME')", ["OS"])
|
|
||||||
expr.execute([], OS) # → "/home/daddy" (successfully called OS.get_environment)
|
|
||||||
```
|
|
||||||
|
|
||||||
So `Expression` is a viable building block only if the host **never passes
|
|
||||||
a base instance (or arguments) that exposes anything sensitive** — i.e. the
|
|
||||||
allowlisting work still has to happen at the call site, `Expression` itself
|
|
||||||
enforces none of it.
|
|
||||||
|
|
||||||
**Static text pattern-matching (blocklisting dangerous substrings like
|
|
||||||
`"OS.execute"` before parsing) was also probed and is not a real
|
|
||||||
defense** — it's trivially defeated by string concatenation, `call()`,
|
|
||||||
`Callable`, or any other indirection, and produces false confidence.
|
|
||||||
|
|
||||||
**Verdict: partially viable, but only as a foundation, not a solution on
|
|
||||||
its own.** A hand-built, narrow, data-driven DSL (not full GDScript, not
|
|
||||||
raw `Expression`) with an explicit allowlist of safe operations and *no
|
|
||||||
ability to reference engine singletons or arbitrary objects* is the only
|
|
||||||
version of "custom interpreter" that's actually safe. This is real
|
|
||||||
engineering effort, not a config flag.
|
|
||||||
|
|
||||||
## 3. Recommendation
|
|
||||||
|
|
||||||
**Don't run untrusted GDScript in-process at all.** Given none of the three
|
|
||||||
in-engine options provide a real boundary, the two approaches actually worth
|
|
||||||
building are, in order of recommended priority:
|
|
||||||
|
|
||||||
1. **Primary: a narrow, non-Turing-complete content DSL**, not general
|
|
||||||
scripting. Most UGC scripting needs in a game like this (custom
|
|
||||||
interactions, simple triggers, item behaviors) don't need a general
|
|
||||||
programming language — they need a small set of declarative
|
|
||||||
verbs/conditions (e.g. `on_interact: give_item("torch")`,
|
|
||||||
`on_enter: play_sound("chime")`). Expressed as data (JSON/Resource, not
|
|
||||||
code) and interpreted by trusted, hand-written GDScript on the engine
|
|
||||||
side. This has *zero* attack surface by construction — there's no
|
|
||||||
interpreter to escape because there's no general execution, just a
|
|
||||||
fixed, reviewed set of verbs. This is almost certainly sufficient for
|
|
||||||
the actual UGC use cases in this game and should be scoped against real
|
|
||||||
feature requirements before assuming more is needed.
|
|
||||||
|
|
||||||
2. **If genuinely general scripting becomes a hard requirement**, isolate it
|
|
||||||
at the OS level, not the engine level: run untrusted scripts in a
|
|
||||||
**separate process** (a stripped-down Godot instance or a dedicated
|
|
||||||
interpreter) under OS sandboxing (a restricted user account, a
|
|
||||||
container, or `firejail`/`bwrap`/seccomp), communicating with the main
|
|
||||||
game over a narrow IPC/RPC boundary that only exposes intentionally-safe
|
|
||||||
operations. This is the only approach that provides a real security
|
|
||||||
boundary for arbitrary code, because it doesn't depend on the scripting
|
|
||||||
language cooperating — the OS enforces it. Substantially more
|
|
||||||
infrastructure than option 1; only worth it if option 1's DSL genuinely
|
|
||||||
can't express what's needed.
|
|
||||||
|
|
||||||
**Do not** attempt in-process GDScript sandboxing via SubViewport,
|
|
||||||
ScriptServer, or ad hoc blocklisting — all three were directly tested and
|
|
||||||
none provide a real boundary (§2).
|
|
||||||
|
|
||||||
## 4. Open Questions
|
|
||||||
|
|
||||||
- What do actual planned UGC scripts need to *do*? (Trigger conditions,
|
|
||||||
simple item/state effects, dialogue branching?) This determines whether
|
|
||||||
the DSL in §3.1 is sufficient or whether §3.2's process isolation is
|
|
||||||
needed. Needs a concrete feature list before implementation starts.
|
|
||||||
- If §3.2 is needed later, does it run per-user-session or shared/pooled?
|
|
||||||
Affects resource cost and isolation guarantees.
|
|
||||||
- Interaction with the content moderation pipeline (`content_moderation.md`):
|
|
||||||
scripts as a moderated content type still need a moderation flag path
|
|
||||||
regardless of which execution model is chosen — a DSL makes static
|
|
||||||
analysis of *what a script can possibly do* far easier than it would be
|
|
||||||
for general code, which is an additional point in its favor.
|
|
||||||
@@ -1,737 +0,0 @@
|
|||||||
# World Chunk Data Format & Persistence Architecture
|
|
||||||
|
|
||||||
> **Status:** Proposed
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document specifies the **world chunk data format** and **persistence
|
|
||||||
schema** for **agp-game**, a Godot-based multiplayer game built by autonomous
|
|
||||||
agents. It defines:
|
|
||||||
|
|
||||||
- The **chunk data format** — both a human-readable **JSON** form (for
|
|
||||||
authoring, tooling, and debugging) and a compact **binary** form (for
|
|
||||||
runtime loading and network transfer).
|
|
||||||
- The **SQLite schema** that persists world state on the server, including
|
|
||||||
chunk metadata, chunk payloads, and the world/room hierarchy.
|
|
||||||
- The **read/write API** that the server and client use to load, save, and
|
|
||||||
stream chunks.
|
|
||||||
|
|
||||||
The goal is a **single, versioned chunk contract** that is shared across the
|
|
||||||
Godot client, the Python backend, and the persistence layer. A chunk is the
|
|
||||||
atomic unit of world geometry, gameplay objects, and persistence — the server
|
|
||||||
loads and saves whole chunks, and clients stream the chunks around them.
|
|
||||||
|
|
||||||
### 1.1 Design Goals
|
|
||||||
|
|
||||||
- **Versioned & forward-compatible:** Every chunk carries a format version so
|
|
||||||
the loader can migrate old data and reject unknown future data gracefully.
|
|
||||||
- **Dual representation:** JSON for authoring/debugging, binary for
|
|
||||||
runtime/network. Both encode the *same* logical model so there is exactly one
|
|
||||||
source of truth for the schema.
|
|
||||||
- **Server-authoritative persistence:** The server is the only writer to the
|
|
||||||
SQLite store. Clients submit *edits* (deltas) and the server applies and
|
|
||||||
persists them, mirroring the server-authoritative networking model.
|
|
||||||
- **Chunk-granular persistence:** Persistence happens at chunk granularity so
|
|
||||||
the server can save a dirty chunk without rewriting the whole world, and can
|
|
||||||
stream only the chunks a client needs.
|
|
||||||
- **Deterministic & testable:** The binary format is byte-stable for a given
|
|
||||||
logical payload, enabling checksums, caching, and reproducible tests.
|
|
||||||
- **Moderation-aware:** Chunks reference content (assets, scripts) that carry
|
|
||||||
content flags (see `content_moderation.md`); the persistence layer stores the
|
|
||||||
moderation state alongside chunk data so enforcement can happen at load time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. World Model & Chunking
|
|
||||||
|
|
||||||
### 2.1 World Hierarchy
|
|
||||||
|
|
||||||
The world is organized as a hierarchy that matches the moderation content graph:
|
|
||||||
|
|
||||||
```
|
|
||||||
World
|
|
||||||
└── Room
|
|
||||||
└── Chunk (the atomic persistence & streaming unit)
|
|
||||||
├── Tiles / Terrain
|
|
||||||
├── Static objects (props, geometry)
|
|
||||||
├── Dynamic objects (spawnable entities, interactables)
|
|
||||||
└── Metadata (flags, version, timestamps)
|
|
||||||
```
|
|
||||||
|
|
||||||
- A **World** is the top-level container (a named, versioned space).
|
|
||||||
- A **Room** is a bounded region within a world (a level, a building, a zone).
|
|
||||||
- A **Chunk** is a fixed-size, axis-aligned region of a room. It is the unit of
|
|
||||||
load, save, and stream.
|
|
||||||
|
|
||||||
### 2.2 Chunk Dimensions
|
|
||||||
|
|
||||||
Chunks are **fixed-size, axis-aligned boxes** in world space. The default
|
|
||||||
dimensions are a configuration constant so they can be tuned per world:
|
|
||||||
|
|
||||||
| Constant | Default | Description |
|
|
||||||
|---------------------|---------|----------------------------------------------------|
|
|
||||||
| `CHUNK_SIZE_X` | 32 | Chunk width in world units (tiles). |
|
|
||||||
| `CHUNK_SIZE_Y` | 32 | Chunk height in world units (tiles). |
|
|
||||||
| `CHUNK_SIZE_Z` | 1 | Chunk depth (1 = flat 2D world; >1 = volumetric). |
|
|
||||||
| `CHUNK_ORIGIN` | `(0,0)` | World-space origin of chunk coordinate `(0,0)`. |
|
|
||||||
|
|
||||||
A chunk is identified by its **integer chunk coordinate** `(cx, cy, cz)`,
|
|
||||||
computed from a world-space position `p` as:
|
|
||||||
|
|
||||||
```
|
|
||||||
cx = floor((p.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)
|
|
||||||
cy = floor((p.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)
|
|
||||||
cz = floor((p.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z)
|
|
||||||
```
|
|
||||||
|
|
||||||
The chunk's world-space bounding box is therefore:
|
|
||||||
|
|
||||||
```
|
|
||||||
min = (CHUNK_ORIGIN.x + cx*CHUNK_SIZE_X, ...)
|
|
||||||
max = min + (CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Chunk Coordinate Key
|
|
||||||
|
|
||||||
For storage and lookup, a chunk is addressed by a **canonical string key**:
|
|
||||||
|
|
||||||
```
|
|
||||||
"{world_id}:{room_id}:{cx}:{cy}:{cz}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Example: `"w_0001:r_0001:12:-4:0"`. This key is the primary lookup key in the
|
|
||||||
SQLite `chunks` table and is used in the read/write API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Chunk Data Format
|
|
||||||
|
|
||||||
### 3.1 Logical Model
|
|
||||||
|
|
||||||
Both the JSON and binary encodings serialize the same logical chunk model. The
|
|
||||||
logical model is defined here once; the two encodings are projections of it.
|
|
||||||
|
|
||||||
```
|
|
||||||
Chunk
|
|
||||||
├── version : uint (format version, currently 1)
|
|
||||||
├── key : string (canonical chunk key, see §2.3)
|
|
||||||
├── world_id : string
|
|
||||||
├── room_id : string
|
|
||||||
├── coord : { cx, cy, cz } (int)
|
|
||||||
├── bounds : { min_x, min_y, min_z, max_x, max_y, max_z } (float)
|
|
||||||
├── tiles : TileLayer[] (terrain / tile data)
|
|
||||||
├── objects : WorldObject[] (static + dynamic objects)
|
|
||||||
├── flags : ContentFlags (moderation flags, see §3.5)
|
|
||||||
├── meta : { created_at, updated_at, revision, author }
|
|
||||||
└── checksum : string (SHA-256 of canonical payload)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Tile Layer
|
|
||||||
|
|
||||||
A chunk's terrain is stored as one or more **tile layers**. Each layer is a
|
|
||||||
dense grid of tile IDs of size `CHUNK_SIZE_X × CHUNK_SIZE_Y` (× `CHUNK_SIZE_Z`
|
|
||||||
for volumetric worlds).
|
|
||||||
|
|
||||||
```
|
|
||||||
TileLayer
|
|
||||||
├── name : string (e.g. "ground", "walls", "decor")
|
|
||||||
├── layer_id : uint
|
|
||||||
├── width : uint (= CHUNK_SIZE_X)
|
|
||||||
├── height : uint (= CHUNK_SIZE_Y)
|
|
||||||
├── depth : uint (= CHUNK_SIZE_Z)
|
|
||||||
├── tiles : uint[] (flattened tile IDs, row-major, length = w*h*d)
|
|
||||||
└── palette : TileDef[] (tile ID → tile definition, shared per layer)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tile encoding:** A tile ID is a `uint16` index into the layer's `palette`.
|
|
||||||
`0` is reserved for "empty/air". The palette maps each tile ID to a `TileDef`:
|
|
||||||
|
|
||||||
```
|
|
||||||
TileDef
|
|
||||||
├── id : uint16
|
|
||||||
├── asset_id : string (reference to a tile/terrain asset)
|
|
||||||
├── solid : bool (collision)
|
|
||||||
├── flags : uint32 (bitmask: walkable, climbable, liquid, ...)
|
|
||||||
└── variants : uint8 (number of visual variants for this tile)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rationale for palette + indices:** Storing dense `uint16` indices rather than
|
|
||||||
full asset strings per cell keeps the binary payload small (2 bytes/cell) while
|
|
||||||
the palette (shared across the layer) carries the rich per-tile metadata.
|
|
||||||
|
|
||||||
### 3.3 World Objects
|
|
||||||
|
|
||||||
Objects are the non-tile entities in a chunk: static props, interactables,
|
|
||||||
spawn points, and dynamic-object templates.
|
|
||||||
|
|
||||||
```
|
|
||||||
WorldObject
|
|
||||||
├── id : string (stable object id, unique within the chunk)
|
|
||||||
├── type : string (e.g. "prop", "interactable", "spawn_point", "door")
|
|
||||||
├── asset_id : string (reference to the object's mesh/glTF asset)
|
|
||||||
├── transform : { pos: [x,y,z], rot: [x,y,z,w], scale: [x,y,z] }
|
|
||||||
├── properties : object (type-specific key/value map, JSON-typed)
|
|
||||||
├── dynamic : bool (true if this is a spawnable dynamic entity)
|
|
||||||
└── flags : ContentFlags (per-object moderation flags)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dynamic objects** are stored as *templates* in the chunk. Their *runtime
|
|
||||||
state* (position, health, inventory) is **not** stored in the chunk; it lives
|
|
||||||
in the runtime entity store and is persisted separately (see §4.4). This keeps
|
|
||||||
chunks stable and diffable — a chunk only changes when its *static* content
|
|
||||||
changes.
|
|
||||||
|
|
||||||
### 3.4 Content Flags
|
|
||||||
|
|
||||||
Each chunk (and each object) carries a **content flag set** that is the
|
|
||||||
aggregate of the moderation flags of the assets and scripts it references. This
|
|
||||||
mirrors the aggregation rules in `content_moderation.md`:
|
|
||||||
|
|
||||||
```
|
|
||||||
ContentFlags
|
|
||||||
├── categories : { violence, sexual, language, substance, gambling, horror, hate, custom }
|
|
||||||
│ (each → severity: none|mild|moderate|severe)
|
|
||||||
├── mature : bool (true if any contributing item is mature)
|
|
||||||
└── sources : string[] (flag ids that contributed, for audit)
|
|
||||||
```
|
|
||||||
|
|
||||||
The server computes and persists these flags at write time so that enforcement
|
|
||||||
(age gating, content filtering) can happen at load time without re-scanning
|
|
||||||
assets.
|
|
||||||
|
|
||||||
### 3.5 JSON Encoding
|
|
||||||
|
|
||||||
The JSON encoding is the **authoring and debugging** representation. It is
|
|
||||||
human-readable, diffable, and used by tools and tests. It is the canonical
|
|
||||||
logical model serialized directly.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"key": "w_0001:r_0001:12:-4:0",
|
|
||||||
"world_id": "w_0001",
|
|
||||||
"room_id": "r_0001",
|
|
||||||
"coord": { "cx": 12, "cy": -4, "cz": 0 },
|
|
||||||
"bounds": { "min_x": 384.0, "min_y": -128.0, "min_z": 0.0,
|
|
||||||
"max_x": 416.0, "max_y": -96.0, "max_z": 1.0 },
|
|
||||||
"tiles": [
|
|
||||||
{
|
|
||||||
"name": "ground",
|
|
||||||
"layer_id": 0,
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"depth": 1,
|
|
||||||
"palette": [
|
|
||||||
{ "id": 0, "asset_id": "", "solid": false, "flags": 0, "variants": 1 },
|
|
||||||
{ "id": 1, "asset_id": "tiles/grass", "solid": false, "flags": 1, "variants": 4 }
|
|
||||||
],
|
|
||||||
"tiles": [0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"id": "obj_0001",
|
|
||||||
"type": "interactable",
|
|
||||||
"asset_id": "props/chest",
|
|
||||||
"transform": { "pos": [400.0, -110.0, 0.0],
|
|
||||||
"rot": [0.0, 0.0, 0.0, 1.0],
|
|
||||||
"scale": [1.0, 1.0, 1.0] },
|
|
||||||
"properties": { "loot_table": "chest_basic", "locked": false },
|
|
||||||
"dynamic": false,
|
|
||||||
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] },
|
|
||||||
"meta": { "created_at": "2026-08-17T00:00:00Z",
|
|
||||||
"updated_at": "2026-08-17T00:00:00Z",
|
|
||||||
"revision": 3,
|
|
||||||
"author": "agent-bot" },
|
|
||||||
"checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note on the example checksum:** The value shown above is illustrative only.
|
|
||||||
> In practice the `checksum` field is the SHA-256 of the canonical binary
|
|
||||||
> payload body (see §3.6.3), not of the JSON text. The value here is a
|
|
||||||
> placeholder for documentation purposes.
|
|
||||||
|
|
||||||
### 3.6 Binary Encoding
|
|
||||||
|
|
||||||
The binary encoding is the **runtime and network** representation. It is compact
|
|
||||||
and byte-stable. It uses a simple, explicitly-specified layout (no external
|
|
||||||
serialization library) so it can be produced and consumed identically by the
|
|
||||||
Godot client (GDScript) and the Python backend.
|
|
||||||
|
|
||||||
#### 3.6.1 Header
|
|
||||||
|
|
||||||
| Offset | Size | Field | Type / Encoding |
|
|
||||||
|--------|------|--------------|------------------------------------------|
|
|
||||||
| 0 | 4 | magic | ASCII `"AGPC"` (0x41 0x47 0x50 0x43) |
|
|
||||||
| 4 | 2 | version | `uint16` LE (currently `1`) |
|
|
||||||
| 6 | 2 | header_len | `uint16` LE (bytes of header after this) |
|
|
||||||
| 8 | 4 | flags | `uint32` LE (bitmask, reserved) |
|
|
||||||
| 12 | 4 | payload_len | `uint32` LE (bytes of payload body) |
|
|
||||||
| 16 | 32 | checksum | SHA-256 of payload body (hex, 32 bytes) |
|
|
||||||
|
|
||||||
Total header size: **48 bytes**.
|
|
||||||
|
|
||||||
#### 3.6.2 Payload Body
|
|
||||||
|
|
||||||
The payload body is a sequence of typed sections. Each section has a small
|
|
||||||
header:
|
|
||||||
|
|
||||||
| Offset | Size | Field | Type / Encoding |
|
|
||||||
|--------|------|------------|----------------------------|
|
|
||||||
| 0 | 1 | section_id | `uint8` (see table below) |
|
|
||||||
| 1 | 4 | section_len| `uint32` LE (bytes of section data) |
|
|
||||||
| 5 | ... | data | section-specific |
|
|
||||||
|
|
||||||
Section IDs:
|
|
||||||
|
|
||||||
| section_id | Section | Data layout |
|
|
||||||
|------------|----------------|-------------------------------------------------------------------|
|
|
||||||
| `0x01` | metadata | world_id, room_id (length-prefixed strings), coord (3×int32), bounds (6×float32) |
|
|
||||||
| `0x02` | tile layer | name, layer_id, dims (3×uint16), palette, dense tile indices |
|
|
||||||
| `0x03` | object | one `WorldObject` (repeated; one section per object) |
|
|
||||||
| `0x04` | content flags | category severities (8×uint8), mature (uint8), sources (string list) |
|
|
||||||
| `0x05` | meta | created_at, updated_at (int64 unix), revision (uint32), author (string) |
|
|
||||||
|
|
||||||
**String encoding:** length-prefixed UTF-8 — `uint16` LE byte length followed by
|
|
||||||
the UTF-8 bytes.
|
|
||||||
|
|
||||||
**Tile layer data layout (section `0x02`):**
|
|
||||||
|
|
||||||
```
|
|
||||||
name : string
|
|
||||||
layer_id : uint16 LE
|
|
||||||
width : uint16 LE
|
|
||||||
height : uint16 LE
|
|
||||||
depth : uint16 LE
|
|
||||||
palette_count : uint16 LE
|
|
||||||
palette : palette_count × TileDef
|
|
||||||
tile_count : uint32 LE
|
|
||||||
tiles : tile_count × uint16 LE (dense, row-major)
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
TileDef:
|
|
||||||
id : uint16 LE
|
|
||||||
asset_id : string
|
|
||||||
solid : uint8
|
|
||||||
flags : uint32 LE
|
|
||||||
variants : uint8
|
|
||||||
```
|
|
||||||
|
|
||||||
**Object data layout (section `0x03`):**
|
|
||||||
|
|
||||||
```
|
|
||||||
id : string
|
|
||||||
type : string
|
|
||||||
asset_id : string
|
|
||||||
pos : 3 × float32
|
|
||||||
rot : 4 × float32
|
|
||||||
scale : 3 × float32
|
|
||||||
dynamic : uint8
|
|
||||||
properties : JSON string (length-prefixed)
|
|
||||||
flags : ContentFlags (see 0x04 layout)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Content flags layout (section `0x04`):**
|
|
||||||
|
|
||||||
```
|
|
||||||
violence, sexual, language, substance, gambling, horror, hate, custom
|
|
||||||
: 8 × uint8 (severity enum: 0=none,1=mild,2=moderate,3=severe)
|
|
||||||
mature : uint8
|
|
||||||
source_count: uint16 LE
|
|
||||||
sources : source_count × string
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.6.3 Byte-Stability
|
|
||||||
|
|
||||||
For a given logical payload, the binary encoding is **deterministic**:
|
|
||||||
|
|
||||||
- All integers are little-endian.
|
|
||||||
- All strings are UTF-8, length-prefixed, and never null-terminated.
|
|
||||||
- Maps (e.g. `properties`) are serialized as a **canonical JSON string** with
|
|
||||||
keys sorted lexicographically.
|
|
||||||
- The `checksum` is the SHA-256 of the payload body bytes.
|
|
||||||
|
|
||||||
This guarantees that two encoders produce identical bytes, enabling content
|
|
||||||
addressing, caching, and reproducible tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. SQLite Persistence Schema
|
|
||||||
|
|
||||||
The server persists world state in a **SQLite** database. SQLite is chosen for
|
|
||||||
its zero-configuration, single-file, transactional, and embedded nature — ideal
|
|
||||||
for a server that owns the world store and needs ACID guarantees without a
|
|
||||||
separate database process.
|
|
||||||
|
|
||||||
### 4.1 Database Layout
|
|
||||||
|
|
||||||
The database file is `world.db` (path configurable). It contains the following
|
|
||||||
tables:
|
|
||||||
|
|
||||||
```
|
|
||||||
worlds
|
|
||||||
rooms
|
|
||||||
chunks
|
|
||||||
chunk_objects
|
|
||||||
content_flags
|
|
||||||
revisions
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 `worlds`
|
|
||||||
|
|
||||||
Top-level world containers.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE worlds (
|
|
||||||
id TEXT PRIMARY KEY, -- e.g. 'w_0001'
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
chunk_size_x INTEGER NOT NULL DEFAULT 32,
|
|
||||||
chunk_size_y INTEGER NOT NULL DEFAULT 32,
|
|
||||||
chunk_size_z INTEGER NOT NULL DEFAULT 1,
|
|
||||||
origin_x REAL NOT NULL DEFAULT 0,
|
|
||||||
origin_y REAL NOT NULL DEFAULT 0,
|
|
||||||
origin_z REAL NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL, -- ISO-8601 UTC
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 `rooms`
|
|
||||||
|
|
||||||
Rooms within a world.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE rooms (
|
|
||||||
id TEXT PRIMARY KEY, -- e.g. 'r_0001'
|
|
||||||
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
bounds_min_x REAL NOT NULL,
|
|
||||||
bounds_min_y REAL NOT NULL,
|
|
||||||
bounds_min_z REAL NOT NULL DEFAULT 0,
|
|
||||||
bounds_max_x REAL NOT NULL,
|
|
||||||
bounds_max_y REAL NOT NULL,
|
|
||||||
bounds_max_z REAL NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
UNIQUE (world_id, name)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_rooms_world ON rooms(world_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 `chunks`
|
|
||||||
|
|
||||||
The core persistence table. One row per chunk. The **payload** is stored as a
|
|
||||||
BLOB in the **binary format** (§3.6); the JSON form is derived on demand for
|
|
||||||
authoring/debugging and is not stored (to avoid dual-write drift).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE chunks (
|
|
||||||
key TEXT PRIMARY KEY, -- canonical key 'world:room:cx:cy:cz'
|
|
||||||
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
|
||||||
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
||||||
cx INTEGER NOT NULL,
|
|
||||||
cy INTEGER NOT NULL,
|
|
||||||
cz INTEGER NOT NULL DEFAULT 0,
|
|
||||||
format_version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
payload BLOB NOT NULL, -- binary chunk encoding (§3.6)
|
|
||||||
payload_len INTEGER NOT NULL,
|
|
||||||
checksum TEXT NOT NULL, -- SHA-256 of payload
|
|
||||||
revision INTEGER NOT NULL DEFAULT 0,
|
|
||||||
dirty INTEGER NOT NULL DEFAULT 0, -- 1 = needs flush to disk
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
UNIQUE (world_id, room_id, cx, cy, cz)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_chunks_world_room ON chunks(world_id, room_id);
|
|
||||||
CREATE INDEX idx_chunks_coord ON chunks(cx, cy, cz);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Design notes:**
|
|
||||||
|
|
||||||
- The `payload` BLOB is the single source of truth for chunk content. The
|
|
||||||
`checksum` guards against corruption and enables content addressing.
|
|
||||||
- `revision` increments on every write; it is used for optimistic concurrency
|
|
||||||
and change detection (see §5.4).
|
|
||||||
- `dirty` marks chunks that have been modified in memory but not yet flushed,
|
|
||||||
enabling a write-behind persistence strategy (see §5.5).
|
|
||||||
|
|
||||||
### 4.5 `chunk_objects`
|
|
||||||
|
|
||||||
A denormalized, queryable index of the objects inside each chunk. This is
|
|
||||||
**derived** from the chunk payload and kept in sync by the write path. It exists
|
|
||||||
so the server can answer queries like "which chunks contain a door?" without
|
|
||||||
parsing every payload BLOB.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE chunk_objects (
|
|
||||||
id TEXT NOT NULL, -- object id (unique within chunk)
|
|
||||||
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
|
|
||||||
type TEXT NOT NULL, -- 'prop', 'interactable', ...
|
|
||||||
asset_id TEXT,
|
|
||||||
dynamic INTEGER NOT NULL DEFAULT 0,
|
|
||||||
pos_x REAL NOT NULL,
|
|
||||||
pos_y REAL NOT NULL,
|
|
||||||
pos_z REAL NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (chunk_key, id)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_chunk_objects_type ON chunk_objects(type);
|
|
||||||
CREATE INDEX idx_chunk_objects_asset ON chunk_objects(asset_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sync mechanism:** `chunk_objects` is maintained **explicitly by the write
|
|
||||||
path** — it is not a SQL trigger. Whenever `put_chunk` (or `delete_chunk`)
|
|
||||||
commits a chunk, the service layer performs a **delete-and-reinsert** of that
|
|
||||||
chunk's object rows in the same transaction: it deletes all rows for
|
|
||||||
`chunk_key`, then inserts fresh rows parsed from the new payload. This keeps the
|
|
||||||
index transactionally consistent with the payload and avoids the complexity and
|
|
||||||
fragility of SQL triggers over a binary BLOB. Because the index is derived, it
|
|
||||||
can always be rebuilt from `chunks.payload` if it ever drifts (a `rebuild_index`
|
|
||||||
utility is provided for this).
|
|
||||||
|
|
||||||
### 4.6 `content_flags`
|
|
||||||
|
|
||||||
Persists the aggregated moderation flags per chunk (and optionally per object).
|
|
||||||
This lets the server enforce age/content gates at load time without parsing
|
|
||||||
payloads.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE content_flags (
|
|
||||||
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
|
|
||||||
object_id TEXT NOT NULL DEFAULT '', -- '' = chunk-level flag (sentinel)
|
|
||||||
violence INTEGER NOT NULL DEFAULT 0,
|
|
||||||
sexual INTEGER NOT NULL DEFAULT 0,
|
|
||||||
language INTEGER NOT NULL DEFAULT 0,
|
|
||||||
substance INTEGER NOT NULL DEFAULT 0,
|
|
||||||
gambling INTEGER NOT NULL DEFAULT 0,
|
|
||||||
horror INTEGER NOT NULL DEFAULT 0,
|
|
||||||
hate INTEGER NOT NULL DEFAULT 0,
|
|
||||||
custom INTEGER NOT NULL DEFAULT 0,
|
|
||||||
mature INTEGER NOT NULL DEFAULT 0,
|
|
||||||
sources TEXT NOT NULL DEFAULT '[]', -- JSON array of flag ids
|
|
||||||
PRIMARY KEY (chunk_key, object_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_content_flags_mature ON content_flags(mature);
|
|
||||||
```
|
|
||||||
|
|
||||||
Severity is stored as `0=none, 1=mild, 2=moderate, 3=severe`.
|
|
||||||
|
|
||||||
**Chunk-level vs. object-level flags:** Chunk-level flags use the **sentinel
|
|
||||||
value `''` (empty string)** in `object_id` rather than `NULL`. This is
|
|
||||||
deliberate: in SQLite, `NULL` values in a composite primary key are **not**
|
|
||||||
unique-constrained, so two rows with the same `chunk_key` and `object_id = NULL`
|
|
||||||
could be inserted accidentally. Using `''` as the sentinel means the composite
|
|
||||||
`PRIMARY KEY (chunk_key, object_id)` enforces that there is **exactly one**
|
|
||||||
chunk-level flag row per chunk. Object-level flags use the object's actual `id`
|
|
||||||
(which is never empty). The application layer treats `object_id = ''` as
|
|
||||||
"chunk-level" and any non-empty value as "object-level".
|
|
||||||
|
|
||||||
### 4.7 `revisions`
|
|
||||||
|
|
||||||
An append-only audit log of chunk writes. Enables rollback, auditing, and
|
|
||||||
replication of changes.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE revisions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
chunk_key TEXT NOT NULL, -- NOT a FK: survives chunk deletion
|
|
||||||
revision INTEGER NOT NULL,
|
|
||||||
actor TEXT NOT NULL, -- user / agent id
|
|
||||||
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
|
|
||||||
checksum TEXT NOT NULL, -- SHA-256 of payload
|
|
||||||
payload BLOB, -- full binary payload at this revision
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_revisions_chunk ON revisions(chunk_key);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rollback support:** The `payload` BLOB stores the **full binary chunk payload**
|
|
||||||
at each revision. This is what makes rollback possible: to restore a chunk to a
|
|
||||||
previous revision, the server reads the `payload` of the target revision and
|
|
||||||
writes it back to `chunks.payload` (incrementing `revision` and appending a new
|
|
||||||
`revisions` row). Without the payload, only the checksum would be available,
|
|
||||||
which is insufficient to reconstruct the chunk.
|
|
||||||
|
|
||||||
**Audit trail preservation:** `chunk_key` is **not** a foreign key to `chunks`.
|
|
||||||
This means deleting a chunk does **not** cascade-delete its revision history —
|
|
||||||
the audit trail is preserved even after a chunk is removed. (The `chunk_key`
|
|
||||||
string is still stored so the history can be correlated to a chunk that existed
|
|
||||||
at that time.) If a chunk is later recreated with the same key, its history is
|
|
||||||
still intact.
|
|
||||||
|
|
||||||
### 4.8 Schema Versioning & Migrations
|
|
||||||
|
|
||||||
The database carries a schema version in a `meta` table:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE meta (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
-- seed: INSERT INTO meta(key, value) VALUES ('schema_version', '1');
|
|
||||||
```
|
|
||||||
|
|
||||||
Migrations are applied in order at startup. Each migration is a numbered SQL
|
|
||||||
script; the server tracks the applied version in `meta` and applies any pending
|
|
||||||
migrations transactionally. This keeps the schema forward-compatible as the
|
|
||||||
chunk format evolves.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Read/Write API
|
|
||||||
|
|
||||||
The API is exposed as a **Python service layer** on the backend (the same
|
|
||||||
process that owns the SQLite store) and consumed by the Godot client over the
|
|
||||||
network transport (see `networking.md`). The service layer is the **only**
|
|
||||||
writer to the database; clients never touch SQLite directly.
|
|
||||||
|
|
||||||
### 5.1 Service Interface (Python)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WorldStore:
|
|
||||||
"""Owns the SQLite connection and chunk read/write operations."""
|
|
||||||
|
|
||||||
def get_chunk(self, key: str) -> Chunk | None: ...
|
|
||||||
def get_chunk_binary(self, key: str) -> bytes | None: ...
|
|
||||||
def put_chunk(self, chunk: Chunk, actor: str) -> Chunk: ...
|
|
||||||
def delete_chunk(self, key: str, actor: str) -> None: ...
|
|
||||||
def list_chunks(self, world_id: str, room_id: str | None = None,
|
|
||||||
bounds: Bounds | None = None) -> list[str]: ...
|
|
||||||
def get_chunk_flags(self, key: str) -> ContentFlags: ...
|
|
||||||
def flush_dirty(self) -> int: ...
|
|
||||||
def rollback_chunk(self, key: str, revision: int, actor: str) -> Chunk: ...
|
|
||||||
def rebuild_index(self, chunk_key: str | None = None) -> int: ...
|
|
||||||
def migrate(self) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Encoding Helpers
|
|
||||||
|
|
||||||
The service layer provides symmetric encode/decode functions shared by the
|
|
||||||
client and server:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def encode_binary(chunk: Chunk) -> bytes: ...
|
|
||||||
def decode_binary(data: bytes) -> Chunk: ...
|
|
||||||
def encode_json(chunk: Chunk) -> str: ...
|
|
||||||
def decode_json(text: str) -> Chunk: ...
|
|
||||||
def checksum(payload: bytes) -> str: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
These are the single implementation of the format spec (§3), so the Godot
|
|
||||||
client and Python backend never drift.
|
|
||||||
|
|
||||||
### 5.3 Network RPCs
|
|
||||||
|
|
||||||
The chunk API is exposed to the client via RPCs (matching the networking
|
|
||||||
architecture). All chunk traffic is **reliable** (chunks are discrete,
|
|
||||||
must-arrive data).
|
|
||||||
|
|
||||||
| RPC | Direction | Payload | Notes |
|
|
||||||
|----------------------------|-----------|-------------------------------------------|-------|
|
|
||||||
| `request_chunk` | C → S | `{ key }` | Client asks for a chunk |
|
|
||||||
| `chunk_data` | S → C | `{ key, binary_payload, checksum }` | Server streams a chunk |
|
|
||||||
| `request_chunk_edit` | C → S | `{ key, base_revision, delta }` | Client submits an edit |
|
|
||||||
| `chunk_edit_result` | S → C | `{ key, success, new_revision, error? }` | Acknowledges an edit |
|
|
||||||
| `chunk_invalidated` | S → C | `{ key, new_revision }` | Server pushes a change |
|
|
||||||
| `request_chunk_flags` | C → S | `{ key }` | Age/content gate check |
|
|
||||||
|
|
||||||
### 5.4 Edit & Concurrency Model
|
|
||||||
|
|
||||||
- **Read:** Client requests a chunk; server returns the binary payload and its
|
|
||||||
`revision`.
|
|
||||||
- **Edit:** Client submits a **delta** against a known `base_revision`. The
|
|
||||||
server applies the delta, increments `revision`, persists, and returns the new
|
|
||||||
revision.
|
|
||||||
- **Optimistic concurrency:** If the chunk's current revision differs from the
|
|
||||||
client's `base_revision`, the server rejects the edit with a conflict error
|
|
||||||
and the client re-fetches and re-applies. This prevents lost updates when two
|
|
||||||
clients edit the same chunk.
|
|
||||||
- **Deltas:** A delta is a JSON patch (RFC 6902 style) applied to the chunk's
|
|
||||||
logical model. The server validates the patched chunk (schema, bounds,
|
|
||||||
moderation) before persisting.
|
|
||||||
|
|
||||||
### 5.5 Write-Behind Persistence
|
|
||||||
|
|
||||||
To amortize disk I/O, the server keeps a **dirty set** of chunks modified in
|
|
||||||
memory and flushes them to SQLite in batches:
|
|
||||||
|
|
||||||
1. `put_chunk` updates the in-memory chunk and marks it `dirty`.
|
|
||||||
2. A background flusher periodically calls `flush_dirty()`, which writes all
|
|
||||||
dirty chunks in a single transaction and clears the `dirty` flag.
|
|
||||||
3. On graceful shutdown, `flush_dirty()` is called to ensure durability.
|
|
||||||
|
|
||||||
This keeps the hot path (in-memory chunk access) fast while guaranteeing
|
|
||||||
durability at shutdown and on a configurable interval.
|
|
||||||
|
|
||||||
**Recovery after unclean shutdown:** The `dirty` column in the `chunks` table
|
|
||||||
exists specifically to support recovery if the process crashes before a flush
|
|
||||||
completes. On startup, the server scans for rows where `dirty = 1` and treats
|
|
||||||
them as **unflushed in-memory state that was lost** — it re-reads the last
|
|
||||||
persisted payload from `chunks.payload` (the last durable state) and clears the
|
|
||||||
`dirty` flag. In other words, `dirty = 1` at startup means "the last write was
|
|
||||||
not durable; discard the in-memory delta and fall back to the persisted
|
|
||||||
payload." This guarantees that the on-disk state is always a consistent,
|
|
||||||
previously-committed snapshot, never a partially-written chunk. (The in-memory
|
|
||||||
dirty set is authoritative during normal operation; the `dirty` column is only a
|
|
||||||
recovery marker.)
|
|
||||||
|
|
||||||
### 5.6 Streaming & Caching
|
|
||||||
|
|
||||||
- The server streams only the chunks a client needs (those within the client's
|
|
||||||
view radius), using `list_chunks` with a bounds query.
|
|
||||||
- Chunks are cached in memory (LRU) keyed by `key`; the cache is invalidated on
|
|
||||||
write.
|
|
||||||
- The binary payload is content-addressed by `checksum`, so unchanged chunks can
|
|
||||||
be served from cache without re-reading SQLite.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Format Versioning & Migration
|
|
||||||
|
|
||||||
- The chunk format `version` field (currently `1`) is checked on decode. A
|
|
||||||
decoder that encounters a higher version than it supports **rejects** the
|
|
||||||
chunk with a clear error rather than misreading it.
|
|
||||||
- The SQLite `schema_version` in `meta` drives database migrations (§4.8).
|
|
||||||
- Chunk payloads are migrated **lazily**: when a chunk is read and its
|
|
||||||
`format_version` is older than the current one, the server migrates it to the
|
|
||||||
current version in memory and marks it dirty so it is rewritten on the next
|
|
||||||
flush. This avoids a costly full-world migration at startup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Security & Integrity
|
|
||||||
|
|
||||||
- **Server-authoritative writes:** Only the `WorldStore` writes to SQLite;
|
|
||||||
clients submit deltas that are validated before application.
|
|
||||||
- **Checksum verification:** Every chunk payload is checksummed (SHA-256) on
|
|
||||||
write and verified on read. Corruption is detected and the chunk is re-fetched
|
|
||||||
or flagged.
|
|
||||||
- **Bounds validation:** Chunk coordinates and object positions are validated
|
|
||||||
against the world's chunk dimensions and room bounds before persistence.
|
|
||||||
- **Moderation enforcement:** `content_flags` are checked at load time; mature
|
|
||||||
chunks are gated behind the age-verification flow (see `content_moderation.md`).
|
|
||||||
- **Input validation:** All string fields are length-limited and validated
|
|
||||||
against allowed character sets to prevent injection and oversized payloads.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Open Questions / Future Work
|
|
||||||
|
|
||||||
- **Volumetric worlds:** The schema and format already support `CHUNK_SIZE_Z >
|
|
||||||
1`, but the tooling and streaming for 3D chunks are not yet specified.
|
|
||||||
- **Chunk compression:** For very large worlds, the binary payload could be
|
|
||||||
compressed (e.g. zstd) before storage/transfer. The header reserves a `flags`
|
|
||||||
bitmask for a compression flag.
|
|
||||||
- **Multi-server sharding:** If a single SQLite file becomes a bottleneck, the
|
|
||||||
`chunks` table could be sharded by `world_id` across multiple databases. The
|
|
||||||
key design already supports this (world_id is a leading key component).
|
|
||||||
- **Delta compression for streaming:** Instead of sending full chunks, the
|
|
||||||
server could send only the delta between revisions to reduce bandwidth for
|
|
||||||
frequently-updated chunks.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# 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
@@ -1,50 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
# Economy — Currency, Marketplace & Creator Payouts — Plan
|
|
||||||
|
|
||||||
> **Status:** Planned
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Feature ID:** `economy`
|
|
||||||
> **Godot version:** 4.6.2 stable
|
|
||||||
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document breaks the **Economy** feature into user stories and right-sized
|
|
||||||
implementation tasks. The feature delivers:
|
|
||||||
|
|
||||||
- **Virtual currency ("Credits")** — a single, server-authoritative currency
|
|
||||||
with an immutable, append-only transaction ledger.
|
|
||||||
- **Premium purchase** — buying Credits with real money through a pluggable
|
|
||||||
payment-provider hook.
|
|
||||||
- **Marketplace** — user-generated content (UGC) listings where creators sell
|
|
||||||
items (assets, avatar items, experience passes) to players for Credits.
|
|
||||||
- **Creator payouts** — accumulating creator earnings and paying them out to
|
|
||||||
external accounts through a pluggable payout-provider hook.
|
|
||||||
- **Godot client** — wallet/transaction history, marketplace browse/buy/sell,
|
|
||||||
and creator payout screens.
|
|
||||||
|
|
||||||
The economy is the monetization backbone of the platform. Because it moves
|
|
||||||
virtual value between real people, correctness (no double-spend, no lost funds,
|
|
||||||
no balance drift) and safety (fraud prevention, moderation, audit) are treated
|
|
||||||
as **blocking requirements**, not add-ons.
|
|
||||||
|
|
||||||
## 2. Why correctness is the differentiator
|
|
||||||
|
|
||||||
The platform is a creator economy (players buy UGC from other players). The
|
|
||||||
economy is the highest-risk surface after chat: a single arithmetic or
|
|
||||||
concurrency bug can mint or destroy value and irreversibly erode trust.
|
|
||||||
Correctness is therefore designed in from the start:
|
|
||||||
|
|
||||||
- **One currency, integer units.** Credits are whole-number integers. There are
|
|
||||||
no floats, no rounding, and no client-computed totals.
|
|
||||||
- **Append-only ledger.** Every balance change is a row in a `transactions`
|
|
||||||
table with a signed amount, a `balance_after` snapshot, a reference to the
|
|
||||||
event that caused it, and an **idempotency key** so retries cannot double
|
|
||||||
credit or double debit.
|
|
||||||
- **Atomic, locked mutations.** Balance changes run in a single database
|
|
||||||
transaction with row locking (`SELECT ... FOR UPDATE`) so concurrent
|
|
||||||
purchases/payouts cannot race.
|
|
||||||
- **Escrow hold.** Marketplace proceeds are held in `pending_escrow` for a
|
|
||||||
configurable window before becoming available to the seller, so fraudulent
|
|
||||||
or moderated sales can be reversed without clawing back paid-out funds.
|
|
||||||
- **Server-authoritative fees.** The platform commission is computed
|
|
||||||
server-side from a configured rate; the client never supplies the fee or the
|
|
||||||
seller's net.
|
|
||||||
|
|
||||||
## 3. Existing constraints (do not re-build)
|
|
||||||
|
|
||||||
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
|
|
||||||
`app/dependencies.py`. All economy endpoints reuse this.
|
|
||||||
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
|
|
||||||
Existing tables: `users`, `sessions`, `worlds`, `assets`.
|
|
||||||
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
|
|
||||||
idempotency short-caching and rate limiting.
|
|
||||||
- **Models:** `User` (owner_id), `Asset` (owner_id, is_public, asset_type).
|
|
||||||
Marketplace items reference existing `assets` rows and/or avatar items.
|
|
||||||
- **Content moderation:** `docs/architecture/content_moderation.md` defines
|
|
||||||
flag vocabulary, age gate, and moderation actions. Marketplace items and
|
|
||||||
listings must inherit and enforce these rules (see §7).
|
|
||||||
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no economy UI yet.
|
|
||||||
|
|
||||||
## 4. Key design decisions
|
|
||||||
|
|
||||||
| Concern | Decision |
|
|
||||||
|---|---|
|
|
||||||
| Currency model | Single virtual currency **"Credits" (CR)**, integer units. One wallet row per user; no fractional amounts. |
|
|
||||||
| Ledger | Append-only `transactions` table: signed `amount`, `balance_after`, `reference_type`/`reference_id`, unique `idempotency_key`. |
|
|
||||||
| Concurrency | `SELECT ... FOR UPDATE` on the wallet row inside the mutation transaction; never read-modify-write from stale data. |
|
|
||||||
| Payments | Pluggable `PaymentProvider` interface with a **no-op default** that simulates a confirmed payment for dev/testing (clearly flagged). Stripe/PayPal later. |
|
|
||||||
| Marketplace items | `catalog_items` (what is sold) + `marketplace_listings` (a seller's offer with a price/status). One item can have multiple listings over time. |
|
|
||||||
| Purchase flow | Atomic transaction: debit buyer → hold funds in escrow (`pending_escrow`) → grant item. Platform fee computed server-side. |
|
|
||||||
| Escrow | Configurable hold window (default 72h) before `pending_escrow` becomes available seller earnings. Refunds during the window reverse the sale. |
|
|
||||||
| Fees | Single configurable `MARKETPLACE_FEE_RATE` (default `0.30`). `seller_net = floor(price * (1 - fee_rate))`. |
|
|
||||||
| Creator balance | `creator_balances` table: `available` (earned, payable), `pending` (inside an open payout request), `lifetime_earned`. Updated in the same transaction as the sale completion. |
|
|
||||||
| Payouts | Pluggable `PayoutProvider.pay()` with a no-op default (simulated external ref). Minimum payout threshold, admin review for large payouts. |
|
|
||||||
| Idempotency | Every money-moving request carries a client-generated `Idempotency-Key`; duplicate keys return the original result. |
|
|
||||||
| Rate limiting | Redis token-bucket dependency reused from the social feature for purchase/listing/payout endpoints. |
|
|
||||||
| Moderation | Listings enforce content flags + age gate; moderators can `delist`/`remove`; fraud/chargeback reversal is an audited admin action. |
|
|
||||||
|
|
||||||
## 5. User stories
|
|
||||||
|
|
||||||
### US-1 Currency & wallet
|
|
||||||
As a player, I can see my Credit balance and transaction history, and the system
|
|
||||||
credits/debits my wallet only through server-authoritative events.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- Exactly one wallet per user, created lazily on first use.
|
|
||||||
- Balance is always a non-negative integer; no operation can make it negative.
|
|
||||||
- Every mutation appends an immutable ledger row with amount, resulting balance,
|
|
||||||
reference, and idempotency key.
|
|
||||||
- Retrying a request with the same idempotency key does not double-apply.
|
|
||||||
- `GET` endpoints expose current balance and paged transaction history.
|
|
||||||
|
|
||||||
### US-2 Premium purchase (buy Credits)
|
|
||||||
As a player, I can purchase Credits with real money and have them credited only
|
|
||||||
after the payment is confirmed.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- A purchase session is created through the payment provider (or the no-op
|
|
||||||
default) and is idempotent.
|
|
||||||
- Credits are granted exactly once, only after a verified provider confirmation
|
|
||||||
(webhook/confirm endpoint), never on client claim.
|
|
||||||
- Purchase amounts are bounded and server-defined; clients cannot pick arbitrary
|
|
||||||
prices.
|
|
||||||
- Confirmation is idempotent and audited (provider reference recorded).
|
|
||||||
|
|
||||||
### US-3 Marketplace listing (sell)
|
|
||||||
As a creator, I can list a catalog item for sale at a Credit price and
|
|
||||||
manage/remove my listings.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- A listing references a valid `catalog_item` owned by the seller (or an
|
|
||||||
authorized seller).
|
|
||||||
- Price is bounded (min/max) and set server-side; status transitions are
|
|
||||||
explicit (`active`, `sold`, `delisted`, `removed_by_moderation`).
|
|
||||||
- Only the seller (or a moderator) can update/remove a listing.
|
|
||||||
- Listings are searchable/filterable (item type, price range, owner).
|
|
||||||
|
|
||||||
### US-4 Marketplace purchase (buy)
|
|
||||||
As a player, I can buy a listed item. The purchase is atomic, holds funds in
|
|
||||||
escrow, grants the item, and splits the payment between platform and seller.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- A single transaction debits the buyer, holds funds in escrow, and grants the
|
|
||||||
item — no partial states on failure.
|
|
||||||
- Platform fee and seller net are computed server-side from the configured rate.
|
|
||||||
- After the escrow hold window, funds move from escrow to the seller's
|
|
||||||
available balance.
|
|
||||||
- Refunds during the hold window reverse the sale and return funds to the buyer.
|
|
||||||
- Concurrent purchases of a one-of-a-kind listing cannot both succeed.
|
|
||||||
|
|
||||||
### US-5 Creator payouts
|
|
||||||
As a creator, I can view my earned balance, request a payout, and track its
|
|
||||||
status; admins can review and approve/reject payouts.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- Available balance is derived only from completed (escrow-released) sales and
|
|
||||||
completed payouts; it never goes negative.
|
|
||||||
- Payout requests are bounded (min threshold, max per period) and rate-limited.
|
|
||||||
- Large payouts require admin review; all payout requests carry an audit trail
|
|
||||||
(requested by, reviewed by, provider reference, status).
|
|
||||||
- The payout provider is called exactly once; idempotent on retry.
|
|
||||||
|
|
||||||
### US-6 Godot client
|
|
||||||
As a player, I can manage Credits, browse/buy/sell marketplace items, and manage
|
|
||||||
payouts from the game UI.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- An API client handles JWT auth, HTTP, and idempotency keys for all economy
|
|
||||||
calls.
|
|
||||||
- Dedicated screens: wallet + transaction history, marketplace browse/buy,
|
|
||||||
sell-listing, and creator payout.
|
|
||||||
- The client never computes balances, fees, or seller nets — it only displays
|
|
||||||
server responses.
|
|
||||||
|
|
||||||
## 6. Task breakdown
|
|
||||||
|
|
||||||
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
|
|
||||||
are sequential for dependency chaining.
|
|
||||||
|
|
||||||
| # | Task | Story | Depends on |
|
|
||||||
|---|------|-------|-----------|
|
|
||||||
| 1 | Wallet & ledger models + Alembic migration (`wallets`, `transactions`) | US-1 | — |
|
|
||||||
| 2 | Wallet service (atomic credit/debit, idempotency, locking) + wallet/transactions API | US-1 | 1 |
|
|
||||||
| 3 | Premium purchase: `PaymentProvider` interface + purchase/confirm endpoints | US-2 | 1 |
|
|
||||||
| 4 | Catalog item & listing models + migration (`catalog_items`, `marketplace_listings`, `creator_balances`) | US-3 | 1 |
|
|
||||||
| 5 | Marketplace listing API (create/list/search/update/remove) | US-3 | 4 |
|
|
||||||
| 6 | Marketplace purchase service + API (atomic escrow, fee split, item grant) | US-4 | 2, 4, 5 |
|
|
||||||
| 7 | Creator payout request models + API (`payout_requests`) | US-5 | 2, 6 |
|
|
||||||
| 8 | Payout admin review + `PayoutProvider` + audit | US-5 | 7 |
|
|
||||||
| 9 | Marketplace moderation + content-flag/age-gate enforcement | cross-cutting | 5, 6 |
|
|
||||||
| 10 | Godot economy API client (auth + HTTP + idempotency) | US-6 | 2 |
|
|
||||||
| 11 | Godot wallet & transaction history UI | US-6 | 10 |
|
|
||||||
| 12 | Godot marketplace browse/buy UI | US-6 | 6, 10 |
|
|
||||||
| 13 | Godot selling & payout UI | US-6 | 5, 7, 10 |
|
|
||||||
|
|
||||||
## 7. Phasing
|
|
||||||
|
|
||||||
- **Phase 1 (currency + payments):** tasks 1–3. The foundation — ledger and
|
|
||||||
money movement must be rock-solid before anything spends Credits.
|
|
||||||
- **Phase 2 (marketplace):** tasks 4–6. Listing and atomic purchase with escrow.
|
|
||||||
- **Phase 3 (payouts):** tasks 7–8. Turn released escrow into creator payouts.
|
|
||||||
- **Phase 4 (moderation):** task 9. Lock down the marketplace against mature
|
|
||||||
content and abuse.
|
|
||||||
- **Phase 5 (client):** tasks 10–13, each landing after its backend dependency.
|
|
||||||
|
|
||||||
## 8. Data model sketch
|
|
||||||
|
|
||||||
```text
|
|
||||||
wallets
|
|
||||||
id bigint PK
|
|
||||||
user_id int UNIQUE FK -> users.id
|
|
||||||
balance bigint NOT NULL DEFAULT 0 CHECK (balance >= 0)
|
|
||||||
created_at timestamptz
|
|
||||||
updated_at timestamptz
|
|
||||||
|
|
||||||
transactions (append-only ledger)
|
|
||||||
id bigint PK
|
|
||||||
wallet_id bigint FK -> wallets.id
|
|
||||||
type enum(credit_purchase, marketplace_purchase,
|
|
||||||
marketplace_sale, escrow_release, payout,
|
|
||||||
refund, admin_adjustment)
|
|
||||||
amount bigint NOT NULL (signed)
|
|
||||||
balance_after bigint NOT NULL
|
|
||||||
reference_type text (e.g. "purchase", "sale", "payout_request")
|
|
||||||
reference_id text
|
|
||||||
idempotency_key text UNIQUE
|
|
||||||
metadata_json jsonb
|
|
||||||
created_at timestamptz
|
|
||||||
|
|
||||||
catalog_items
|
|
||||||
id bigint PK
|
|
||||||
owner_id int FK -> users.id
|
|
||||||
asset_id int FK -> assets.id (nullable; null for non-asset virtual items)
|
|
||||||
item_type enum(asset, avatar_item, experience_pass)
|
|
||||||
name text
|
|
||||||
description text
|
|
||||||
metadata_json jsonb
|
|
||||||
created_at timestamptz
|
|
||||||
|
|
||||||
marketplace_listings
|
|
||||||
id bigint PK
|
|
||||||
catalog_item_id bigint FK -> catalog_items.id
|
|
||||||
seller_id int FK -> users.id
|
|
||||||
price bigint NOT NULL CHECK (price > 0)
|
|
||||||
status enum(active, sold, delisted, removed_by_moderation)
|
|
||||||
created_at timestamptz
|
|
||||||
updated_at timestamptz
|
|
||||||
|
|
||||||
purchases
|
|
||||||
id bigint PK
|
|
||||||
listing_id bigint FK -> marketplace_listings.id
|
|
||||||
buyer_id int FK -> users.id
|
|
||||||
seller_id int FK -> users.id
|
|
||||||
price bigint NOT NULL
|
|
||||||
platform_fee bigint NOT NULL
|
|
||||||
seller_net bigint NOT NULL
|
|
||||||
status enum(pending_escrow, completed, refunded, reversed)
|
|
||||||
escrow_hold_until timestamptz
|
|
||||||
created_at timestamptz
|
|
||||||
|
|
||||||
creator_balances
|
|
||||||
id bigint PK
|
|
||||||
user_id int UNIQUE FK -> users.id
|
|
||||||
available bigint NOT NULL DEFAULT 0
|
|
||||||
pending bigint NOT NULL DEFAULT 0
|
|
||||||
lifetime_earned bigint NOT NULL DEFAULT 0
|
|
||||||
updated_at timestamptz
|
|
||||||
|
|
||||||
payout_requests
|
|
||||||
id bigint PK
|
|
||||||
creator_id int FK -> users.id
|
|
||||||
amount bigint NOT NULL CHECK (amount > 0)
|
|
||||||
status enum(pending, approved, paid, rejected)
|
|
||||||
external_ref text
|
|
||||||
provider_ref text
|
|
||||||
requested_by int FK -> users.id
|
|
||||||
reviewed_by int FK -> users.id (nullable)
|
|
||||||
reviewed_at timestamptz
|
|
||||||
created_at timestamptz
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. Security, anti-fraud & audit
|
|
||||||
|
|
||||||
- **Server-authoritative balances/fees:** clients never compute totals; amounts
|
|
||||||
are validated server-side against bounds and the current listing.
|
|
||||||
- **Row locking:** wallet and creator-balance mutations use `SELECT ... FOR
|
|
||||||
UPDATE` within one transaction.
|
|
||||||
- **Idempotency:** every money-moving endpoint requires an `Idempotency-Key`;
|
|
||||||
duplicate keys return the stored original outcome.
|
|
||||||
- **Rate limiting:** purchase, listing, and payout endpoints are token-bucket
|
|
||||||
rate-limited per user.
|
|
||||||
- **Escrow & reversibility:** funds are not payable until the hold window
|
|
||||||
elapses; chargebacks/refunds reverse before release.
|
|
||||||
- **Moderation:** listings carry the item's effective content flags; mature
|
|
||||||
items require age verification (per `content_moderation.md`); moderators can
|
|
||||||
`delist`/`remove` and trigger audited reversals.
|
|
||||||
- **Audit:** the ledger plus `payout_requests` review fields form an append-only
|
|
||||||
audit trail for every movement of value.
|
|
||||||
|
|
||||||
## 10. Out of scope for this feature
|
|
||||||
|
|
||||||
- Real Stripe/PayPal/KYC integrations (provider interfaces leave the hooks; the
|
|
||||||
defaults are deterministic no-ops).
|
|
||||||
- Subscriptions, recurring billing, or premium memberships.
|
|
||||||
- Gift cards, promotional codes, or user-to-user Credit transfers.
|
|
||||||
- Partial refunds, chargeback disputes, and seller tax/VAT handling.
|
|
||||||
- In-world vending machines / scripted economy triggers (future, built on this
|
|
||||||
ledger via the scripting sandbox).
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# Social — Friends, Chat & Groups — Plan
|
|
||||||
|
|
||||||
> **Status:** Planned
|
|
||||||
> **Owner:** Platform team
|
|
||||||
> **Feature ID:** `social`
|
|
||||||
> **Godot version:** 4.6.2 stable
|
|
||||||
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document breaks the **Social** feature into user stories and right-sized
|
|
||||||
implementation tasks. The feature delivers:
|
|
||||||
|
|
||||||
- **Friends & following** — a friend request/accept model plus a one-way follow
|
|
||||||
relationship, with block lists.
|
|
||||||
- **Presence & status** — online/offline, custom status message, last-seen.
|
|
||||||
- **Chat** — direct messages (DM) and in-experience (world/room) chat with
|
|
||||||
**first-class safety**: server-side text filtering, report-message, and rate
|
|
||||||
limiting.
|
|
||||||
- **Groups** — named groups with roles (owner/admin/member) and a simple group
|
|
||||||
page.
|
|
||||||
- **Public profiles** — avatar, bio, favorite experiences, and a friends list
|
|
||||||
that respects privacy settings.
|
|
||||||
|
|
||||||
## 2. Why chat safety is the differentiator
|
|
||||||
|
|
||||||
The platform is explicitly meant to pull adults away from a youth-oriented
|
|
||||||
competitor. Chat is the highest-risk surface: it is real-time, user-generated,
|
|
||||||
and where abuse happens fastest. Safety is therefore treated as a **blocking
|
|
||||||
requirement**, not an add-on:
|
|
||||||
|
|
||||||
- Every message passes a **server-side text filter** before it is stored or
|
|
||||||
delivered. The client filter is cosmetic only; the server is authoritative.
|
|
||||||
- Every message can be **reported**; reports flow into a moderation queue with
|
|
||||||
actor + reason + audit trail (mirroring `content_moderation.md`).
|
|
||||||
- Sending is **rate limited** per user (Redis token bucket) to stop flooding and
|
|
||||||
harassment-by-volume.
|
|
||||||
- Report and block are always reachable from a message context in the UI.
|
|
||||||
|
|
||||||
## 3. Existing constraints (do not re-build)
|
|
||||||
|
|
||||||
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
|
|
||||||
`app/dependencies.py`. All social endpoints reuse this.
|
|
||||||
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
|
|
||||||
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
|
|
||||||
presence and rate limiting.
|
|
||||||
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). Avatars
|
|
||||||
and favorite-experience references point at existing rows.
|
|
||||||
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no social UI yet.
|
|
||||||
|
|
||||||
## 4. Key design decisions
|
|
||||||
|
|
||||||
| Concern | Decision |
|
|
||||||
|---|---|
|
|
||||||
| Message transport | REST for send/history; **WebSocket** gateway for real-time delivery + presence (matches `networking.md`'s "WebSocket as secondary transport" for chat) |
|
|
||||||
| Presence store | Redis (TTL heartbeat) + persisted `last_seen_at` on the user record |
|
|
||||||
| Text filtering | Pluggable `TextFilter` interface with a deterministic default (profanity list + normalization) so it runs without external services; third-party hook later |
|
|
||||||
| Rate limiting | Redis token-bucket dependency, reusable across all social endpoints |
|
|
||||||
| Reports | `message_reports` table + moderation endpoints (list/review/resolve) with audit fields |
|
|
||||||
| Friends vs. follow | Friends = mutual (request→accept). Follow = one-way (no approval). Block = hard deny that also hides content and prevents DMs |
|
|
||||||
| Privacy | Per-user settings JSON (`profile_visibility`, `friends_list_visibility`, `favorites_visibility`) enforced server-side on every read |
|
|
||||||
| Groups | `groups` + `group_members` (role: owner/admin/member) + group page aggregate endpoint |
|
|
||||||
| Real-time | One WebSocket endpoint multiplexing presence, DM, and world-chat messages via Redis pub/sub |
|
|
||||||
|
|
||||||
## 5. User stories
|
|
||||||
|
|
||||||
### US-1 Friends & following
|
|
||||||
As a player, I can send a friend request, accept/decline incoming requests,
|
|
||||||
unfriend, follow/unfollow other players, and block users so they can't interact
|
|
||||||
with me.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- Friend requests are one-directional until accepted; accepted friendships are mutual.
|
|
||||||
- Follow is one-way and requires no approval.
|
|
||||||
- Block prevents DMs, hides the blocked user's profile/friends interactions, and
|
|
||||||
removes any existing friendship/follow between the two.
|
|
||||||
- Duplicate/self requests are rejected idempotently.
|
|
||||||
|
|
||||||
### US-2 Presence & status
|
|
||||||
As a player, I can see whether my friends are online, their custom status, and
|
|
||||||
when they were last seen.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- Online/offline is derived from a server-side heartbeat (Redis TTL), not client claims.
|
|
||||||
- `last_seen_at` persists and is visible (subject to privacy settings).
|
|
||||||
- Custom status text is optional and length-limited.
|
|
||||||
|
|
||||||
### US-3 Public profiles
|
|
||||||
As a player, I can view a public profile page showing avatar, bio, favorite
|
|
||||||
experiences, and a friends list that respects the owner's privacy settings.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- Avatar and bio are editable by the owner only.
|
|
||||||
- Favorite experiences reference existing `World` rows.
|
|
||||||
- A viewer sees only the sections the owner's privacy settings allow; blocked
|
|
||||||
users see a restricted view.
|
|
||||||
|
|
||||||
### US-4 Chat (DM + in-experience) with safety
|
|
||||||
As a player, I can send direct messages and chat inside a world/room. Messages
|
|
||||||
are filtered, rate limited, and reportable.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- Server filters message text before storing/delivering; filtered content is either
|
|
||||||
rejected (blocking) or redacted (configurable per filter policy).
|
|
||||||
- Send rate is limited per user (token bucket); excess sends get `429`.
|
|
||||||
- Every message has a stable id, author, timestamp, and conversation/world scope.
|
|
||||||
- Any message can be reported with a reason; reports are stored and reviewable.
|
|
||||||
- Real-time delivery over WebSocket with REST fallback for history.
|
|
||||||
|
|
||||||
### US-5 Groups
|
|
||||||
As a player, I can create a group, join/leave groups, and (as owner/admin)
|
|
||||||
manage roles and view a simple group page.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- Each group has exactly one owner and any number of admins/members.
|
|
||||||
- Role transitions are authorized (only owner can transfer ownership or promote/demote admins).
|
|
||||||
- Group page lists name, description, member list with roles, and member count.
|
|
||||||
|
|
||||||
### US-6 Godot client
|
|
||||||
As a player, I can access friends, presence, chat, profiles, and groups from the
|
|
||||||
game UI.
|
|
||||||
|
|
||||||
**Acceptance criteria**
|
|
||||||
- An API client handles JWT auth, HTTP, and WebSocket for all social calls.
|
|
||||||
- Dedicated screens/panels: friends+presence, DM & world chat (with report),
|
|
||||||
profile page, group page.
|
|
||||||
- Client-side filtering is cosmetic; server responses remain authoritative.
|
|
||||||
|
|
||||||
## 6. Task breakdown
|
|
||||||
|
|
||||||
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
|
|
||||||
are sequential for dependency chaining.
|
|
||||||
|
|
||||||
| # | Task | Story | Depends on |
|
|
||||||
|---|------|-------|-----------|
|
|
||||||
| 1 | Friends/follow/block models + Alembic migration | US-1 | — |
|
|
||||||
| 2 | Friends & follow API router | US-1 | 1 |
|
|
||||||
| 3 | Presence & status model + endpoints (Redis heartbeat) | US-2 | — |
|
|
||||||
| 4 | Profile model + migration + own-profile endpoints | US-3 | 1 |
|
|
||||||
| 5 | Public profile view + favorites + privacy enforcement | US-3 | 2, 4 |
|
|
||||||
| 6 | Chat data models + migration (conversations, messages, reports) | US-4 | — |
|
|
||||||
| 7 | Text filtering service + profanity config | US-4 | — |
|
|
||||||
| 8 | Redis rate-limiting dependency | US-4 | — |
|
|
||||||
| 9 | DM chat send/list endpoints (filter + rate limit) | US-4 | 6, 7, 8 |
|
|
||||||
| 10 | Report-message endpoint + moderation queue | US-4 | 6, 9 |
|
|
||||||
| 11 | In-experience (world/room) chat endpoints | US-4 | 9 |
|
|
||||||
| 12 | Real-time WebSocket gateway (presence + DM + world chat) | US-4 | 3, 9, 11 |
|
|
||||||
| 13 | Group + membership models + migration | US-5 | — |
|
|
||||||
| 14 | Groups API (CRUD, join/leave, roles, group page) | US-5 | 13 |
|
|
||||||
| 15 | Godot API client (auth + HTTP + WebSocket) | US-6 | 2 |
|
|
||||||
| 16 | Godot friends & presence UI | US-6 | 3, 15 |
|
|
||||||
| 17 | Godot chat UI (DM + world chat + report) | US-6 | 12, 15 |
|
|
||||||
| 18 | Godot profile page UI | US-6 | 5, 15 |
|
|
||||||
| 19 | Godot groups UI + group page | US-6 | 14, 15 |
|
|
||||||
|
|
||||||
## 7. Phasing
|
|
||||||
|
|
||||||
- **Phase 1 (data + core APIs):** tasks 1–5 (friends, presence, profiles).
|
|
||||||
- **Phase 2 (chat safety — the differentiator):** tasks 6–12 (chat + safety +
|
|
||||||
realtime). This is the critical path and highest priority.
|
|
||||||
- **Phase 3 (groups):** tasks 13–14.
|
|
||||||
- **Phase 4 (client):** tasks 15–19, each landing after its backend dependency.
|
|
||||||
|
|
||||||
## 8. Out of scope for this feature
|
|
||||||
|
|
||||||
- Age verification / maturity gating (covered by `content_moderation.md`).
|
|
||||||
- Voice chat, media/image messages, message editing/deletion history.
|
|
||||||
- Third-party profanity/toxicity vendors (the `TextFilter` interface leaves a
|
|
||||||
hook, but the default is deterministic and dependency-free).
|
|
||||||
- Mobile push notifications.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
; Engine configuration file.
|
|
||||||
; It's best edited using the editor UI and not directly,
|
|
||||||
; but it can also be edited by hand.
|
|
||||||
|
|
||||||
config_version=5
|
|
||||||
|
|
||||||
[application]
|
|
||||||
|
|
||||||
config/name="AGP-Game"
|
|
||||||
run/main_scene="res://scenes/main_menu.tscn"
|
|
||||||
config/features=PackedStringArray("4.6.2")
|
|
||||||
agp/api_base_url="http://127.0.0.1:8000"
|
|
||||||
|
|
||||||
[autoload]
|
|
||||||
|
|
||||||
ApiClient="*res://scripts/api_client.gd"
|
|
||||||
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
|
||||||
Session="*res://scripts/session.gd"
|
|
||||||
|
|
||||||
[display]
|
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
|
||||||
window/size/viewport_height=1080
|
|
||||||
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]
|
|
||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
|
||||||
renderer/rendering_method.mobile="gl_compatibility"
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
extends Node
|
|
||||||
|
|
||||||
func _ready():
|
|
||||||
print("=== SANDBOX PROBE 6: Corrected Evidence Gathering (Godot 4.6.2) ===")
|
|
||||||
probe_script_loading()
|
|
||||||
probe_os_apis()
|
|
||||||
probe_file_access()
|
|
||||||
probe_network()
|
|
||||||
probe_classdb()
|
|
||||||
probe_expression_engine()
|
|
||||||
probe_engine_singletons()
|
|
||||||
probe_worker_threads()
|
|
||||||
probe_project_settings()
|
|
||||||
probe_allowlist()
|
|
||||||
print("=== SANDBOX PROBE 6 END ===")
|
|
||||||
get_tree().quit()
|
|
||||||
|
|
||||||
func probe_script_loading():
|
|
||||||
print("--- Script loading ---")
|
|
||||||
var gd = GDScript.new()
|
|
||||||
gd.source_code = "extends RefCounted\nfunc hello():\n\treturn 42\n"
|
|
||||||
var err = gd.reload()
|
|
||||||
print("GDScript.reload() from source_code err=", err)
|
|
||||||
if err == OK:
|
|
||||||
var inst = gd.new()
|
|
||||||
print(" instantiated, hello()=", inst.call("hello"))
|
|
||||||
var res = load("res://test_script.gd")
|
|
||||||
print("load('res://test_script.gd') = ", res)
|
|
||||||
if res:
|
|
||||||
print(" get_source_code() available: ", res.has_method("get_source_code"))
|
|
||||||
print(" GDScript instance has 'get_ast': ", gd.has_method("get_ast"))
|
|
||||||
print(" GDScript instance has 'get_parser': ", gd.has_method("get_parser"))
|
|
||||||
print(" GDScript instance has 'get_script_method_list': ", gd.has_method("get_script_method_list"))
|
|
||||||
print(" GDScript instance has 'get_script_property_list': ", gd.has_method("get_script_property_list"))
|
|
||||||
|
|
||||||
func probe_os_apis():
|
|
||||||
print("--- OS APIs ---")
|
|
||||||
var out = []
|
|
||||||
var exit_code = OS.execute("echo", ["hello"], out)
|
|
||||||
print("OS.execute('echo') exit=", exit_code, " out=", out)
|
|
||||||
print("OS.get_environment('HOME') = ", OS.get_environment("HOME"))
|
|
||||||
print("OS has 'create_process': ", OS.has_method("create_process"))
|
|
||||||
print("OS.get_cmdline_args() = ", OS.get_cmdline_args())
|
|
||||||
print("OS.get_user_data_dir() = ", OS.get_user_data_dir())
|
|
||||||
print("OS.get_executable_path() = ", OS.get_executable_path())
|
|
||||||
|
|
||||||
func probe_file_access():
|
|
||||||
print("--- File access ---")
|
|
||||||
var f = FileAccess.open("/etc/hostname", FileAccess.READ)
|
|
||||||
print("FileAccess.open('/etc/hostname') = ", f)
|
|
||||||
if f:
|
|
||||||
print(" content: ", f.get_as_text())
|
|
||||||
f.close()
|
|
||||||
var w = FileAccess.open("/tmp/godot_probe_write.txt", FileAccess.WRITE)
|
|
||||||
print("FileAccess.open('/tmp/...', WRITE) = ", w)
|
|
||||||
if w:
|
|
||||||
w.store_string("probe")
|
|
||||||
w.close()
|
|
||||||
print(" wrote to /tmp OK")
|
|
||||||
var dir = DirAccess.open("/")
|
|
||||||
print("DirAccess.open('/') = ", dir)
|
|
||||||
if dir:
|
|
||||||
var names = dir.get_files()
|
|
||||||
print(" root files count: ", names.size())
|
|
||||||
var res_dir = DirAccess.open("res://")
|
|
||||||
print("DirAccess.open('res://') = ", res_dir)
|
|
||||||
var user_dir = DirAccess.open("user://")
|
|
||||||
print("DirAccess.open('user://') = ", user_dir)
|
|
||||||
|
|
||||||
func probe_network():
|
|
||||||
print("--- Network ---")
|
|
||||||
var tcp = TCPServer.new()
|
|
||||||
var bind_err = tcp.listen(0)
|
|
||||||
print("TCPServer.listen(0) err=", bind_err)
|
|
||||||
var http = HTTPClient.new()
|
|
||||||
print("HTTPClient instantiated: ", http != null)
|
|
||||||
var http_req = HTTPRequest.new()
|
|
||||||
print("HTTPRequest instantiated: ", http_req != null)
|
|
||||||
var udp = PacketPeerUDP.new()
|
|
||||||
print("PacketPeerUDP instantiated: ", udp != null)
|
|
||||||
var ws = WebSocketPeer.new()
|
|
||||||
print("WebSocketPeer instantiated: ", ws != null)
|
|
||||||
|
|
||||||
func probe_classdb():
|
|
||||||
print("--- ClassDB ---")
|
|
||||||
print("ClassDB.class_exists('OS'): ", ClassDB.class_exists("OS"))
|
|
||||||
print("ClassDB.class_exists('FileAccess'): ", ClassDB.class_exists("FileAccess"))
|
|
||||||
print("ClassDB.class_exists('TCPServer'): ", ClassDB.class_exists("TCPServer"))
|
|
||||||
print("ClassDB.class_exists('HTTPClient'): ", ClassDB.class_exists("HTTPClient"))
|
|
||||||
var class_list = ClassDB.get_class_list()
|
|
||||||
print("ClassDB.get_class_list() count: ", class_list.size())
|
|
||||||
var os_methods = ClassDB.class_get_method_list("OS")
|
|
||||||
print("OS method count: ", os_methods.size())
|
|
||||||
var inst = ClassDB.instantiate("Node")
|
|
||||||
print("ClassDB.instantiate('Node') = ", inst)
|
|
||||||
print("ClassDB has 'class_get_singleton': ", ClassDB.has_method("class_get_singleton"))
|
|
||||||
|
|
||||||
func probe_expression_engine():
|
|
||||||
print("--- Expression engine ---")
|
|
||||||
var expr = Expression.new()
|
|
||||||
var err = expr.parse("1 + 2")
|
|
||||||
print("Expression.parse('1 + 2') err=", err)
|
|
||||||
if err == OK:
|
|
||||||
var result = expr.execute()
|
|
||||||
print(" Expression.execute() = ", result)
|
|
||||||
var expr2 = Expression.new()
|
|
||||||
err = expr2.parse("OS.get_environment('HOME')")
|
|
||||||
print("Expression.parse('OS.get_environment') err=", err)
|
|
||||||
if err == OK:
|
|
||||||
var result2 = expr2.execute()
|
|
||||||
print(" Expression.execute() = ", result2)
|
|
||||||
var expr3 = Expression.new()
|
|
||||||
err = expr3.parse("get_environment('HOME')", ["OS"])
|
|
||||||
print("Expression.parse with base_instance err=", err)
|
|
||||||
if err == OK:
|
|
||||||
var result3 = expr3.execute([], OS)
|
|
||||||
print(" Expression.execute with OS base = ", result3)
|
|
||||||
var expr4 = Expression.new()
|
|
||||||
err = expr4.parse("FileAccess.open('/etc/hostname', 1)")
|
|
||||||
print("Expression.parse('FileAccess.open') err=", err)
|
|
||||||
if err == OK:
|
|
||||||
var result4 = expr4.execute()
|
|
||||||
print(" Expression.execute() = ", result4)
|
|
||||||
var expr5 = Expression.new()
|
|
||||||
err = expr5.parse("get_tree().quit()")
|
|
||||||
print("Expression.parse('get_tree().quit()') err=", err)
|
|
||||||
if err == OK:
|
|
||||||
var result5 = expr5.execute()
|
|
||||||
print(" Expression.execute() = ", result5)
|
|
||||||
|
|
||||||
func probe_engine_singletons():
|
|
||||||
print("--- Engine singletons ---")
|
|
||||||
print("Engine has 'remove_singleton': ", Engine.has_method("remove_singleton"))
|
|
||||||
print("Engine has 'add_singleton': ", Engine.has_method("add_singleton"))
|
|
||||||
print("Engine.get_main_loop() = ", Engine.get_main_loop())
|
|
||||||
|
|
||||||
func probe_worker_threads():
|
|
||||||
print("--- Worker threads ---")
|
|
||||||
var thread = Thread.new()
|
|
||||||
print(" Thread.new() = ", thread)
|
|
||||||
print(" WorkerThreadPool has 'add_task': ", WorkerThreadPool.has_method("add_task"))
|
|
||||||
|
|
||||||
func probe_project_settings():
|
|
||||||
print("--- Project settings ---")
|
|
||||||
var settings = ProjectSettings.get_property_list()
|
|
||||||
var relevant = []
|
|
||||||
for s in settings:
|
|
||||||
var name = String(s.name)
|
|
||||||
if "sandbox" in name.to_lower() or "restrict" in name.to_lower() or "security" in name.to_lower():
|
|
||||||
relevant.append(name)
|
|
||||||
print(" Sandbox/security project settings: ", relevant)
|
|
||||||
print(" ProjectSettings has 'application/run/disable_os': ", ProjectSettings.has_setting("application/run/disable_os"))
|
|
||||||
print(" ProjectSettings has 'application/run/disable_file_access': ", ProjectSettings.has_setting("application/run/disable_file_access"))
|
|
||||||
print(" ProjectSettings has 'application/run/disable_network': ", ProjectSettings.has_setting("application/run/disable_network"))
|
|
||||||
print(" ProjectSettings has 'set_setting': ", ProjectSettings.has_method("set_setting"))
|
|
||||||
print(" ProjectSettings has 'save': ", ProjectSettings.has_method("save"))
|
|
||||||
|
|
||||||
func probe_allowlist():
|
|
||||||
print("--- Allowlist / static analysis ---")
|
|
||||||
var dangerous_patterns = [
|
|
||||||
"OS.execute", "OS.create_process", "OS.get_environment",
|
|
||||||
"FileAccess.open", "DirAccess.open", "TCPServer", "HTTPClient",
|
|
||||||
"load(", "preload(", "ClassDB", "Engine.get_main_loop",
|
|
||||||
"get_tree().quit", "ProjectSettings"
|
|
||||||
]
|
|
||||||
var test_script = """extends Node
|
|
||||||
func _ready():
|
|
||||||
var f = FileAccess.open("/etc/passwd", FileAccess.READ)
|
|
||||||
OS.execute("rm", ["-rf", "/"])
|
|
||||||
"""
|
|
||||||
print(" Test script contains dangerous patterns:")
|
|
||||||
for p in dangerous_patterns:
|
|
||||||
if p in test_script:
|
|
||||||
print(" DETECTED: ", p)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://probe6.gd" id="1"]
|
|
||||||
|
|
||||||
[node name="Probe6" type="Node"]
|
|
||||||
script = ExtResource("1")
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
; Engine configuration file.
|
|
||||||
config_version=5
|
|
||||||
|
|
||||||
[application]
|
|
||||||
|
|
||||||
config/name="sandbox_probe"
|
|
||||||
run/main_scene="res://probe6.tscn"
|
|
||||||
config/features=PackedStringArray("4.6")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
extends RefCounted
|
|
||||||
|
|
||||||
func hello():
|
|
||||||
return "from test_script.gd"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[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")
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[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")
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user