Compare commits

..

1 Commits

171 changed files with 991 additions and 14462 deletions
-39
View File
@@ -1,39 +0,0 @@
name: checkpoint
# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke
# tests, multiplayer, fresh-clone boot) on every push and PR.
#
# Runs on a `host`-label act_runner on the AGP box: Docker, ~/godot/godot,
# and Python 3 are already there. Deliberately uses no JavaScript actions
# (actions/checkout etc.) so the host needs no Node install -- checkout is a
# plain git clone from the local Gitea. See docs/ci.md.
on:
push:
branches: [main]
pull_request:
jobs:
checkpoint:
runs-on: host
steps:
- name: Checkout
run: |
set -eu
rm -rf repo && mkdir repo && cd repo
git init -q
git remote add origin "http://x:${{ github.token }}@localhost:3000/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Python env
working-directory: repo
run: |
set -eu
python3 -m venv .venv
.venv/bin/pip install --quiet --upgrade pip
.venv/bin/pip install --quiet -r backend/requirements.txt
- name: Checkpoint suite
working-directory: repo
run: .venv/bin/python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690
-1
View File
@@ -11,4 +11,3 @@ Thumbs.db
# Godot UID files (regenerated from path)
*.uid
assets/build_info.json
+1
View File
@@ -0,0 +1 @@
+85
View File
@@ -0,0 +1,85 @@
63f7b34db8d8cdea90c76aacccf841ec
::res://::1787275777
export_presets.cfg::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
README.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
::res://addons/::1787272931
::res://assets/::1787272931
::res://backend/::1787273125
alembic.ini::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
docker-compose.yml::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
pytest.ini::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
requirements.txt::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
::res://backend/__pycache__/::1787273108
::res://backend/alembic/::1787273077
::res://backend/alembic/__pycache__/::1787273077
::res://backend/alembic/versions/::1787273077
::res://backend/alembic/versions/__pycache__/::1787273077
::res://backend/app/::1787273077
::res://backend/app/__pycache__/::1787273078
::res://backend/app/models/::1787273077
::res://backend/app/models/__pycache__/::1787273077
::res://backend/app/routers/::1787273078
::res://backend/app/routers/__pycache__/::1787273079
::res://backend/app/services/::1787273079
::res://backend/app/services/__pycache__/::1787273079
::res://backend/tests/::1787273108
::res://backend/tests/__pycache__/::1787273109
::res://docs/::1787272931
::res://docs/architecture/::1787272931
avatar.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
content_moderation.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
editor.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
networking.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
pc2_test_level.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
scripting_sandbox.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
world.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
::res://docs/checkpoints/::1787272931
PC1.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
::res://docs/planning/::1787272931
client-server-wiring.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
economy.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
social.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
::res://docs/testing/::1787275777
README.md::TextFile::-1::1787272931::0::1::::<><><>0<>0<><>::
test_inventory.md::TextFile::-1::1787275777::0::1::::<><><>0<>0<><>::
::res://scenes/::1787275142
game.tscn::PackedScene::-1::1787275142::0::1::::<><><>0<>0<><>::res://scenes/worlds/test_level.tscn<>res://scenes/player.tscn
main.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
main_menu.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd
player.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/player.gd
server.tscn::PackedScene::-1::1787275142::0::1::::<><><>0<>0<><>::res://scripts/server.gd
server_player.tscn::PackedScene::-1::1787275142::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
world_list.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::res://scripts/world_list.gd
::res://scenes/worlds/::1787272931
test_level.tscn::PackedScene::-1::1787272931::0::1::::<><><>0<>0<><>::
::res://scripts/::1787275777
api_client.gd::GDScript::5412288119287126975::1787272931::0::1::::<>Node<><>0<>0<><>::
chunk.gd::GDScript::7364239597441807588::1787272931::0::1::::Chunk<>Node3D<><>0<>0<><>::
chunk_data_decoder.gd::GDScript::3922068956043581888::1787272931::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
chunk_scene.gd::GDScript::9207602004456600331::1787272931::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
chunk_stream_manager.gd::GDScript::2366645676568508746::1787272931::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
dynamic_object_node.gd::GDScript::8863349716576659118::1787272931::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
main_menu.gd::GDScript::2834562513277893052::1787272931::0::1::::<>Control<><>0<>0<><>::
network_client.gd::GDScript::7615969254612857355::1787275777::0::1::::NetworkClient<>Node<><>0<>0<><>::
network_player_proxy.gd::GDScript::5755366661206648802::1787275777::0::1::::<>Node<><>0<>0<><>::
network_receiver_proxy.gd::GDScript::318799598255479274::1787275777::0::1::::<>Node<><>0<>0<><>::
player.gd::GDScript::4510795778937304264::1787275142::0::1::::Player<>CharacterBody3D<><>0<>0<><>::
player_spawner.gd::GDScript::3637338339553764865::1787275142::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
server.gd::GDScript::6336910781803684784::1787275142::0::1::::<>Node<><>0<>0<><>::
smoke_test_api.gd::GDScript::1760923834350699002::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_auth.gd::GDScript::3027816090663230577::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_e2e.gd::GDScript::9086409960595132737::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_enter_world.gd::GDScript::4171815758762850910::1787275142::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_back.gd::GDScript::4436937414562273157::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_full_flow.gd::GDScript::1948803640558784862::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_ui.gd::GDScript::396938597300585484::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_network_client.gd::GDScript::2714050080302875978::1787275777::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_player.gd::GDScript::3497042634752676337::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_test_level.gd::GDScript::2607555612829866949::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_world_list.gd::GDScript::4300757286283894147::1787272931::0::1::::<>SceneTree<><>0<>0<><>::
static_object_node.gd::GDScript::5276389741929225897::1787272931::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
tile_def.gd::GDScript::2430528079284085109::1787272931::0::1::::TileDef<>RefCounted<><>0<>0<><>::
tile_layer.gd::GDScript::4797906255131447844::1787272931::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
tile_layer_node.gd::GDScript::9033225196818247616::1787272931::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
world_list.gd::GDScript::6119591848847211153::1787275142::0::1::::<>Control<><>0<>0<><>::
world_object.gd::GDScript::6429674491464200335::1787272931::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
+11
View File
@@ -0,0 +1,11 @@
[game_view]
select_mode=0
[editor_metadata]
executable_path="/home/daddy/godot/godot"
[recent_files]
scenes=["res://scenes/main_menu.tscn", "res://scenes/server.tscn"]
@@ -0,0 +1,5 @@
[folding]
node_unfolds=[]
resource_unfolds=[]
nodes_folded=[]
+105
View File
@@ -0,0 +1,105 @@
list=[{
"base": &"Node3D",
"class": &"Chunk",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk.gd"
}, {
"base": &"RefCounted",
"class": &"ChunkDataDecoder",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_data_decoder.gd"
}, {
"base": &"Node3D",
"class": &"ChunkScene",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_scene.gd"
}, {
"base": &"Node",
"class": &"ChunkStreamManager",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_stream_manager.gd"
}, {
"base": &"Node3D",
"class": &"DynamicObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/dynamic_object_node.gd"
}, {
"base": &"Node",
"class": &"NetworkClient",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/network_client.gd"
}, {
"base": &"CharacterBody3D",
"class": &"Player",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player.gd"
}, {
"base": &"CharacterBody3D",
"class": &"PlayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player_spawner.gd"
}, {
"base": &"Node3D",
"class": &"StaticObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/static_object_node.gd"
}, {
"base": &"RefCounted",
"class": &"TileDef",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_def.gd"
}, {
"base": &"RefCounted",
"class": &"TileLayer",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer.gd"
}, {
"base": &"Node3D",
"class": &"TileLayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer_node.gd"
}, {
"base": &"RefCounted",
"class": &"WorldObject",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/world_object.gd"
}]
Binary file not shown.
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
-115
View File
@@ -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.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bva2etlpa8act"
path="res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"
[deps]
source_file="res://assets/avatars/chest_bandit_01.glb"
dest_files=["res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ctwcxf552y325"
path="res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"
[deps]
source_file="res://assets/avatars/face_glasses_01.glb"
dest_files=["res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://j4dc6l3l0j6f"
path="res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"
[deps]
source_file="res://assets/avatars/hair_mohawk_01.glb"
dest_files=["res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dp4vqp2trrank"
path="res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"
[deps]
source_file="res://assets/avatars/hair_ponytail_01.glb"
dest_files=["res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bkp35fnns36xb"
path="res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"
[deps]
source_file="res://assets/avatars/head_cyber_01.glb"
dest_files=["res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bgfjpdu8kg0aj"
path="res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"
[deps]
source_file="res://assets/avatars/left_foot_boot_01.glb"
dest_files=["res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://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.
-42
View File
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://yo204q1m6hs3"
path="res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"
[deps]
source_file="res://assets/avatars/pants_cargo_01.glb"
dest_files=["res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://30ij0eapm6ti"
path="res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"
[deps]
source_file="res://assets/avatars/right_foot_boot_01.glb"
dest_files=["res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -1,42 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://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
-40
View File
@@ -1,40 +0,0 @@
{
"objects": {
"crate_small": {
"asset_id": "props/crate_small.glb",
"type": "prop",
"display_name": "Small Crate",
"category": "props"
},
"rock_01": {
"asset_id": "props/rock_01.glb",
"type": "prop",
"display_name": "Rock",
"category": "nature"
},
"tree_pine_01": {
"asset_id": "props/tree_pine_01.glb",
"type": "prop",
"display_name": "Pine Tree",
"category": "nature"
},
"lamp_post_01": {
"asset_id": "props/lamp_post_01.glb",
"type": "prop",
"display_name": "Lamp Post",
"category": "props"
},
"chest_01": {
"asset_id": "props/chest.glb",
"type": "interactable",
"display_name": "Treasure Chest",
"category": "interactables"
},
"spawn_point": {
"asset_id": "",
"type": "spawn_point",
"display_name": "Spawn Point",
"category": "gameplay"
}
}
}
@@ -1,17 +0,0 @@
{
"content_flags": {
"violence": "none",
"mature": false
},
"rules": [
{
"trigger": {"type": "on_interact"},
"conditions": [],
"actions": [
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
{"verb": "play_sound", "sound_id": "chest_open"},
{"verb": "rotate", "target": "self", "degrees": 90, "axis": "y", "duration": 0.4}
]
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"templates": {
"small_crate": {
"asset_id": "props/crate_small.glb",
"display_name": "Small Crate"
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ 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, world_content # noqa: F401
from app.models import user, session, world, asset # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -1,39 +0,0 @@
"""add avatar_loadouts table
Revision ID: 0004_add_avatar_loadouts_table
Revises: 0003_add_refresh_tokens_table
Create Date: 2026-09-05 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0004_add_avatar_loadouts_table"
down_revision: Union[str, None] = "0003_add_refresh_tokens_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the avatar_loadouts table (one row per user)."""
op.create_table(
"avatar_loadouts",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("data", postgresql.JSONB(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
"""Drop the avatar_loadouts table."""
op.drop_table("avatar_loadouts")
@@ -1,120 +0,0 @@
"""add world_rooms, world_chunks, world_chunk_revisions tables
Revision ID: 0005_add_world_content_tables
Revises: 0004_add_avatar_loadouts_table
Create Date: 2026-09-14 00:00:00.000000
See docs/architecture/world_persistence_impl.md §4. Chunk content
(rooms, chunk payloads, chunk revisions) lives in Postgres, purely
additive to the existing ``worlds`` table.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0005_add_world_content_tables"
down_revision: Union[str, None] = "0004_add_avatar_loadouts_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"world_rooms",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"world_id", sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("slug", sa.String(64), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column(
"grid", postgresql.JSONB(), nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}',
),
sa.Column("bounds", postgresql.JSONB(), nullable=True),
sa.Column("spawn", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),
)
op.create_index("ix_world_rooms_world_id", "world_rooms", ["world_id"])
op.create_table(
"world_chunks",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"world_id", sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"room_id", sa.Integer(),
sa.ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("chunk_key", sa.String(160), nullable=False, unique=True),
sa.Column("cx", sa.Integer(), nullable=False),
sa.Column("cy", sa.Integer(), nullable=False),
sa.Column("cz", sa.Integer(), nullable=False, server_default="0"),
sa.Column("format_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("payload", sa.LargeBinary(), nullable=False),
sa.Column("payload_len", sa.Integer(), nullable=False),
sa.Column("checksum", sa.String(64), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column("content_flags", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("is_mature", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"updated_by_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
)
op.create_index("ix_world_chunks_world_id", "world_chunks", ["world_id"])
op.create_index("ix_world_chunks_room_id", "world_chunks", ["room_id"])
op.create_index(
"ix_world_chunks_room_coord", "world_chunks", ["room_id", "cx", "cy", "cz"],
)
op.create_index(
"ix_world_chunks_mature", "world_chunks", ["world_id"],
postgresql_where=sa.text("is_mature"),
)
op.create_table(
"world_chunk_revisions",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("chunk_key", sa.String(160), nullable=False),
sa.Column("world_id", sa.Integer(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("action", sa.String(16), nullable=False),
sa.Column(
"actor_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("checksum", sa.String(64), nullable=False),
sa.Column("payload", sa.LargeBinary(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_world_chunk_revisions_chunk_key", "world_chunk_revisions", ["chunk_key"])
op.create_index("ix_world_chunk_revisions_world_id", "world_chunk_revisions", ["world_id"])
op.create_index("ix_world_chunk_revisions_created_at", "world_chunk_revisions", ["created_at"])
def downgrade() -> None:
op.drop_table("world_chunk_revisions")
op.drop_table("world_chunks")
op.drop_table("world_rooms")
-41
View File
@@ -75,52 +75,11 @@ class Settings(BaseSettings):
# CORS
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
# How long ensure_server() waits for a spawned server to report
# "ready": true in its status file before giving up (503 to the
# joining client). See world_persistence_impl.md §7.4 -- content
# loading makes a slow start more likely than the old bare-port-bind
# wait, so this is a separate, longer-by-default budget.
GAME_SERVER_READY_TIMEOUT_SECONDS: float = 20.0
# The base URL the dedicated Godot server should call back into this
# backend on, for the read-only /api/internal content routes
# (world_persistence_impl.md §4.4 / §7).
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
# ── World content persistence (world_persistence_impl.md §4.4) ─────────
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
+3 -9
View File
@@ -6,18 +6,15 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets, avatar, world_content, internal
from app.services.game_servers import supervisor
from app.routers import auth, users, worlds, assets
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown events."""
supervisor.start()
# Startup: initialize database, connect to Redis, etc.
yield
await supervisor.shutdown()
# Shutdown: close connections, cleanup
app = FastAPI(
@@ -40,10 +37,7 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
@app.get("/api/health")
+1 -6
View File
@@ -2,10 +2,5 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
]
__all__ = ["User", "Session", "World", "Asset"]
-41
View File
@@ -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")
-6
View File
@@ -24,12 +24,6 @@ class User(Base):
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
avatar_loadout = relationship(
"AvatarLoadout",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
@property
def is_adult(self) -> bool:
-3
View File
@@ -34,6 +34,3 @@ class World(Base):
owner = relationship("User", back_populates="worlds")
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
# world_content.py / world_persistence_impl.md §4.1. Added here (only)
# so PC1/PC2 code depending on this module is otherwise untouched.
rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")
-153
View File
@@ -1,153 +0,0 @@
"""World content persistence models.
Implements the schema decided in ``docs/architecture/world_persistence_impl.md``
§4: chunk content (rooms, chunk payloads, chunk revisions) lives in Postgres,
alongside ``worlds``, rather than in a per-world SQLite file
(``docs/architecture/world.md`` §4, superseded).
The chunk *payload* format itself (the 48-byte header + sectioned binary
body) is unchanged from ``world.md`` §3.6 and is decoded/encoded by
``app.services.chunk_codec``, not by these models -- these just store the
opaque ``BYTEA``.
Conventions follow the existing models (``app/models/world.py``,
``app/models/avatar_loadout.py``): declarative ``Column(...)`` style,
``Integer`` surrogate PKs, ``DateTime(timezone=True)`` with
``server_default=func.now()``, JSONB for document-shaped fields.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
LargeBinary,
String,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# Default chunk grid, matching Chunk.CHUNK_SIZE_* in scripts/chunk.gd (the
# client's source of truth for the same constants) and world.md §2.2.
DEFAULT_ROOM_GRID: dict = {"size": [32, 32, 1], "origin": [0, 0, 0]}
# The room every world gets automatically (world_persistence_impl.md §4.1).
MAIN_ROOM_SLUG = "main"
MAIN_ROOM_NAME = "Main"
class WorldRoom(Base):
"""A bounded region within a world -- a level, building, or zone
(world.md §2.1). Holds the chunk grid config so a world can later hold
rooms with different grids without touching the ``worlds`` table.
"""
__tablename__ = "world_rooms"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(
Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True
)
slug = Column(String(64), nullable=False) # url-safe; "main" by default
name = Column(String(255), nullable=False)
# {"size": [x, y, z], "origin": [x, y, z]}
grid = Column(
JSONB, nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}',
)
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
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
)
__table_args__ = (
UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),
)
world = relationship("World", back_populates="rooms")
chunks = relationship("WorldChunk", back_populates="room", cascade="all, delete-orphan")
class WorldChunk(Base):
"""One chunk's persisted content -- the atomic load/save/stream unit
(world.md §2.1). ``payload`` is the world.md §3.6 binary blob, stored
verbatim as BYTEA.
Deltas from world.md §4.4 (SQLite design): a surrogate integer PK +
unique coordinate constraint (rather than ``chunk_key`` as the PK), no
``dirty`` column (write-behind doesn't apply over Postgres -- see
world_persistence_impl.md §3.3/§5), and content flags folded into this
row as JSONB + an indexed ``is_mature`` boolean rather than a separate
``content_flags`` table (world.md §4.6).
"""
__tablename__ = "world_chunks"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(
Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True
) # denormalized: world-scoped queries without a join through rooms
room_id = Column(
Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"), nullable=False, index=True
)
chunk_key = Column(String(160), nullable=False, unique=True) # world.md §2.3 / §4.5
cx = Column(Integer, nullable=False)
cy = Column(Integer, nullable=False)
cz = Column(Integer, nullable=False, server_default="0")
format_version = Column(Integer, nullable=False, server_default="1")
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes
payload_len = Column(Integer, nullable=False)
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
revision = Column(Integer, nullable=False, server_default="1")
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
is_mature = Column(Boolean, nullable=False, server_default="false")
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
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
)
__table_args__ = (
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
)
room = relationship("WorldRoom", back_populates="chunks")
class WorldChunkRevision(Base):
"""Audit trail of chunk writes (world.md §4.7). ``chunk_key``/``world_id``
are deliberately **not** foreign keys, so deleting a chunk or a world
preserves the audit trail.
Retention (world_persistence_impl.md §4.3, required): only the newest
``settings.WORLD_CHUNK_REVISION_KEEP`` rows per ``chunk_key`` keep a
non-null ``payload``; older rows are pruned (payload nulled, metadata
row kept) by ``WorldStore`` in the same transaction as the write that
triggers it.
"""
__tablename__ = "world_chunk_revisions"
id = Column(Integer, primary_key=True, index=True)
chunk_key = Column(String(160), nullable=False, index=True)
world_id = Column(Integer, nullable=False, index=True)
revision = Column(Integer, nullable=False)
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
checksum = Column(String(64), nullable=False)
payload = Column(LargeBinary, nullable=True) # full payload at this revision; nulled by retention
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
-73
View File
@@ -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()
+17 -29
View File
@@ -16,15 +16,11 @@ 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 ---
@@ -43,19 +39,14 @@ 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
over_18: bool # Simple boolean confirmation instead of date_of_birth
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: date | None = None
date_of_birth: str | None = None
is_active: bool
created_at: datetime | None = None
@@ -117,9 +108,15 @@ def hash_password(password: str) -> str:
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.
Not called by /register anymore (that endpoint now just trusts the
client's over_18 boolean, see RegisterRequest) -- kept as a real,
tested utility since date-of-birth storage/verification is coming
back in a future pass (real age math, not just a client-side claim,
the user explicitly wants to revisit this design). Removing it broke
backend/tests/test_auth_age_gate.py's collection entirely (ImportError
on `from app.routers.auth import calculate_age`) for zero benefit --
restoring it is the lower-risk path over rewriting or deleting 17
tests for logic this codebase will need again.
Args:
birth_date: The person's date of birth.
@@ -154,25 +151,16 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(auth_rate_limit)])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens.
Requires a date of birth; the server computes age and rejects anyone
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
verification design and its self-attestation limitation.
Requires over_18 boolean confirmation; rejects users who are not 18+.
"""
age = calculate_age(req.date_of_birth)
if age < 0 or req.date_of_birth > date.today():
if not req.over_18:
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",
detail="Must be 18 or older to register",
)
# Check if username already exists
@@ -195,7 +183,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=req.date_of_birth,
date_of_birth=None, # No longer collecting DOB
)
db.add(user)
await db.flush() # flush to get user.id before creating refresh token
@@ -215,7 +203,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
)
@router.post("/login", response_model=TokenResponse, dependencies=[Depends(auth_rate_limit)])
@router.post("/login", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return access + refresh tokens.
-78
View File
@@ -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)
-146
View File
@@ -1,146 +0,0 @@
"""Internal, dedicated-server-only content API.
Implements `docs/architecture/world_persistence_impl.md` §7 + §8.3. This is
how a spawned Godot dedicated server (`scripts/server.gd`, spawned by
`app.services.game_servers`) pulls its world's content read-only over HTTP
before binding its ENet port. Mounted at ``/api/internal`` -- a prefix that
can also be blocked at the reverse proxy, per the doc's rejected-alternative
note in §7.3.
Gated by ``require_game_server`` (§7.3): a bearer token minted per spawn by
``GameServerSupervisor._spawn`` and checked against
``supervisor.validate_token(world_id, token)``. There is no user identity
here at all -- these routes skip the per-user visibility check public
routes apply, and apply no mature-content gating (gating is a client/user
concern; the dedicated server still needs the geometry for collision).
Read-only by construction: there is no write route here, and adding one
would violate world_persistence_impl.md D2 (the FastAPI backend is the
only writer).
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.world import World
from app.models.world_content import MAIN_ROOM_SLUG
from app.services import chunk_codec
from app.services.game_servers import supervisor
from app.services.world_store import Bounds, WorldStore
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
async def require_game_server(
world_id: int,
authorization: Annotated[str | None, Header()] = None,
) -> None:
"""world_persistence_impl.md §7.3. A path whose ``world_id`` differs
from the token's world is 403 -- ``validate_token`` enforces that by
construction (it looks the token up *by* ``world_id``)."""
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
token = authorization.split(" ", 1)[1].strip()
if not token or not supervisor.validate_token(world_id, token):
raise HTTPException(status_code=403, detail="invalid or expired server token")
async def _get_world_or_404(world_id: int, db: AsyncSession) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if world is None:
raise HTTPException(status_code=404, detail="World not found")
return world
async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str):
room = await store.get_room(world_id, slug)
if room is None and slug == MAIN_ROOM_SLUG:
room = await store.get_or_create_main_room(world_id)
await db.commit()
return room
@router.get("/worlds/{world_id}/manifest", dependencies=[Depends(require_game_server)])
async def manifest(world_id: int, db: DbDep):
await _get_world_or_404(world_id, db)
store = WorldStore(db)
rooms = await store.list_rooms(world_id)
if not rooms:
rooms = [await store.get_or_create_main_room(world_id)]
await db.commit()
room_payloads = []
for r in rooms:
room_payloads.append({
"slug": r.slug, "name": r.name, "grid": r.grid, "bounds": r.bounds,
"spawn": r.spawn, "chunk_count": await store.count_chunks(r.id),
})
return {
"world_id": world_id,
"content_revision": await store.content_revision(world_id),
"rooms": room_payloads,
}
@router.get("/worlds/{world_id}/rooms/{slug}/chunks", dependencies=[Depends(require_game_server)])
async def internal_list_chunks(
world_id: int, slug: str, db: DbDep,
cx_min: int | None = None, cx_max: int | None = None,
cy_min: int | None = None, cy_max: int | None = None,
cz: int | None = None,
):
await _get_world_or_404(world_id, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz)
chunks = await store.list_chunks(world_id, room.id, bounds)
return {
"room": room.slug,
"content_revision": await store.content_revision(world_id),
"chunks": [
{
"key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz,
"revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len,
"is_mature": c.is_mature,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in chunks
],
}
@router.get(
"/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}",
dependencies=[Depends(require_game_server)],
)
async def internal_get_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, db: DbDep):
await _get_world_or_404(world_id, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
chunk = await store.get_chunk(key)
if chunk is None:
raise HTTPException(status_code=404, detail="chunk not found")
return Response(
content=chunk.payload, media_type="application/octet-stream",
headers={
"ETag": f'"{chunk.checksum}"', "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key,
},
)
-451
View File
@@ -1,451 +0,0 @@
"""World content router -- rooms + chunks, for the editor and player client.
Implements `docs/architecture/world_persistence_impl.md` §8.1. JWT-authenticated
(``CurrentUser``, same as ``app/routers/worlds.py``). All chunk content
reads/writes go through ``WorldStore``; this module is HTTP plumbing +
authorization only.
Authorization (doc §5.3):
- **Read** (world visibility, room listing, chunk fetch/index/revisions):
same rule as ``GET /api/worlds/{id}`` -- 404 (not 403) if the world is
neither PUBLIC nor owned by the caller. See ``_get_world_readable``.
- **Write** (room create, chunk PUT/DELETE/rollback): caller must be
``world.owner_id``, matching ``update_world`` -- 403 otherwise, with
*no* visibility gate (this deliberately mirrors ``update_world``, which
also has no visibility check; see ``_get_world_writable``).
"""
from __future__ import annotations
import base64
import binascii
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
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.user import User
from app.models.world import World, WorldVisibility
from app.models.world_content import MAIN_ROOM_SLUG, WorldRoom
from app.services import chunk_codec
from app.services.game_servers import supervisor
from app.services.world_store import Bounds, RevisionConflict, RevisionNotAvailable, WorldStore
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# world_persistence_impl.md §8.1: "Bounds are capped (default 32x32 chunks
# per query) and return 422 beyond that."
MAX_BOUNDS_SPAN = 32
# §8.1 batch write cap, beyond WORLD_CHUNK_MAX_BATCH chunks: "Capped at ...
# 8 MB."
MAX_BATCH_TOTAL_BYTES = 8 * 1024 * 1024
# ─────────────────────────── auth helpers ───────────────────────────
async def _get_world_readable(world_id: int, current_user: User, db: AsyncSession) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if world is None:
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")
return world
async def _get_world_writable(world_id: int, current_user: User, db: AsyncSession) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
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")
return world
async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str) -> WorldRoom | None:
"""world_persistence_impl.md §4.1: lazily create the main room for
worlds that predate this feature, on first read/write."""
room = await store.get_room(world_id, slug)
if room is None and slug == MAIN_ROOM_SLUG:
room = await store.get_or_create_main_room(world_id)
await db.commit()
return room
def _is_live(world_id: int) -> bool:
return any(s["world_id"] == str(world_id) for s in supervisor.debug_state())
def _validation_error_body(exc: chunk_codec.ChunkValidationError) -> dict:
return {"detail": "chunk validation failed", "reason": exc.reason}
def _conflict_body(exc: RevisionConflict) -> dict:
body: dict = {
"detail": "revision conflict",
"current_revision": exc.current_revision,
"current_checksum": exc.current_checksum,
}
if exc.coord is not None:
body["coord"] = list(exc.coord)
return body
# ─────────────────────────── schemas ───────────────────────────
class RoomResponse(BaseModel):
id: int
slug: str
name: str
grid: dict
bounds: dict | None
spawn: dict | None
chunk_count: int
class RoomCreate(BaseModel):
slug: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-z0-9_-]+$")
name: str = Field(..., min_length=1, max_length=255)
grid: dict | None = None
bounds: dict | None = None
spawn: dict | None = None
# ─────────────────────────── rooms ───────────────────────────
@router.get("/{world_id}/rooms", response_model=list[RoomResponse])
async def list_rooms(world_id: int, current_user: CurrentUser, db: DbDep):
await _get_world_readable(world_id, current_user, db)
store = WorldStore(db)
rooms = await store.list_rooms(world_id)
if not rooms:
rooms = [await store.get_or_create_main_room(world_id)]
await db.commit()
return [
RoomResponse(
id=r.id, slug=r.slug, name=r.name, grid=r.grid, bounds=r.bounds, spawn=r.spawn,
chunk_count=await store.count_chunks(r.id),
)
for r in rooms
]
@router.post("/{world_id}/rooms", response_model=RoomResponse, status_code=201)
async def create_room(world_id: int, body: RoomCreate, current_user: CurrentUser, db: DbDep):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
if await store.get_room(world_id, body.slug) is not None:
raise HTTPException(status_code=409, detail="a room with this slug already exists")
room = await store.create_room(
world_id, body.slug, body.name, grid=body.grid, bounds=body.bounds, spawn=body.spawn
)
await db.commit()
await db.refresh(room)
return RoomResponse(
id=room.id, slug=room.slug, name=room.name, grid=room.grid,
bounds=room.bounds, spawn=room.spawn, chunk_count=0,
)
# ─────────────────────────── chunk index ───────────────────────────
@router.get("/{world_id}/rooms/{slug}/chunks")
async def list_chunks(
world_id: int, slug: str, current_user: CurrentUser, db: DbDep,
cx_min: int | None = None, cx_max: int | None = None,
cy_min: int | None = None, cy_max: int | None = None,
cz: int | None = None,
):
await _get_world_readable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
if cx_min is not None and cx_max is not None and (cx_max - cx_min + 1) > MAX_BOUNDS_SPAN:
raise HTTPException(status_code=422, detail=f"cx bounds span exceeds {MAX_BOUNDS_SPAN}")
if cy_min is not None and cy_max is not None and (cy_max - cy_min + 1) > MAX_BOUNDS_SPAN:
raise HTTPException(status_code=422, detail=f"cy bounds span exceeds {MAX_BOUNDS_SPAN}")
bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz)
chunks = await store.list_chunks(world_id, room.id, bounds)
content_revision = await store.content_revision(world_id)
return {
"room": room.slug,
"content_revision": content_revision,
"chunks": [
{
"key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz,
"revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len,
"is_mature": c.is_mature,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in chunks
],
}
# ─────────────────────────── chunk fetch ───────────────────────────
@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}")
async def get_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int,
current_user: CurrentUser, db: DbDep,
accept: Annotated[str | None, Header()] = None,
if_none_match: Annotated[str | None, Header(alias="If-None-Match")] = None,
):
await _get_world_readable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
chunk = await store.get_chunk(key)
if chunk is None:
raise HTTPException(status_code=404, detail="chunk not found")
etag = f'"{chunk.checksum}"'
headers = {"ETag": etag, "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key}
if if_none_match is not None and if_none_match.strip('"') == chunk.checksum:
return Response(status_code=304, headers=headers)
wants_json = (
accept is not None
and "application/json" in accept
and "application/octet-stream" not in accept
)
if wants_json:
decoded = chunk_codec.decode_binary(chunk.payload)
body = chunk_codec.to_json_dict(decoded, key=key, chunk_checksum=chunk.checksum)
return JSONResponse(content=body, headers=headers)
return Response(content=chunk.payload, media_type="application/octet-stream", headers=headers)
# ─────────────────────────── chunk write ───────────────────────────
async def _read_write_payload(request: Request, x_base_revision: int | None) -> tuple[bytes, int]:
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.json()
try:
payload_bytes = base64.b64decode(body["payload_b64"], validate=True)
except (KeyError, binascii.Error, ValueError) as exc:
raise HTTPException(status_code=422, detail=f"invalid payload_b64: {exc}")
base_revision = int(body.get("base_revision", 0))
return payload_bytes, base_revision
payload_bytes = await request.body()
return payload_bytes, (x_base_revision if x_base_revision is not None else 0)
@router.put("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}")
async def put_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int,
request: Request, current_user: CurrentUser, db: DbDep,
x_base_revision: Annotated[int | None, Header(alias="X-Base-Revision")] = None,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
payload_bytes, base_revision = await _read_write_payload(request, x_base_revision)
if len(payload_bytes) > settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"payload exceeds WORLD_CHUNK_MAX_PAYLOAD_BYTES ({settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES})",
)
try:
chunk = await store.put_chunk(
room=room, cx=cx, cy=cy, cz=cz, payload_bytes=payload_bytes,
base_revision=base_revision, actor_id=current_user.id,
)
except chunk_codec.ChunkCodecError as exc:
await db.rollback()
return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)})
except chunk_codec.ChunkValidationError as exc:
await db.rollback()
return JSONResponse(status_code=422, content=_validation_error_body(exc))
except RevisionConflict as exc:
# Nothing must stick from a failed write. Returning a JSONResponse
# here (rather than raising) means no exception propagates through
# `get_db`, so this rollback has to be explicit -- see the note on
# the batch endpoint below, where this is load-bearing for
# all-or-nothing semantics.
await db.rollback()
return JSONResponse(status_code=409, content=_conflict_body(exc))
await db.commit()
await db.refresh(chunk)
return {
"key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum,
"payload_len": chunk.payload_len,
"updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None,
"is_mature": chunk.is_mature, "live_session": _is_live(world_id),
}
# ─────────────────────────── batch write ───────────────────────────
class BatchChunkItem(BaseModel):
cx: int
cy: int
cz: int
base_revision: int
payload_b64: str
class BatchPutRequest(BaseModel):
chunks: list[BatchChunkItem]
@router.put("/{world_id}/rooms/{slug}/chunks")
async def put_chunks_batch(
world_id: int, slug: str, body: BatchPutRequest, current_user: CurrentUser, db: DbDep,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
if len(body.chunks) > settings.WORLD_CHUNK_MAX_BATCH:
raise HTTPException(
status_code=422,
detail=f"batch of {len(body.chunks)} exceeds WORLD_CHUNK_MAX_BATCH ({settings.WORLD_CHUNK_MAX_BATCH})",
)
items = []
total_bytes = 0
for item in body.chunks:
try:
payload_bytes = base64.b64decode(item.payload_b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=422, detail=f"invalid payload_b64 for ({item.cx},{item.cy},{item.cz}): {exc}")
total_bytes += len(payload_bytes)
items.append({
"cx": item.cx, "cy": item.cy, "cz": item.cz,
"base_revision": item.base_revision, "payload_bytes": payload_bytes,
})
if total_bytes > MAX_BATCH_TOTAL_BYTES:
raise HTTPException(status_code=413, detail=f"batch exceeds {MAX_BATCH_TOTAL_BYTES} bytes")
try:
rows = await store.put_chunks_batch(room=room, items=items, actor_id=current_user.id)
except chunk_codec.ChunkCodecError as exc:
# All-or-nothing (world_persistence_impl.md §8.1): earlier items in
# this batch may already be flushed (but never committed) to this
# transaction. Rolling back here is what actually makes the batch
# atomic -- without it, returning a JSONResponse instead of raising
# means no exception reaches `get_db`, and its own transaction
# handling would otherwise commit those earlier writes anyway.
await db.rollback()
return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)})
except chunk_codec.ChunkValidationError as exc:
await db.rollback()
return JSONResponse(status_code=422, content=_validation_error_body(exc))
except RevisionConflict as exc:
await db.rollback()
return JSONResponse(status_code=409, content=_conflict_body(exc))
await db.commit()
for row in rows:
await db.refresh(row)
return {
"live_session": _is_live(world_id),
"chunks": [
{
"key": r.chunk_key, "revision": r.revision, "checksum": r.checksum,
"payload_len": r.payload_len,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
"is_mature": r.is_mature,
}
for r in rows
],
}
# ─────────────────────────── delete ───────────────────────────
@router.delete("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}", status_code=204)
async def delete_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
room = await _resolve_room(store, db, world_id, slug)
if room is None:
raise HTTPException(status_code=404, detail="room not found")
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
await store.delete_chunk(key, current_user.id)
await db.commit()
return Response(status_code=204)
# ─────────────────────────── revisions + rollback ───────────────────────────
@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions")
async def chunk_revisions(
world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep,
):
# doc: "(owner)" -- same gate as writes.
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
revisions = await store.list_revisions(key)
return [
{
"revision": r.revision, "action": r.action, "actor_id": r.actor_id,
"checksum": r.checksum, "payload_available": r.payload is not None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in revisions
]
class RollbackRequest(BaseModel):
revision: int
@router.post("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback")
async def rollback_chunk(
world_id: int, slug: str, cx: int, cy: int, cz: int,
body: RollbackRequest, current_user: CurrentUser, db: DbDep,
):
await _get_world_writable(world_id, current_user, db)
store = WorldStore(db)
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
try:
chunk = await store.rollback_chunk(key, body.revision, current_user.id)
except RevisionNotAvailable as exc:
await db.rollback()
return JSONResponse(status_code=404, content={"detail": str(exc)})
except chunk_codec.ChunkValidationError as exc:
await db.rollback()
return JSONResponse(status_code=422, content=_validation_error_body(exc))
await db.commit()
await db.refresh(chunk)
return {
"key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum,
"payload_len": chunk.payload_len,
"updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None,
"is_mature": chunk.is_mature, "live_session": _is_live(world_id),
}
+1 -44
View File
@@ -12,8 +12,6 @@ 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
from app.services.world_store import WorldStore
router = APIRouter()
@@ -108,52 +106,13 @@ async def get_world(
return world
class JoinResponse(BaseModel):
"""Address of the dedicated server hosting this world."""
world_id: int
host: str
port: int
@router.post("/{world_id}/join", response_model=JoinResponse)
async def join_world(
world_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Ensure a dedicated game server is running for this world and return its
ENet address for the client to connect to.
503 if game-server orchestration isn't enabled on this backend (e.g. a
plain dev/test instance with no Godot binary).
"""
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="World not found")
try:
info = await supervisor.ensure_server(str(world_id))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
return JoinResponse(world_id=world_id, host=info["host"], port=info["port"])
@router.post("/", response_model=WorldResponse, status_code=201)
async def create_world(
world_data: WorldCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new world.
Also creates the world's "main" room (world_persistence_impl.md §4.1) --
every world gets one so chunk content has somewhere to live from the
start.
"""
"""Create a new world."""
world = World(
name=world_data.name,
description=world_data.description,
@@ -161,8 +120,6 @@ async def create_world(
visibility=world_data.visibility,
)
db.add(world)
await db.flush() # assigns world.id, needed for the room's FK
await WorldStore(db).get_or_create_main_room(world.id)
await db.commit()
await db.refresh(world)
return world
-676
View File
@@ -1,676 +0,0 @@
"""Python port of the chunk binary/JSON codec, `docs/architecture/world.md` §3.
This is the server-side half of the "single shared format, two
implementations" contract world.md §5.2 and
`world_persistence_impl.md` §5.5/§9 call for. The Godot-side implementation
is `scripts/chunk_data_decoder.gd`; this module must decode anything it
produces and produce bytes it can decode.
IMPORTANT DEVIATION (see the "Known cross-codec divergence" note at the
bottom of this file): the current `scripts/chunk_data_decoder.gd` does not
actually implement IEEE-754 float32 for the `pos`/`rot`/`scale`/`bounds`
fields -- it performs a lossy numeric `int`/`float` cast instead of a bit
reinterpretation, which both truncates fractional values and does not
write IEEE-754 bits at all. This module implements the format as
*specified* (real `<f` IEEE-754 float32, matching every other field in the
spec and matching what any conformant decoder would assume). See the note
for what this means for cross-codec byte parity today.
Nothing here talks to a database or the FastAPI app; it is a pure,
side-effect-free codec so it can be golden-file tested in isolation.
"""
from __future__ import annotations
import hashlib
import json
import struct
from dataclasses import dataclass, field
MAGIC = b"AGPC"
HEADER_SIZE = 48
CURRENT_VERSION = 1
SECTION_META = 0x01
SECTION_TILE = 0x02
SECTION_OBJECT = 0x03
SECTION_FLAGS = 0x04
SECTION_REVISION_META = 0x05
CATEGORY_FIELDS = (
"violence", "sexual", "language", "substance",
"gambling", "horror", "hate", "custom",
)
class ChunkCodecError(Exception):
"""Malformed bytes -- the payload isn't shaped like a chunk at all."""
class ChunkValidationError(Exception):
"""A structurally-valid chunk failed a content/business-rule check.
``reason`` is the machine-readable code the API surface (world_persistence_impl.md
§8.1) puts in the 422 response body's ``reason`` field, e.g.
``"checksum_mismatch"``, ``"layer_dims"``, ``"object_out_of_bounds"``.
"""
def __init__(self, reason: str, message: str | None = None) -> None:
self.reason = reason
super().__init__(message or reason)
# ───────────────────────── Logical model ─────────────────────────
@dataclass
class TileDef:
id: int
asset_id: str = ""
solid: bool = False
flags: int = 0
variants: int = 1
@dataclass
class TileLayer:
name: str
layer_id: int = 0
width: int = 0
height: int = 0
depth: int = 1
palette: list[TileDef] = field(default_factory=list)
tiles: list[int] = field(default_factory=list) # flattened, row-major
@dataclass
class ContentFlags:
violence: int = 0
sexual: int = 0
language: int = 0
substance: int = 0
gambling: int = 0
horror: int = 0
hate: int = 0
custom: int = 0
mature: bool = False
sources: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"categories": {name: getattr(self, name) for name in CATEGORY_FIELDS},
"mature": self.mature,
"sources": list(self.sources),
}
@classmethod
def from_dict(cls, d: dict) -> "ContentFlags":
cats = d.get("categories", {})
return cls(
**{name: int(cats.get(name, 0)) for name in CATEGORY_FIELDS},
mature=bool(d.get("mature", False)),
sources=list(d.get("sources", [])),
)
@dataclass
class WorldObject:
id: str
type: str
asset_id: str
pos: tuple[float, float, float] = (0.0, 0.0, 0.0)
rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)
scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
dynamic: bool = False
properties: dict = field(default_factory=dict)
flags: ContentFlags = field(default_factory=ContentFlags)
@dataclass
class ChunkBounds:
"""Stored here as min-corner + size (matching the Godot client's AABB
type) for convenience, but the §3.6 BINARY wire format is min + max
(world.md §3.1, and the client's actual ChunkDataDecoder) -- fixed
2026-09-14 after a real client/server interop test caught this class's
original binary codec writing min+size on the wire, which the client
decoder (unaware of the "size" convention) read as a second min/max
pair, corrupting every chunk's bounds. The two representations are
fully recoverable from each other; `max` below is computed for exactly
this reason."""
min: tuple[float, float, float] = (0.0, 0.0, 0.0)
size: tuple[float, float, float] = (0.0, 0.0, 0.0)
@property
def max(self) -> tuple[float, float, float]:
return tuple(a + b for a, b in zip(self.min, self.size))
@dataclass
class ChunkPayload:
version: int = CURRENT_VERSION
world_id: str = ""
room_id: str = ""
coord: tuple[int, int, int] = (0, 0, 0)
bounds: ChunkBounds = field(default_factory=ChunkBounds)
tile_layers: list[TileLayer] = field(default_factory=list)
objects: list[WorldObject] = field(default_factory=list)
flags: ContentFlags = field(default_factory=ContentFlags)
created_at: int = 0
updated_at: int = 0
revision: int = 0
author: str = ""
def build_key(world_id: str | int, room_slug: str, cx: int, cy: int, cz: int) -> str:
"""world_persistence_impl.md §4.5: ``"{world.id}:{room.slug}:{cx}:{cy}:{cz}"``."""
return f"{world_id}:{room_slug}:{cx}:{cy}:{cz}"
# ───────────────────────── Binary primitives ─────────────────────────
# All integers little-endian, matching world.md §3.6.3. Floats are packed
# as real IEEE-754 float32 -- see the module docstring's deviation note.
def _read_u16(data: bytes, off: int) -> int:
return struct.unpack_from("<H", data, off)[0]
def _read_u32(data: bytes, off: int) -> int:
return struct.unpack_from("<I", data, off)[0]
def _read_i32(data: bytes, off: int) -> int:
return struct.unpack_from("<i", data, off)[0]
def _read_i64(data: bytes, off: int) -> int:
return struct.unpack_from("<q", data, off)[0]
def _read_f32(data: bytes, off: int) -> float:
return struct.unpack_from("<f", data, off)[0]
def _w_u16(v: int) -> bytes:
return struct.pack("<H", v & 0xFFFF)
def _w_u32(v: int) -> bytes:
return struct.pack("<I", v & 0xFFFFFFFF)
def _w_i32(v: int) -> bytes:
return struct.pack("<i", v)
def _w_i64(v: int) -> bytes:
return struct.pack("<q", v)
def _w_f32(v: float) -> bytes:
return struct.pack("<f", v)
def _read_str(data: bytes, off: int) -> tuple[str, int]:
"""Length-prefixed UTF-8 string. Returns (value, next_offset)."""
n = _read_u16(data, off)
off += 2
s = data[off:off + n].decode("utf-8")
return s, off + n
def _w_str(s: str) -> bytes:
b = s.encode("utf-8")
if len(b) > 0xFFFF:
raise ChunkCodecError(f"string field too long to encode ({len(b)} bytes)")
return _w_u16(len(b)) + b
def checksum(payload_body: bytes) -> str:
"""SHA-256 hex digest of the payload body (world.md §3.6.3)."""
return hashlib.sha256(payload_body).hexdigest()
# ───────────────────────── Section codecs ─────────────────────────
def _encode_metadata(payload: ChunkPayload) -> bytes:
out = bytearray()
out += _w_str(payload.world_id)
out += _w_str(payload.room_id)
out += _w_i32(payload.coord[0])
out += _w_i32(payload.coord[1])
out += _w_i32(payload.coord[2])
out += _w_f32(payload.bounds.min[0])
out += _w_f32(payload.bounds.min[1])
out += _w_f32(payload.bounds.min[2])
out += _w_f32(payload.bounds.max[0])
out += _w_f32(payload.bounds.max[1])
out += _w_f32(payload.bounds.max[2])
return bytes(out)
def _decode_metadata(data: bytes, result: ChunkPayload) -> None:
off = 0
result.world_id, off = _read_str(data, off)
result.room_id, off = _read_str(data, off)
coord = (_read_i32(data, off), _read_i32(data, off + 4), _read_i32(data, off + 8))
off += 12
min_pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
max_pos = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20))
size = tuple(a - b for a, b in zip(max_pos, min_pos))
result.coord = coord
result.bounds = ChunkBounds(min=min_pos, size=size)
def _encode_tile_layer(layer: TileLayer) -> bytes:
out = bytearray()
out += _w_str(layer.name)
out += _w_u16(layer.layer_id)
out += _w_u16(layer.width)
out += _w_u16(layer.height)
out += _w_u16(layer.depth)
out += _w_u16(len(layer.palette))
for td in layer.palette:
out += _w_u16(td.id)
out += _w_str(td.asset_id)
out += bytes([1 if td.solid else 0])
out += _w_u32(td.flags)
out += bytes([td.variants & 0xFF])
out += _w_u32(len(layer.tiles))
for tile_id in layer.tiles:
out += _w_u16(tile_id)
return bytes(out)
def _decode_tile_layer(data: bytes) -> TileLayer:
off = 0
name, off = _read_str(data, off)
layer_id = _read_u16(data, off); off += 2
width = _read_u16(data, off); off += 2
height = _read_u16(data, off); off += 2
depth = _read_u16(data, off); off += 2
palette_count = _read_u16(data, off); off += 2
palette: list[TileDef] = []
for _ in range(palette_count):
tid = _read_u16(data, off); off += 2
asset_id, off = _read_str(data, off)
solid = data[off] != 0; off += 1
flags = _read_u32(data, off); off += 4
variants = data[off]; off += 1
palette.append(TileDef(id=tid, asset_id=asset_id, solid=solid, flags=flags, variants=variants))
tile_count = _read_u32(data, off); off += 4
tiles: list[int] = []
for _ in range(tile_count):
tiles.append(_read_u16(data, off))
off += 2
return TileLayer(name=name, layer_id=layer_id, width=width, height=height,
depth=depth, palette=palette, tiles=tiles)
def _encode_flags(flags: ContentFlags) -> bytes:
out = bytearray()
for name in CATEGORY_FIELDS:
out.append(getattr(flags, name) & 0xFF)
out.append(1 if flags.mature else 0)
out += _w_u16(len(flags.sources))
for src in flags.sources:
out += _w_str(src)
return bytes(out)
def _decode_flags(data: bytes) -> ContentFlags:
off = 0
values = {}
for name in CATEGORY_FIELDS:
values[name] = data[off]
off += 1
mature = data[off] != 0
off += 1
source_count = _read_u16(data, off)
off += 2
sources = []
for _ in range(source_count):
s, off = _read_str(data, off)
sources.append(s)
return ContentFlags(**values, mature=mature, sources=sources)
def _encode_object(obj: WorldObject) -> bytes:
out = bytearray()
out += _w_str(obj.id)
out += _w_str(obj.type)
out += _w_str(obj.asset_id)
for v in obj.pos:
out += _w_f32(v)
for v in obj.rot:
out += _w_f32(v)
for v in obj.scale:
out += _w_f32(v)
out.append(1 if obj.dynamic else 0)
# Canonical JSON: keys sorted lexicographically (world.md §3.6.3).
props_json = json.dumps(obj.properties, sort_keys=True, separators=(",", ":"))
out += _w_str(props_json)
out += _encode_flags(obj.flags)
return bytes(out)
def _decode_object(data: bytes) -> WorldObject:
off = 0
oid, off = _read_str(data, off)
otype, off = _read_str(data, off)
asset_id, off = _read_str(data, off)
pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
off += 12
rot = (
_read_f32(data, off), _read_f32(data, off + 4),
_read_f32(data, off + 8), _read_f32(data, off + 12),
)
off += 16
scale = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
off += 12
dynamic = data[off] != 0
off += 1
props_str, off = _read_str(data, off)
try:
properties = json.loads(props_str) if props_str else {}
except json.JSONDecodeError as exc:
raise ChunkCodecError(f"object {oid!r}: invalid properties JSON") from exc
obj_flags = _decode_flags(data[off:])
return WorldObject(id=oid, type=otype, asset_id=asset_id, pos=pos, rot=rot,
scale=scale, dynamic=dynamic, properties=properties, flags=obj_flags)
def _encode_revision_meta(payload: ChunkPayload) -> bytes:
out = bytearray()
out += _w_i64(payload.created_at)
out += _w_i64(payload.updated_at)
out += _w_u32(payload.revision)
out += _w_str(payload.author)
return bytes(out)
def _decode_revision_meta(data: bytes, result: ChunkPayload) -> None:
off = 0
result.created_at = _read_i64(data, off); off += 8
result.updated_at = _read_i64(data, off); off += 8
result.revision = _read_u32(data, off); off += 4
result.author, off = _read_str(data, off)
def _wrap_section(section_id: int, data: bytes) -> bytes:
return bytes([section_id]) + _w_u32(len(data)) + data
# ───────────────────────── Top-level encode/decode ─────────────────────────
def encode_binary(payload: ChunkPayload) -> bytes:
"""Encode a ChunkPayload to the world.md §3.6 wire format."""
sections = bytearray()
sections += _wrap_section(SECTION_META, _encode_metadata(payload))
for layer in payload.tile_layers:
sections += _wrap_section(SECTION_TILE, _encode_tile_layer(layer))
for obj in payload.objects:
sections += _wrap_section(SECTION_OBJECT, _encode_object(obj))
sections += _wrap_section(SECTION_FLAGS, _encode_flags(payload.flags))
sections += _wrap_section(SECTION_REVISION_META, _encode_revision_meta(payload))
payload_body = bytes(sections)
digest = hashlib.sha256(payload_body).digest() # 32 raw bytes
header = bytearray()
header += MAGIC
header += _w_u16(CURRENT_VERSION)
header += _w_u16(HEADER_SIZE - 8) # header_len: bytes of header after this field (spec-correct)
header += _w_u32(0) # flags (reserved)
header += _w_u32(len(payload_body))
header += digest
return bytes(header) + payload_body
def decode_binary(data: bytes) -> ChunkPayload:
"""Decode a world.md §3.6 binary chunk. Raises ChunkCodecError on
structurally malformed input (short buffer, bad magic, unsupported
version, truncated payload)."""
if len(data) < HEADER_SIZE:
raise ChunkCodecError(f"data too short for header ({len(data)} bytes)")
if data[0:4] != MAGIC:
raise ChunkCodecError("bad magic bytes")
version = _read_u16(data, 4)
if version < 1 or version > CURRENT_VERSION:
raise ChunkCodecError(f"unsupported chunk format version {version}")
# header_len (offset 6) is intentionally not used for offset math --
# both this decoder and the Godot one always slice at the fixed
# HEADER_SIZE, matching the reference implementation's actual (if
# spec-inconsistent) behaviour.
payload_len = _read_u32(data, 12)
# bytes 16:48 are the header's advisory checksum -- decode_and_validate
# re-reads it directly and recomputes against the parsed payload body.
if len(data) - HEADER_SIZE != payload_len:
raise ChunkCodecError(
f"declared payload_len ({payload_len}) does not match actual "
f"remaining bytes ({len(data) - HEADER_SIZE})"
)
payload_body = data[HEADER_SIZE:HEADER_SIZE + payload_len]
result = ChunkPayload(version=version)
offset = 0
while offset < len(payload_body):
if offset + 5 > len(payload_body):
raise ChunkCodecError("truncated section header")
section_id = payload_body[offset]
section_len = _read_u32(payload_body, offset + 1)
data_start = offset + 5
data_end = data_start + section_len
if data_end > len(payload_body):
raise ChunkCodecError("truncated section body")
section_data = payload_body[data_start:data_end]
if section_id == SECTION_META:
_decode_metadata(section_data, result)
elif section_id == SECTION_TILE:
result.tile_layers.append(_decode_tile_layer(section_data))
elif section_id == SECTION_OBJECT:
result.objects.append(_decode_object(section_data))
elif section_id == SECTION_FLAGS:
result.flags = _decode_flags(section_data)
elif section_id == SECTION_REVISION_META:
_decode_revision_meta(section_data, result)
# Unknown section ids are skipped (forward-compat), matching the
# Godot decoder's match-with-no-default behaviour.
offset = data_end
return result
# ───────────────────────── JSON encoding (world.md §3.5) ─────────────────────────
# Derived, never stored (world_persistence_impl.md §8.1) -- produced here
# from a decoded ChunkPayload for the ``Accept: application/json`` path.
def to_json_dict(payload: ChunkPayload, *, key: str, chunk_checksum: str) -> dict:
return {
"version": payload.version,
"key": key,
"world_id": payload.world_id,
"room_id": payload.room_id,
"coord": {"cx": payload.coord[0], "cy": payload.coord[1], "cz": payload.coord[2]},
"bounds": {
"min_x": payload.bounds.min[0], "min_y": payload.bounds.min[1], "min_z": payload.bounds.min[2],
"max_x": payload.bounds.max[0], "max_y": payload.bounds.max[1], "max_z": payload.bounds.max[2],
},
"tiles": [
{
"name": layer.name,
"layer_id": layer.layer_id,
"width": layer.width,
"height": layer.height,
"depth": layer.depth,
"palette": [
{"id": td.id, "asset_id": td.asset_id, "solid": td.solid,
"flags": td.flags, "variants": td.variants}
for td in layer.palette
],
"tiles": list(layer.tiles),
}
for layer in payload.tile_layers
],
"objects": [
{
"id": obj.id, "type": obj.type, "asset_id": obj.asset_id,
"transform": {
"pos": list(obj.pos), "rot": list(obj.rot), "scale": list(obj.scale),
},
"properties": obj.properties,
"dynamic": obj.dynamic,
"flags": obj.flags.to_dict(),
}
for obj in payload.objects
],
"flags": payload.flags.to_dict(),
"meta": {
"created_at": payload.created_at, "updated_at": payload.updated_at,
"revision": payload.revision, "author": payload.author,
},
"checksum": chunk_checksum,
}
# ───────────────────────── Validation (world_persistence_impl.md §5.5) ─────────────────────────
# Not precisely quantified by the design doc beyond "length limits" --
# these are this implementation's conservative defaults (world.md §7).
MAX_STRING_FIELD_LEN = 256
MAX_PROPERTIES_JSON_LEN = 16_384
MAX_SOURCES_PER_FLAGSET = 32
def _check_string_lengths(obj: WorldObject) -> None:
for field_name, value in (("id", obj.id), ("type", obj.type), ("asset_id", obj.asset_id)):
if len(value) > MAX_STRING_FIELD_LEN:
raise ChunkValidationError(
"string_field_too_long", f"object.{field_name} exceeds {MAX_STRING_FIELD_LEN} chars"
)
props_json = json.dumps(obj.properties)
if len(props_json) > MAX_PROPERTIES_JSON_LEN:
raise ChunkValidationError(
"string_field_too_long", f"object {obj.id!r} properties exceed {MAX_PROPERTIES_JSON_LEN} bytes"
)
if len(obj.flags.sources) > MAX_SOURCES_PER_FLAGSET:
raise ChunkValidationError("string_field_too_long", f"object {obj.id!r} has too many flag sources")
def decode_and_validate(
data: bytes,
*,
expected_key: str,
grid_size: tuple[int, int, int],
max_objects: int,
) -> ChunkPayload:
"""Decode + fully validate a client-submitted chunk payload.
This is the boundary world_persistence_impl.md §5.5 requires: "the
backend must not trust the client". Raises ``ChunkCodecError`` for
structurally malformed bytes (mapped to a 422 by the router with a
generic reason) or ``ChunkValidationError`` with a specific ``reason``
code for a well-formed-but-invalid payload.
"""
payload = decode_binary(data) # raises ChunkCodecError for structural problems first
# Checksum is advisory in the header (world.md §7); the value that
# matters is recomputed from the actual payload body we just parsed.
payload_body = data[HEADER_SIZE:]
recomputed = checksum(payload_body)
header_checksum_hex = data[16:48].hex()
if header_checksum_hex != recomputed:
raise ChunkValidationError(
"checksum_mismatch",
"header checksum does not match the recomputed SHA-256 of the payload body",
)
actual_key = build_key(payload.world_id, payload.room_id, *payload.coord)
if actual_key != expected_key:
raise ChunkValidationError(
"chunk_key_mismatch",
f"payload key {actual_key!r} does not match URL {expected_key!r}",
)
for layer in payload.tile_layers:
if (layer.width, layer.height, layer.depth) != tuple(grid_size):
raise ChunkValidationError(
"layer_dims",
f"tile layer {layer.name!r} dims {(layer.width, layer.height, layer.depth)} "
f"!= room grid {tuple(grid_size)}",
)
valid_ids = {0} | {td.id for td in layer.palette}
for tile_id in layer.tiles:
if tile_id not in valid_ids:
raise ChunkValidationError(
"tile_out_of_palette",
f"tile layer {layer.name!r} references palette id {tile_id} not in its palette",
)
if len(layer.tiles) != layer.width * layer.height * layer.depth:
raise ChunkValidationError(
"layer_dims",
f"tile layer {layer.name!r} has {len(layer.tiles)} cells, expected "
f"{layer.width * layer.height * layer.depth}",
)
if len(payload.objects) > max_objects:
raise ChunkValidationError(
"too_many_objects", f"{len(payload.objects)} objects exceeds max {max_objects}"
)
bmin, bmax = payload.bounds.min, payload.bounds.max
for obj in payload.objects:
_check_string_lengths(obj)
if not all(bmin[i] <= obj.pos[i] <= bmax[i] for i in range(3)):
raise ChunkValidationError(
"object_out_of_bounds",
f"object {obj.id!r} position {obj.pos} outside chunk bounds "
f"[{bmin}, {bmax}]",
)
if len(payload.author) > MAX_STRING_FIELD_LEN:
raise ChunkValidationError("string_field_too_long", "meta.author too long")
return payload
# ───────────────────────── Known cross-codec divergence ─────────────────────────
#
# `scripts/chunk_data_decoder.gd`'s `_read_float32`/`_write_float32` do:
#
# static func _write_float32(val: float) -> PackedByteArray:
# var bits := val as int
# return _write_uint32(bits)
#
# static func _read_float32(data: PackedByteArray, offset: int) -> float:
# var bits := _read_uint32(data, offset)
# return bits as float
#
# `as` between int and float in GDScript is a numeric conversion, not a bit
# reinterpretation (verified empirically against Godot 4.6.2 headless:
# `1.5 as int` -> `1`, `1 as float` -> `1.0`; the IEEE-754 bit pattern of
# 1.5f is 0x3FC00000 = 1069547520, not 1). So the shipped client codec
# truncates every float32 field (bounds, object pos/rot/scale) to its
# integer part and stores that integer as a raw int32 -- not as float32
# bits at all. This is a real bug in already-merged, client-side code
# (scripts/ is out of scope for this task) that breaks the "byte-stable
# across two implementations" guarantee world.md §3.6.3 states.
#
# This module implements the *specified* format (real IEEE-754 float32)
# rather than replicating that bug, because world.md §3.6 is normative and
# says "float32" for these fields, and mirroring the bug would mean the
# backend also corrupts every non-integer transform/bounds value it
# stores. Practical effect until the client-side bug is fixed: payloads
# with integer-only floats will decode identically on both sides; any
# payload with a fractional position/rotation/scale/bounds value will not
# be byte-identical (or numerically identical) between this module and the
# current `scripts/chunk_data_decoder.gd`. This is flagged for the
# client/editor follow-on task, not fixed here.
-239
View File
@@ -1,239 +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):
- Port allocation is a linear scan of the configured range.
- No crash-restart: if a server process dies, the next join respawns it.
With world content now durable in Postgres (world_persistence_impl.md
D2), this is genuinely safe rather than merely tolerated: the sim
process holds no unsaved world content, so a reap/crash/kill can never
lose world data.
Per-spawn auth token (world_persistence_impl.md §7.3): each spawned server
gets a random ``secrets.token_urlsafe(32)`` on its command line, which it
uses as a Bearer token against ``/api/internal`` to pull its world's
content read-only before binding its ENet port. The token grants access to
exactly one world and dies with its process (the reaper's
``del self._servers[wid]`` invalidates it; see ``validate_token``).
"""
from __future__ import annotations
import asyncio
import hmac
import json
import secrets
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
token: str
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 is_ready(self) -> bool:
return self.status().get("ready") is True
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
try:
await self._await_ready(srv)
except TimeoutError:
# Real failure now, not a silent "hope it's fine" --
# see _await_ready's docstring. Don't leave a possibly
# half-started process registered.
srv.stop()
del self._servers[world_id]
raise RuntimeError(
f"dedicated server for world {world_id} did not become ready in time"
)
srv.last_join = time.time()
return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id}
def validate_token(self, world_id: str, token: str) -> bool:
"""`require_game_server` dependency support (world_persistence_impl.md
§7.3): true iff *token* is the live token for *world_id*'s server.
A dict lookup, as the doc specifies, plus a constant-time compare
since it's a bearer credential."""
srv = self._servers.get(str(world_id))
if srv is None or not srv.alive():
return False
return hmac.compare_digest(srv.token, token)
# ── internals ────────────────────────────────────────────────────────
def _spawn(self, world_id: str) -> _WorldServer:
port = self._free_port()
token = secrets.token_urlsafe(32)
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),
"--api-url", settings.GAME_SERVER_INTERNAL_API_URL,
"--server-token", token,
]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return _WorldServer(
world_id=world_id, port=port, proc=proc, status_path=status_path, token=token
)
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_ready(self, srv: _WorldServer) -> None:
"""Wait for a ``"ready": true`` field in the status file
(world_persistence_impl.md §7.4).
This replaces the old ``_await_port``, which had two real bugs:
it treated a bound UDP port as "ready", but with content loading
the port binds *before* the server has fetched its world's rooms
and chunks (world_persistence_impl.md §7.2 step 4) -- so a client
could get handed an address for a server still mid-fetch. And it
*failed open*: past its 8s deadline it simply returned, with no
error, so ``ensure_server`` reported success for a server that may
never have started at all. This raises ``TimeoutError`` instead,
which ``ensure_server`` turns into a real 503 for the joining
client, and also bails out early (same exception) if the process
has already exited.
"""
deadline = time.time() + settings.GAME_SERVER_READY_TIMEOUT_SECONDS
while time.time() < deadline:
if srv.is_ready():
return
if not srv.alive():
raise TimeoutError(
f"server process for world {srv.world_id} exited before becoming ready"
)
await asyncio.sleep(0.25)
raise TimeoutError(
f"server for world {srv.world_id} did not report ready within "
f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s"
)
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()
-431
View File
@@ -1,431 +0,0 @@
"""World content storage service.
Implements the `world.md` §5.1 service interface over Postgres, per
`world_persistence_impl.md` §9 -- **minus** ``flush_dirty`` and
``rebuild_index``, which don't apply once there is no write-behind dirty
set and no derived index to rebuild (see that doc's §3.3 and §4.2).
This is the *only* place chunk content is written (world_persistence_impl.md
D2 / §5). Callers (the routers) own transaction boundaries: methods here
``flush()`` so later statements in the same request see earlier ones and so
integrity errors surface before a caller commits, but they never
``commit()`` -- that mirrors the existing convention in
``app/routers/worlds.py`` (``get_db`` docs: "Handlers are responsible for
committing or rolling back their own transactions").
Known gap this module had to resolve on its own (flagged, not silently
decided): `world_persistence_impl.md` §6/§7.2/§8.1 all reference a world's
monotonic ``content_revision`` ("bumped on every chunk write"), but no
column for it is defined anywhere in §4's schema. Rather than add an
undocumented column, ``content_revision()`` below derives it as
``MAX(id)`` over ``world_chunk_revisions`` scoped to the world: that table
is append-only (retention only nulls ``payload``, it never deletes rows),
so the value is genuinely monotonic and increments on every
create/update/delete/rollback.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import load_only
from app.config import settings
from app.models.world_content import (
MAIN_ROOM_NAME,
MAIN_ROOM_SLUG,
WorldChunk,
WorldChunkRevision,
WorldRoom,
)
from app.services import chunk_codec
class RevisionConflict(Exception):
"""Optimistic-concurrency conflict (world.md §5.4). ``current_revision``
is 0 and ``current_checksum`` is None when the caller's ``base_revision``
assumed a chunk exists but it doesn't."""
def __init__(
self,
current_revision: int,
current_checksum: str | None,
coord: tuple[int, int, int] | None = None,
) -> None:
self.current_revision = current_revision
self.current_checksum = current_checksum
self.coord = coord
super().__init__(f"revision conflict: current_revision={current_revision}")
class RevisionNotAvailable(Exception):
"""Raised by ``rollback_chunk`` when the requested revision doesn't
exist, or exists but its payload was pruned by the retention policy
(world_persistence_impl.md §4.3)."""
@dataclass
class Bounds:
cx_min: int | None = None
cx_max: int | None = None
cy_min: int | None = None
cy_max: int | None = None
cz: int | None = None
class WorldStore:
"""Owns chunk/room read/write operations over the async session
handed to it. One instance per request is the expected usage
(``WorldStore(db)`` where ``db`` is the ``DbDep`` session)."""
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ── rooms ────────────────────────────────────────────────────────────
async def get_room(self, world_id: int, slug: str) -> WorldRoom | None:
result = await self.db.execute(
select(WorldRoom).where(WorldRoom.world_id == world_id, WorldRoom.slug == slug)
)
return result.scalar_one_or_none()
async def list_rooms(self, world_id: int) -> list[WorldRoom]:
result = await self.db.execute(
select(WorldRoom).where(WorldRoom.world_id == world_id).order_by(WorldRoom.id)
)
return list(result.scalars().all())
async def create_room(
self,
world_id: int,
slug: str,
name: str,
*,
grid: dict | None = None,
bounds: dict | None = None,
spawn: dict | None = None,
) -> WorldRoom:
room = WorldRoom(world_id=world_id, slug=slug, name=name)
if grid is not None:
room.grid = grid
if bounds is not None:
room.bounds = bounds
if spawn is not None:
room.spawn = spawn
self.db.add(room)
await self.db.flush()
await self.db.refresh(room)
return room
async def get_or_create_main_room(self, world_id: int) -> WorldRoom:
"""world_persistence_impl.md §4.1: every world gets a ``main`` room.
Called from ``create_world`` for new worlds, and lazily here for
worlds that predate this migration."""
room = await self.get_room(world_id, MAIN_ROOM_SLUG)
if room is not None:
return room
try:
return await self.create_room(world_id, MAIN_ROOM_SLUG, MAIN_ROOM_NAME)
except IntegrityError:
# Lost a race with a concurrent creator; the row now exists.
await self.db.rollback()
room = await self.get_room(world_id, MAIN_ROOM_SLUG)
if room is None:
raise
return room
async def count_chunks(self, room_id: int) -> int:
result = await self.db.execute(
select(func.count()).select_from(WorldChunk).where(WorldChunk.room_id == room_id)
)
return int(result.scalar_one())
async def content_revision(self, world_id: int) -> int:
"""See module docstring for why this is derived rather than stored."""
result = await self.db.execute(
select(func.max(WorldChunkRevision.id)).where(WorldChunkRevision.world_id == world_id)
)
return int(result.scalar() or 0)
# ── chunk reads ──────────────────────────────────────────────────────
async def get_chunk(self, key: str) -> WorldChunk | None:
result = await self.db.execute(select(WorldChunk).where(WorldChunk.chunk_key == key))
return result.scalar_one_or_none()
async def get_chunk_by_coord(self, room_id: int, cx: int, cy: int, cz: int) -> WorldChunk | None:
result = await self.db.execute(
select(WorldChunk).where(
WorldChunk.room_id == room_id, WorldChunk.cx == cx,
WorldChunk.cy == cy, WorldChunk.cz == cz,
)
)
return result.scalar_one_or_none()
async def get_chunk_binary(self, key: str) -> bytes | None:
chunk = await self.get_chunk(key)
return chunk.payload if chunk is not None else None
async def get_chunk_flags(self, key: str) -> dict | None:
chunk = await self.get_chunk(key)
return chunk.content_flags if chunk is not None else None
async def list_chunks(
self,
world_id: int,
room_id: int | None = None,
bounds: Bounds | None = None,
) -> list[WorldChunk]:
"""Index query -- world.md §5.1's ``list_chunks``. Payload bytes are
deliberately not loaded (world_persistence_impl.md §8.1: "Payloads
are not included")."""
query = select(WorldChunk).options(
load_only(
WorldChunk.id, WorldChunk.world_id, WorldChunk.room_id, WorldChunk.chunk_key,
WorldChunk.cx, WorldChunk.cy, WorldChunk.cz, WorldChunk.revision,
WorldChunk.checksum, WorldChunk.payload_len, WorldChunk.is_mature,
WorldChunk.updated_at,
)
).where(WorldChunk.world_id == world_id)
if room_id is not None:
query = query.where(WorldChunk.room_id == room_id)
if bounds is not None:
if bounds.cx_min is not None:
query = query.where(WorldChunk.cx >= bounds.cx_min)
if bounds.cx_max is not None:
query = query.where(WorldChunk.cx <= bounds.cx_max)
if bounds.cy_min is not None:
query = query.where(WorldChunk.cy >= bounds.cy_min)
if bounds.cy_max is not None:
query = query.where(WorldChunk.cy <= bounds.cy_max)
if bounds.cz is not None:
query = query.where(WorldChunk.cz == bounds.cz)
query = query.order_by(WorldChunk.cx, WorldChunk.cy, WorldChunk.cz)
result = await self.db.execute(query)
return list(result.scalars().all())
# ── chunk writes ─────────────────────────────────────────────────────
async def put_chunk(
self,
*,
room: WorldRoom,
cx: int,
cy: int,
cz: int,
payload_bytes: bytes,
base_revision: int,
actor_id: int | None,
) -> WorldChunk:
"""Full-chunk write with optimistic concurrency (world.md §5.4,
world_persistence_impl.md §5.4/§8.2). Validates the payload
server-side (§5.5) before ever touching a row. ``base_revision=0``
means "create; 409 if it already exists"."""
key = chunk_codec.build_key(room.world_id, room.slug, cx, cy, cz)
grid_size = tuple(room.grid.get("size", [32, 32, 1]))
# Raises ChunkCodecError / ChunkValidationError -- the router maps
# those to 422. Nothing below trusts the request body's own claims
# about content_flags/is_mature; both come from `decoded`.
decoded = chunk_codec.decode_and_validate(
payload_bytes,
expected_key=key,
grid_size=grid_size,
max_objects=settings.WORLD_CHUNK_MAX_OBJECTS,
)
body_checksum = chunk_codec.checksum(payload_bytes[chunk_codec.HEADER_SIZE:])
result = await self.db.execute(
select(WorldChunk)
.where(
WorldChunk.room_id == room.id, WorldChunk.cx == cx,
WorldChunk.cy == cy, WorldChunk.cz == cz,
)
.with_for_update()
)
row = result.scalar_one_or_none()
if base_revision == 0:
if row is not None:
raise RevisionConflict(row.revision, row.checksum, (cx, cy, cz))
row = WorldChunk(
world_id=room.world_id, room_id=room.id, chunk_key=key,
cx=cx, cy=cy, cz=cz, revision=1,
)
self.db.add(row)
action = "create"
else:
if row is None or row.revision != base_revision:
raise RevisionConflict(
row.revision if row else 0, row.checksum if row else None, (cx, cy, cz)
)
row.revision = row.revision + 1
action = "update"
row.format_version = decoded.version
row.payload = payload_bytes
row.payload_len = len(payload_bytes)
row.checksum = body_checksum
row.content_flags = decoded.flags.to_dict()
row.is_mature = decoded.flags.mature
row.updated_by_id = actor_id
try:
await self.db.flush()
except IntegrityError:
# Lost a create race to a concurrent writer between our SELECT
# and this flush. Roll back and report the real current state.
await self.db.rollback()
existing = await self.get_chunk_by_coord(room.id, cx, cy, cz)
raise RevisionConflict(
existing.revision if existing else 0,
existing.checksum if existing else None,
(cx, cy, cz),
)
await self._record_revision(key, room.world_id, row.revision, action, actor_id, body_checksum, payload_bytes)
await self.db.refresh(row)
return row
async def put_chunks_batch(
self,
*,
room: WorldRoom,
items: list[dict],
actor_id: int | None,
) -> list[WorldChunk]:
"""Batch write (world_persistence_impl.md §8.1). One transaction,
all-or-nothing: none of this ``commit()``s, so a ``RevisionConflict``
raised partway leaves nothing persisted once the caller's request
handler lets the exception (or the HTTPException it becomes)
propagate through ``get_db``, which rolls back on any exception."""
rows: list[WorldChunk] = []
for item in items:
row = await self.put_chunk(
room=room, cx=item["cx"], cy=item["cy"], cz=item["cz"],
payload_bytes=item["payload_bytes"], base_revision=item["base_revision"],
actor_id=actor_id,
)
rows.append(row)
return rows
async def delete_chunk(self, key: str, actor_id: int | None) -> None:
row = await self.get_chunk(key)
if row is None:
return
await self._record_revision(key, row.world_id, row.revision, "delete", actor_id, row.checksum, None)
await self.db.delete(row)
await self.db.flush()
async def rollback_chunk(self, key: str, revision: int, actor_id: int | None) -> WorldChunk:
"""Restore a chunk to a previously-recorded revision's payload
(world.md §5.1). Raises ``RevisionNotAvailable`` if that revision
was never recorded, its payload was pruned by retention, or the
chunk has since been deleted outright (nothing to update in place)."""
rev_result = await self.db.execute(
select(WorldChunkRevision)
.where(WorldChunkRevision.chunk_key == key, WorldChunkRevision.revision == revision)
.order_by(WorldChunkRevision.id.desc())
.limit(1)
)
rev_row = rev_result.scalar_one_or_none()
if rev_row is None:
raise RevisionNotAvailable(f"no revision {revision} recorded for {key!r}")
if rev_row.payload is None:
raise RevisionNotAvailable(f"revision {revision} of {key!r} was pruned; payload unavailable")
chunk = await self.get_chunk(key)
if chunk is None:
raise RevisionNotAvailable(f"chunk {key!r} no longer exists; cannot roll back in place")
room_result = await self.db.execute(select(WorldRoom).where(WorldRoom.id == chunk.room_id))
room = room_result.scalar_one()
grid_size = tuple(room.grid.get("size", [32, 32, 1]))
decoded = chunk_codec.decode_and_validate(
rev_row.payload, expected_key=key, grid_size=grid_size,
max_objects=settings.WORLD_CHUNK_MAX_OBJECTS,
)
body_checksum = chunk_codec.checksum(rev_row.payload[chunk_codec.HEADER_SIZE:])
chunk.revision = chunk.revision + 1
chunk.payload = rev_row.payload
chunk.payload_len = len(rev_row.payload)
chunk.checksum = body_checksum
chunk.format_version = decoded.version
chunk.content_flags = decoded.flags.to_dict()
chunk.is_mature = decoded.flags.mature
chunk.updated_by_id = actor_id
await self.db.flush()
# Tagged "update" (not a fourth action value) to keep the
# documented 'create'|'update'|'delete' action enum intact.
await self._record_revision(
key, chunk.world_id, chunk.revision, "update", actor_id, body_checksum, rev_row.payload
)
await self.db.refresh(chunk)
return chunk
async def list_revisions(self, chunk_key: str, limit: int = 50) -> list[WorldChunkRevision]:
"""Not in world.md §5.1's interface by name, but required to
implement the revision-history endpoint world_persistence_impl.md
§8.1 lists (``GET .../revisions``)."""
result = await self.db.execute(
select(WorldChunkRevision)
.where(WorldChunkRevision.chunk_key == chunk_key)
.order_by(WorldChunkRevision.revision.desc(), WorldChunkRevision.id.desc())
.limit(limit)
)
return list(result.scalars().all())
async def migrate(self) -> None:
"""No-op. `world.md` §4.8's hand-rolled SQLite migration runner is
superseded by Alembic (`backend/alembic/`) now that content lives
in Postgres (world_persistence_impl.md §3.3). Kept only so this
class's shape still matches the §5.1 interface it's implementing
a subset of."""
return None
# ── internals ────────────────────────────────────────────────────────
async def _record_revision(
self,
chunk_key: str,
world_id: int,
revision: int,
action: str,
actor_id: int | None,
checksum_hex: str,
payload_bytes: bytes | None,
) -> None:
"""Append an audit row and enforce the retention policy
(world_persistence_impl.md §4.3, required): keep the newest
``WORLD_CHUNK_REVISION_KEEP`` payloads per ``chunk_key``, null out
``payload`` on older rows, in the same transaction as the write."""
rev = WorldChunkRevision(
chunk_key=chunk_key, world_id=world_id, revision=revision, action=action,
actor_id=actor_id, checksum=checksum_hex, payload=payload_bytes,
)
self.db.add(rev)
await self.db.flush()
keep = settings.WORLD_CHUNK_REVISION_KEEP
keep_ids_result = await self.db.execute(
select(WorldChunkRevision.id)
.where(WorldChunkRevision.chunk_key == chunk_key)
.order_by(WorldChunkRevision.id.desc())
.limit(keep)
)
keep_ids = [row[0] for row in keep_ids_result.all()]
if keep_ids:
await self.db.execute(
update(WorldChunkRevision)
.where(
WorldChunkRevision.chunk_key == chunk_key,
WorldChunkRevision.id.notin_(keep_ids),
WorldChunkRevision.payload.isnot(None),
)
.values(payload=None)
)
+14 -159
View File
@@ -1,183 +1,38 @@
"""Shared pytest fixtures + hermetic-DB bootstrap.
"""Shared pytest fixtures.
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.
The auth integration tests hit the *real* Postgres database (per
``DATABASE_URL``), so they persist data between runs. Without cleanup a
second run of the suite fails with 409 conflicts because the users it
registered on the first run already exist. The session-scoped fixture below
truncates all application tables once before the first test, making the suite
re-runnable.
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.
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
keys, but the tables are listed explicitly for clarity.
"""
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 sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import get_db
from app.main import app
TEST_TABLES = (
"avatar_loadouts", "assets",
# world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 /
# world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world
# must not lose the audit trail), so it won't be cascade-truncated via
# `worlds` the way world_rooms/world_chunks would be. List all three
# explicitly rather than rely on cascade for some of them.
"world_chunk_revisions", "world_chunks", "world_rooms",
"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)
TEST_TABLES = ("assets", "worlds", "sessions", "refresh_tokens", "users")
@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 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()
Binary file not shown.
Binary file not shown.
-105
View File
@@ -1,105 +0,0 @@
"""Generates the golden-file chunk fixtures in this directory.
These pin the world.md §3.6 binary format so the Python codec
(app/services/chunk_codec.py) and any future Godot-side test fixture can be
checked for drift (world_persistence_impl.md §5.5's "drift control"
requirement). No Godot-side golden fixture existed at the time this was
written (searched scripts/ and tests/ for AGPC / binary-format test
vectors -- none found), so these are authored here, from this
implementation, as the first golden files for the format. The client/editor
follow-on task should decode these same files with
`ChunkDataDecoder.decode_binary()` and assert the same logical values
(see the note in chunk_codec.py about a confirmed float32 handling bug in
that decoder that will need fixing for full byte parity on non-integer
transforms).
Run with: `python tests/fixtures/chunks/generate_fixtures.py` from `backend/`.
Fixtures are checked in; this script is for regenerating them if the format
or these examples change, not run as part of the test suite.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # backend/
from app.services import chunk_codec as cc
FIXTURES_DIR = Path(__file__).resolve().parent
def _empty_chunk() -> cc.ChunkPayload:
return cc.ChunkPayload(
world_id="7", room_id="main", coord=(0, 0, 0),
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
tile_layers=[], objects=[],
flags=cc.ContentFlags(),
created_at=1_700_000_000, updated_at=1_700_000_000, revision=1, author="fixture-gen",
)
def _simple_tile_chunk() -> cc.ChunkPayload:
layer = cc.TileLayer(
name="ground", layer_id=0, width=4, height=4, depth=1,
palette=[
cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1),
cc.TileDef(id=1, asset_id="tiles/grass", solid=False, flags=1, variants=4),
cc.TileDef(id=2, asset_id="tiles/stone", solid=True, flags=2, variants=2),
],
tiles=[0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0],
)
return cc.ChunkPayload(
world_id="12", room_id="main", coord=(3, -1, 0),
bounds=cc.ChunkBounds(min=(96.0, -32.0, 0.0), size=(32.0, 32.0, 1.0)),
tile_layers=[layer], objects=[],
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
created_at=1_700_000_000, updated_at=1_700_000_500, revision=2, author="fixture-gen",
)
def _chunk_with_objects() -> cc.ChunkPayload:
layer = cc.TileLayer(
name="ground", layer_id=0, width=2, height=2, depth=1,
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
tiles=[0, 0, 0, 0],
)
obj1 = cc.WorldObject(
id="obj_0001", type="interactable", asset_id="props/chest",
pos=(101.5, -110.25, 0.5), rot=(0.0, 0.0, 0.0, 1.0), scale=(1.0, 1.0, 1.0),
dynamic=False,
properties={"locked": False, "loot_table": "chest_basic"},
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
)
obj2 = cc.WorldObject(
id="obj_0002", type="spawn_point", asset_id="",
pos=(96.0, -128.0, 1.0), rot=(0.0, 0.7071, 0.0, 0.7071), scale=(1.0, 1.0, 1.0),
dynamic=True,
properties={},
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
)
return cc.ChunkPayload(
world_id="12", room_id="main", coord=(3, -4, 0),
bounds=cc.ChunkBounds(min=(96.0, -128.0, 0.0), size=(32.0, 32.0, 1.0)),
tile_layers=[layer], objects=[obj1, obj2],
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
created_at=1_700_000_000, updated_at=1_700_001_000, revision=5, author="agent-bot",
)
FIXTURES = {
"empty_chunk.bin": _empty_chunk,
"simple_tile_chunk.bin": _simple_tile_chunk,
"chunk_with_objects.bin": _chunk_with_objects,
}
def main() -> None:
for filename, builder in FIXTURES.items():
payload = builder()
data = cc.encode_binary(payload)
(FIXTURES_DIR / filename).write_bytes(data)
print(f"wrote {filename} ({len(data)} bytes)")
if __name__ == "__main__":
main()
Binary file not shown.
+66 -76
View File
@@ -3,102 +3,92 @@
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
from app.routers.auth import calculate_age
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
today = date(2026, 6, 15)
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 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)
"""A person who turned 18 yesterday is 18."""
today = date(2025, 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)
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 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_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_leap_day_birthday_after(self):
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class 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)
class TestRegisterRequestOver18Field:
"""Tests for RegisterRequest's over_18 field.
def test_dob_required(self):
with pytest.raises(Exception):
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
field_validator were removed from RegisterRequest 2026-08-20 (interim
simplification: a plain client-side over_18 confirmation instead of
collecting/validating an actual date of birth; see calculate_age's
own docstring in app/routers/auth.py for why that function is still
here even though this endpoint no longer calls it). There's no date
format left to validate, so the equivalent coverage is just: the
field exists, is a real bool, and is required."""
def test_over_18_true_accepted(self):
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
assert req.over_18 is True
def test_over_18_false_accepted(self):
"""False is a valid value at the schema level -- the endpoint,
not the schema, is what rejects it (see
TestRegister::test_register_rejects_not_over_18 in
test_auth_endpoints.py)."""
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
assert req.over_18 is False
def test_over_18_missing_rejected(self):
"""over_18 has no default -- omitting it entirely is a validation error."""
from app.routers.auth import RegisterRequest
from pydantic import ValidationError
with pytest.raises(ValidationError):
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
+24 -16
View File
@@ -73,7 +73,7 @@ class TestRegister:
"username": "newuser",
"email": "new@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
@@ -81,23 +81,26 @@ class TestRegister:
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
def test_register_rejects_not_over_18(self):
"""over_18=false gets a 400 with a detail message.
The interim design (2026-08-20) trusts a client-side boolean
confirmation instead of collecting/validating an actual date of
birth -- this only checks that the flag is respected, not real
age math (there's no date to verify against anymore). Real DOB
collection/verification may come back in a future pass."""
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,
"over_18": False,
},
)
assert resp.status_code == 403
assert "at least 18" in resp.json()["detail"]
assert resp.status_code == 400
assert "18 or older" in resp.json()["detail"]
def test_register_rejects_duplicate_username(self):
"""Registering the same username twice returns 409."""
@@ -109,7 +112,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup1@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
# Second registration with same username, different email
@@ -119,7 +122,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
assert resp.status_code == 409
@@ -134,7 +137,7 @@ class TestRegister:
"username": "user1",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
resp = client.post(
@@ -143,14 +146,19 @@ class TestRegister:
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
def test_register_missing_dob(self):
"""Missing the required date_of_birth field returns 422."""
def test_register_missing_over_18(self):
"""Missing the required over_18 field returns 422.
Replaces the old test_register_invalid_date_format -- there's no
date format to get wrong anymore (no date field at all), so the
closest equivalent "malformed request" case is the required
over_18 boolean being absent entirely."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
@@ -181,7 +189,7 @@ class TestLogin:
"username": username,
"email": email,
"password": password,
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
+1 -1
View File
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
-91
View File
@@ -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
-219
View File
@@ -1,219 +0,0 @@
"""Tests for app.services.chunk_codec -- the Python port of world.md §3.6.
Pure unit tests: no database, no app, no network. Golden fixtures live in
tests/fixtures/chunks/ (see generate_fixtures.py there for how/why).
"""
from pathlib import Path
import pytest
from app.services import chunk_codec as cc
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "chunks"
def _read_fixture(name: str) -> bytes:
return (FIXTURES_DIR / name).read_bytes()
class TestGoldenFixtures:
"""Byte-stability: decode a committed fixture, re-encode it, and the
bytes must match exactly (world.md §3.6.3)."""
@pytest.mark.parametrize("filename", [
"empty_chunk.bin", "simple_tile_chunk.bin", "chunk_with_objects.bin",
])
def test_round_trip_is_byte_identical(self, filename):
original = _read_fixture(filename)
decoded = cc.decode_binary(original)
re_encoded = cc.encode_binary(decoded)
assert re_encoded == original
def test_simple_tile_chunk_logical_values(self):
decoded = cc.decode_binary(_read_fixture("simple_tile_chunk.bin"))
assert decoded.world_id == "12"
assert decoded.room_id == "main"
assert decoded.coord == (3, -1, 0)
assert decoded.bounds.min == (96.0, -32.0, 0.0)
assert decoded.bounds.size == (32.0, 32.0, 1.0)
assert len(decoded.tile_layers) == 1
layer = decoded.tile_layers[0]
assert layer.name == "ground"
assert (layer.width, layer.height, layer.depth) == (4, 4, 1)
assert len(layer.palette) == 3
assert layer.tiles == [0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0]
assert decoded.revision == 2
assert decoded.author == "fixture-gen"
def test_chunk_with_objects_logical_values(self):
decoded = cc.decode_binary(_read_fixture("chunk_with_objects.bin"))
assert len(decoded.objects) == 2
obj1, obj2 = decoded.objects
assert obj1.id == "obj_0001"
assert obj1.type == "interactable"
assert obj1.pos == (101.5, -110.25, 0.5)
assert obj1.properties == {"locked": False, "loot_table": "chest_basic"}
assert obj2.id == "obj_0002"
assert obj2.dynamic is True
assert obj2.flags.violence == 1
assert obj2.flags.sources == ["asset:props/chest"]
assert decoded.flags.violence == 1
assert decoded.flags.sources == ["asset:props/chest"]
def test_empty_chunk_has_no_layers_or_objects(self):
decoded = cc.decode_binary(_read_fixture("empty_chunk.bin"))
assert decoded.tile_layers == []
assert decoded.objects == []
class TestChecksum:
def test_checksum_is_sha256_hex(self):
import hashlib
assert cc.checksum(b"") == hashlib.sha256(b"").hexdigest()
assert len(cc.checksum(b"")) == 64
def test_header_checksum_matches_recomputed_for_valid_fixture(self):
data = _read_fixture("simple_tile_chunk.bin")
header_checksum_hex = data[16:48].hex()
body = data[cc.HEADER_SIZE:]
assert header_checksum_hex == cc.checksum(body)
class TestBuildKey:
def test_matches_world_persistence_impl_4_5_format(self):
assert cc.build_key(12, "main", 3, -1, 0) == "12:main:3:-1:0"
assert cc.build_key("12", "main", 3, -1, 0) == "12:main:3:-1:0"
class TestFloat32Fidelity:
"""This is the concrete regression test for the cross-codec divergence
documented at the bottom of chunk_codec.py: this module must encode
real IEEE-754 float32, including fractional and negative values,
round-tripping exactly (up to float32 precision) -- unlike
scripts/chunk_data_decoder.gd's current int-truncating implementation."""
def test_fractional_and_negative_floats_survive_round_trip(self):
payload = cc.ChunkPayload(
world_id="1", room_id="main", coord=(0, 0, 0),
bounds=cc.ChunkBounds(min=(-17.25, 0.125, -3.0), size=(32.0, 32.0, 1.0)),
objects=[cc.WorldObject(
id="o1", type="prop", asset_id="a",
pos=(1.5, -2.75, 0.001), rot=(0.0, 0.7071068, 0.0, 0.7071068),
scale=(1.0, 1.0, 1.0),
)],
)
encoded = cc.encode_binary(payload)
decoded = cc.decode_binary(encoded)
assert decoded.bounds.min == pytest.approx((-17.25, 0.125, -3.0), abs=1e-6)
assert decoded.objects[0].pos == pytest.approx((1.5, -2.75, 0.001), abs=1e-6)
assert decoded.objects[0].rot == pytest.approx(
(0.0, 0.7071068, 0.0, 0.7071068), abs=1e-6
)
class TestMalformedInput:
def test_bad_magic_raises_codec_error(self):
data = bytearray(_read_fixture("empty_chunk.bin"))
data[0:4] = b"XXXX"
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(bytes(data))
def test_too_short_raises_codec_error(self):
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(b"AGPC\x01\x00")
def test_unsupported_version_raises_codec_error(self):
data = bytearray(_read_fixture("empty_chunk.bin"))
data[4:6] = (99).to_bytes(2, "little")
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(bytes(data))
def test_payload_len_mismatch_raises_codec_error(self):
data = bytearray(_read_fixture("empty_chunk.bin"))
# Corrupt the declared payload_len (offset 12..16).
data[12:16] = (99999).to_bytes(4, "little")
with pytest.raises(cc.ChunkCodecError):
cc.decode_binary(bytes(data))
class TestValidation:
"""decode_and_validate: the server-side "don't trust the client"
boundary (world_persistence_impl.md §5.5)."""
def _valid_args(self):
return {
"expected_key": "12:main:3:-1:0",
"grid_size": (4, 4, 1),
"max_objects": 512,
}
def test_valid_payload_passes(self):
data = _read_fixture("simple_tile_chunk.bin")
payload = cc.decode_and_validate(data, **self._valid_args())
assert payload.coord == (3, -1, 0)
def test_checksum_mismatch_detected(self):
data = bytearray(_read_fixture("simple_tile_chunk.bin"))
# Corrupt one byte of the header's advisory checksum field
# (offset 16..48) only -- magic/version/payload_len and the whole
# payload body stay untouched, so this exercises exactly the
# "recomputed checksum disagrees with the header" path rather than
# also breaking structural decoding.
data[16] ^= 0xFF
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(bytes(data), **self._valid_args())
assert exc_info.value.reason == "checksum_mismatch"
def test_chunk_key_mismatch_detected(self):
data = _read_fixture("simple_tile_chunk.bin")
args = self._valid_args()
args["expected_key"] = "12:main:99:99:0" # doesn't match the payload's own coord
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(data, **args)
assert exc_info.value.reason == "chunk_key_mismatch"
def test_layer_dims_mismatch_detected(self):
data = _read_fixture("simple_tile_chunk.bin")
args = self._valid_args()
args["grid_size"] = (8, 8, 1) # fixture's layer is 4x4x1
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(data, **args)
assert exc_info.value.reason == "layer_dims"
def test_too_many_objects_detected(self):
data = _read_fixture("chunk_with_objects.bin") # has 2 objects
args = self._valid_args()
args["expected_key"] = "12:main:3:-4:0"
args["grid_size"] = (2, 2, 1)
args["max_objects"] = 1
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(data, **args)
assert exc_info.value.reason == "too_many_objects"
def test_object_out_of_bounds_detected(self):
payload = cc.ChunkPayload(
world_id="1", room_id="main", coord=(0, 0, 0),
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
objects=[cc.WorldObject(id="o1", type="prop", asset_id="a", pos=(9999.0, 0.0, 0.0))],
)
data = cc.encode_binary(payload)
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(
data, expected_key="1:main:0:0:0", grid_size=(32, 32, 1), max_objects=10
)
assert exc_info.value.reason == "object_out_of_bounds"
def test_tile_out_of_palette_detected(self):
layer = cc.TileLayer(
name="ground", width=2, height=2, depth=1,
palette=[cc.TileDef(id=1)], # tile 5 below isn't in this palette
tiles=[1, 5, 0, 1],
)
payload = cc.ChunkPayload(world_id="1", room_id="main", coord=(0, 0, 0), tile_layers=[layer])
data = cc.encode_binary(payload)
with pytest.raises(cc.ChunkValidationError) as exc_info:
cc.decode_and_validate(
data, expected_key="1:main:0:0:0", grid_size=(2, 2, 1), max_objects=10
)
assert exc_info.value.reason == "tile_out_of_palette"
-153
View File
@@ -1,153 +0,0 @@
"""Unit tests for the game_servers.py changes made for world persistence
(world_persistence_impl.md §7.3/§7.4): per-spawn token validation, and the
``_await_ready`` replacement for the old, fail-open ``_await_port``.
No real Godot process is spawned here (that stays a manual/checkpoint
integration test, per test_world_join.py's docstring) -- these exercise
``GameServerSupervisor`` against a fake process object standing in for
``subprocess.Popen``.
"""
import json
import pytest
from app.config import settings
from app.services.game_servers import GameServerSupervisor, _WorldServer
class _FakeProc:
"""Stands in for subprocess.Popen: only .poll() is needed by the code
paths under test here."""
def __init__(self, exited: bool = False):
self._exited = exited
def poll(self):
return 0 if self._exited else None
def terminate(self):
self._exited = True
def wait(self, timeout=None):
return 0
def kill(self):
self._exited = True
def _make_srv(tmp_path, token="secret", alive=True) -> _WorldServer:
status_path = tmp_path / "status.json"
return _WorldServer(
world_id="7", port=27000, proc=_FakeProc(exited=not alive),
status_path=status_path, token=token,
)
class TestValidateToken:
def test_unknown_world_is_false(self):
sup = GameServerSupervisor()
assert sup.validate_token("no-such-world", "anything") is False
def test_correct_token_is_true(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
assert sup.validate_token("7", "secret") is True
# world_id is looked up as str(world_id), so an int works too --
# this matters because the FastAPI path param is an int.
assert sup.validate_token(7, "secret") is True
def test_wrong_token_is_false(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
assert sup.validate_token("7", "wrong") is False
def test_token_scoped_to_its_own_world_only(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
# Same token, different world_id -- must not authenticate (§7.3:
# "a path whose world_id differs from the token's world is 403").
assert sup.validate_token("8", "secret") is False
def test_dead_server_token_is_false(self, tmp_path):
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret", alive=False)
assert sup.validate_token("7", "secret") is False
def test_reaping_invalidates_the_token(self, tmp_path):
"""§7.3: "dies with its process" -- once removed from _servers
(what the reaper does), the same token no longer validates."""
sup = GameServerSupervisor()
sup._servers["7"] = _make_srv(tmp_path, token="secret")
assert sup.validate_token("7", "secret") is True
del sup._servers["7"]
assert sup.validate_token("7", "secret") is False
class TestAwaitReady:
"""Replaces the old _await_port, which (a) treated a bound UDP port as
readiness even though content loading happens before create_server, and
(b) failed open past its deadline with no error at all."""
async def test_raises_timeout_error_when_never_ready(self, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path) # status file never written
with pytest.raises(TimeoutError):
await sup._await_ready(srv)
async def test_returns_once_status_file_reports_ready(self, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 5.0)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path)
srv.status_path.write_text(json.dumps({"ready": True, "players": 0}))
await sup._await_ready(srv) # must not raise / must not hang
async def test_ready_false_is_not_ready(self, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path)
srv.status_path.write_text(json.dumps({"ready": False, "players": 0}))
with pytest.raises(TimeoutError):
await sup._await_ready(srv)
async def test_raises_promptly_if_process_already_exited(self, tmp_path, monkeypatch):
# A long deadline, but the process is already dead -- must not wait
# out the full deadline (this is the "no crash restart mid-wait"
# case: don't silently poll a corpse for 20s).
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 20.0)
sup = GameServerSupervisor()
srv = _make_srv(tmp_path, alive=False)
with pytest.raises(TimeoutError):
await sup._await_ready(srv)
class TestSpawnCommandLine:
def test_spawn_includes_api_url_and_server_token(self, monkeypatch, tmp_path):
"""world_persistence_impl.md §7.2 step 1 / §7.4: `_spawn` must pass
--api-url and --server-token on the command line."""
sup = GameServerSupervisor()
monkeypatch.setattr(sup, "_free_port", lambda: 27050)
captured = {}
class _Proc:
def poll(self):
return None
def _fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
return _Proc()
monkeypatch.setattr(
"app.services.game_servers.subprocess.Popen", _fake_popen
)
srv = sup._spawn("42")
cmd = captured["cmd"]
assert "--api-url" in cmd
assert cmd[cmd.index("--api-url") + 1] == settings.GAME_SERVER_INTERNAL_API_URL
assert "--server-token" in cmd
token_in_cmd = cmd[cmd.index("--server-token") + 1]
assert token_in_cmd == srv.token
assert len(srv.token) > 20 # secrets.token_urlsafe(32) -- not a short/guessable value
-56
View File
@@ -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
+2 -2
View File
@@ -59,7 +59,7 @@ class TestRefresh:
"username": username,
"email": email,
"password": password,
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
@@ -143,7 +143,7 @@ class TestRefresh:
"username": "refep_loginrefuser",
"email": "loginref@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
"over_18": True,
},
)
# Login to get a refresh token
-490
View File
@@ -1,490 +0,0 @@
"""Integration tests for the world content API: rooms + chunks.
Covers docs/architecture/world_persistence_impl.md §8.1 (public router,
app/routers/world_content.py) and §8.3 (internal router,
app/routers/internal.py), plus the create_world -> auto "main" room
behaviour (§4.1).
Runs against the real ephemeral Postgres the hermetic conftest.py stands
up -- these are full HTTP round trips through TestClient, not mocked.
"""
import base64
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services import chunk_codec as cc
from app.services.game_servers import supervisor
GRID_32 = (32, 32, 1)
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"]
def _build_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, *,
grid=GRID_32, revision: int = 1, extra_tag: str = "") -> bytes:
"""A small but structurally-real chunk payload for `world_id`/`slug`/coord,
sized to match the room's default 32x32x1 grid."""
w, h, d = grid
layer = cc.TileLayer(
name="ground", layer_id=0, width=w, height=h, depth=d,
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
tiles=[0] * (w * h * d),
)
obj = cc.WorldObject(
id=f"obj_{extra_tag or '1'}", type="prop", asset_id="props/rock",
pos=(1.0, 1.0, 0.0), properties={"tag": extra_tag},
)
payload = cc.ChunkPayload(
world_id=str(world_id), room_id=slug, coord=(cx, cy, cz),
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(float(w), float(h), float(d))),
tile_layers=[layer], objects=[obj], revision=revision, author="pytest",
created_at=1_700_000_000, updated_at=1_700_000_000,
)
return cc.encode_binary(payload)
class TestCreateWorldMainRoom:
def test_create_world_auto_creates_main_room(self):
client = TestClient(app)
headers, _ = _auth(client, "cw_main")
wid = _make_world(client, headers)
r = client.get(f"/api/worlds/{wid}/rooms", headers=headers)
assert r.status_code == 200, r.text
rooms = r.json()
assert len(rooms) == 1
assert rooms[0]["slug"] == "main"
assert rooms[0]["name"] == "Main"
assert rooms[0]["chunk_count"] == 0
assert rooms[0]["grid"]["size"] == [32, 32, 1]
class TestRoomAuth:
def test_create_room_requires_owner(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "room_owner")
wid = _make_world(client, owner_headers)
other_headers, _ = _auth(client, "room_other")
r = client.post(f"/api/worlds/{wid}/rooms", headers=other_headers,
json={"slug": "annex", "name": "Annex"})
assert r.status_code == 403
def test_list_rooms_of_private_world_is_404_for_non_owner(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "room_priv_owner")
wid = _make_world(client, owner_headers, "private")
other_headers, _ = _auth(client, "room_priv_other")
r = client.get(f"/api/worlds/{wid}/rooms", headers=other_headers)
assert r.status_code == 404
class TestChunkPutGetRoundTrip:
def test_binary_put_then_binary_get_round_trips(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_rt")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 0, 0, 0)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["revision"] == 1
assert body["payload_len"] == len(payload)
assert body["checksum"] == cc.checksum(payload[cc.HEADER_SIZE:])
assert body["live_session"] is False
g = client.get(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Accept": "application/octet-stream"},
)
assert g.status_code == 200
assert g.content == payload
assert g.headers["ETag"] == f'"{body["checksum"]}"'
assert g.headers["X-Chunk-Revision"] == "1"
def test_json_accept_returns_decoded_shape(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_json")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 1, 1, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
g = client.get(
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
headers={**headers, "Accept": "application/json"},
)
assert g.status_code == 200
data = g.json()
assert data["coord"] == {"cx": 1, "cy": 1, "cz": 0}
assert data["world_id"] == str(wid)
assert data["room_id"] == "main"
assert len(data["objects"]) == 1
def test_json_put_with_base64_payload(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_json_put")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 2, 2, 0)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/2/2/0", headers=headers,
json={"base_revision": 0, "payload_b64": base64.b64encode(payload).decode()},
)
assert r.status_code == 200, r.text
assert r.json()["revision"] == 1
def test_if_none_match_returns_304(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_304")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 3, 3, 0)
put = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
checksum = put.json()["checksum"]
g = client.get(
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
headers={**headers, "If-None-Match": f'"{checksum}"'},
)
assert g.status_code == 304
def test_unwritten_coordinate_is_404(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_404")
wid = _make_world(client, headers)
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/9/9/0", headers=headers)
assert g.status_code == 404
class TestRevisionConflict:
def test_create_conflict_when_chunk_already_exists(self):
client = TestClient(app)
headers, _ = _auth(client, "conflict_create")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 4, 4, 0)
first = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert first.status_code == 200
second = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert second.status_code == 409
body = second.json()
assert body["detail"] == "revision conflict"
assert body["current_revision"] == 1
def test_stale_base_revision_conflict_reports_current_state(self):
client = TestClient(app)
headers, _ = _auth(client, "conflict_stale")
wid = _make_world(client, headers)
payload_v1 = _build_chunk(wid, "main", 5, 5, 0, revision=1)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload_v1,
)
payload_v2 = _build_chunk(wid, "main", 5, 5, 0, revision=2, extra_tag="v2")
r2 = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
content=payload_v2,
)
assert r2.status_code == 200
assert r2.json()["revision"] == 2
# Now retry with the now-stale base_revision=1 again.
payload_v3 = _build_chunk(wid, "main", 5, 5, 0, revision=3, extra_tag="v3")
stale = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
content=payload_v3,
)
assert stale.status_code == 409
body = stale.json()
assert body["current_revision"] == 2
assert body["current_checksum"] == r2.json()["checksum"]
class TestBatchWrite:
def test_batch_put_all_succeed(self):
client = TestClient(app)
headers, _ = _auth(client, "batch_ok")
wid = _make_world(client, headers)
chunks = [
{"cx": cx, "cy": 0, "cz": 0, "base_revision": 0,
"payload_b64": base64.b64encode(_build_chunk(wid, "main", cx, 0, 0)).decode()}
for cx in (10, 11)
]
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
assert r.status_code == 200, r.text
body = r.json()
assert len(body["chunks"]) == 2
assert all(c["revision"] == 1 for c in body["chunks"])
idx = client.get(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers)
keys = {c["key"] for c in idx.json()["chunks"]}
assert f"{wid}:main:10:0:0" in keys
assert f"{wid}:main:11:0:0" in keys
def test_batch_put_is_all_or_nothing_on_conflict(self):
client = TestClient(app)
headers, _ = _auth(client, "batch_conflict")
wid = _make_world(client, headers)
# Pre-create one of the two coordinates so the batch's base_revision=0
# for it will conflict.
pre = _build_chunk(wid, "main", 20, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/20/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=pre,
)
chunks = [
{"cx": 21, "cy": 0, "cz": 0, "base_revision": 0,
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 21, 0, 0)).decode()},
{"cx": 20, "cy": 0, "cz": 0, "base_revision": 0, # conflicts: already exists at revision 1
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 20, 0, 0, extra_tag="v2")).decode()},
]
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
assert r.status_code == 409
# Nothing from this batch should have been persisted -- (21,0,0)
# must NOT exist even though it was valid and came first.
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/21/0/0", headers=headers)
assert g.status_code == 404
class TestDelete:
def test_delete_then_get_is_404(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_delete")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 30, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/30/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
d = client.delete(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
assert d.status_code == 204
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
assert g.status_code == 404
class TestRevisionsAndRollback:
def test_revisions_then_rollback(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_rollback")
wid = _make_world(client, headers)
v1 = _build_chunk(wid, "main", 40, 0, 0, revision=1)
r1 = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=v1,
)
v2 = _build_chunk(wid, "main", 40, 0, 0, revision=2, extra_tag="v2")
r2 = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
content=v2,
)
assert r2.status_code == 200
revs = client.get(f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/revisions", headers=headers)
assert revs.status_code == 200
rev_numbers = [row["revision"] for row in revs.json()]
assert 1 in rev_numbers and 2 in rev_numbers
rb = client.post(
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/rollback",
headers=headers, json={"revision": 1},
)
assert rb.status_code == 200, rb.text
assert rb.json()["revision"] == 3 # a new revision, restoring v1's content
assert rb.json()["checksum"] == r1.json()["checksum"]
def test_revisions_requires_owner(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "revisions_owner")
wid = _make_world(client, owner_headers)
payload = _build_chunk(wid, "main", 41, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/41/0/0",
headers={**owner_headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
other_headers, _ = _auth(client, "revisions_other")
r = client.get(f"/api/worlds/{wid}/rooms/main/chunks/41/0/0/revisions", headers=other_headers)
assert r.status_code == 403
class TestValidationErrors:
def test_malformed_payload_is_422(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_malformed")
wid = _make_world(client, headers)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=b"not a real chunk",
)
assert r.status_code == 422
assert r.json()["reason"] == "malformed"
def test_wrong_grid_size_is_422_layer_dims(self):
client = TestClient(app)
headers, _ = _auth(client, "chunk_layerdims")
wid = _make_world(client, headers)
# Room's default grid is 32x32x1; build a chunk sized for 4x4x1.
payload = _build_chunk(wid, "main", 0, 0, 0, grid=(4, 4, 1))
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert r.status_code == 422
assert r.json()["reason"] == "layer_dims"
def test_oversized_payload_is_413(self, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "WORLD_CHUNK_MAX_PAYLOAD_BYTES", 10)
client = TestClient(app)
headers, _ = _auth(client, "chunk_413")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 0, 0, 0)
r = client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
assert r.status_code == 413
class TestInternalApi:
"""docs/architecture/world_persistence_impl.md §7/§8.3. No real Godot
process is spawned in this test env (GAME_SERVERS_ENABLED=False); we
register a fake in-memory server entry with a known token to exercise
require_game_server without needing a live process."""
@pytest.fixture
def fake_server_token(self):
# NB: a class body can't read a same-named local from the
# enclosing function via `token = token` (class bodies use LOAD_NAME,
# which skips enclosing function scopes) -- use a literal instead.
world_id_str = "internal-test-world"
class _FakeSrv:
def alive(self_inner):
return True
token = "test-internal-token-12345"
supervisor._servers[world_id_str] = _FakeSrv()
yield _FakeSrv.token
supervisor._servers.pop(world_id_str, None)
def test_missing_token_is_401(self):
client = TestClient(app)
r = client.get("/api/internal/worlds/1/manifest")
assert r.status_code == 401
def test_wrong_token_is_403(self):
client = TestClient(app)
r = client.get(
"/api/internal/worlds/1/manifest",
headers={"Authorization": "Bearer not-the-real-token"},
)
assert r.status_code == 403
def test_valid_token_but_wrong_world_id_is_403(self, fake_server_token):
# The fake token is registered under world_id "internal-test-world";
# a request for a different world_id must not authenticate.
client = TestClient(app)
r = client.get(
"/api/internal/worlds/999999/manifest",
headers={"Authorization": f"Bearer {fake_server_token}"},
)
assert r.status_code == 403
def test_valid_token_reaches_manifest_for_real_world(self):
client = TestClient(app)
headers, _ = _auth(client, "internal_manifest")
wid = _make_world(client, headers)
class _FakeSrv:
def alive(self_inner):
return True
token = "another-real-token"
supervisor._servers[str(wid)] = _FakeSrv()
try:
r = client.get(
f"/api/internal/worlds/{wid}/manifest",
headers={"Authorization": "Bearer another-real-token"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["world_id"] == wid
assert len(body["rooms"]) == 1
assert body["rooms"][0]["slug"] == "main"
finally:
supervisor._servers.pop(str(wid), None)
def test_internal_chunk_fetch_returns_binary(self):
client = TestClient(app)
headers, _ = _auth(client, "internal_chunk")
wid = _make_world(client, headers)
payload = _build_chunk(wid, "main", 0, 0, 0)
client.put(
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
content=payload,
)
class _FakeSrv:
def alive(self_inner):
return True
token = "internal-chunk-token"
supervisor._servers[str(wid)] = _FakeSrv()
try:
r = client.get(
f"/api/internal/worlds/{wid}/rooms/main/chunks/0/0/0",
headers={"Authorization": "Bearer internal-chunk-token"},
)
assert r.status_code == 200
assert r.content == payload
finally:
supervisor._servers.pop(str(wid), None)
-54
View File
@@ -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"]
-31
View File
@@ -449,37 +449,6 @@ The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
---
## 9a. Loadout Persistence (implemented, PC3 Phase 1)
**Storage.** One row per user in `avatar_loadouts` (`user_id` PK/FK,
`data` JSONB, timestamps). The entire loadout is stored as a single JSON
document in the exact shape the client's `AvatarLoadout.to_json()` emits
(`{"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}`) rather than
exploded into columns.
**Why one JSON blob, not columns.** The server has no copy of the asset
catalog (`assets/avatars/catalog.json` ships with the client). It therefore
cannot meaningfully validate *which* asset belongs in *which* slot, whether
an asset exists, or slot-exclusivity rules — that validation stays on the
client (`AvatarLoadout.validate()` against the loaded `AvatarCatalog`). The
server enforces only the envelope: a JSON object with a non-empty string
`base_body`, an object `slots`, and a sane size cap. Keeping the schema
opaque to the server also means catalog/loadout-shape changes don't need a
migration.
**Endpoints** (`/api/avatar`, bearer auth):
| Method | Path | Behaviour |
|---|---|---|
| `GET` | `/loadout` | The user's saved loadout, or the server default (`{"base_body": "base_human_male_01", "slots": {}}`, `is_default: true`) if they have never saved one. |
| `PUT` | `/loadout` | Replace the user's loadout (full document, not a patch). |
**Defaults.** A brand-new account has no row; `GET` returns
`models.avatar_loadout.DEFAULT_LOADOUT` with `is_default: true` so the client
can distinguish "never customised" from "customised back to the base look".
---
## 10. Summary
| Concern | Decision |
+3 -18
View File
@@ -227,33 +227,18 @@ An account has an **age verification status**:
### 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)
Server validates format & plausibility (age 13–120, 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 >= 18 ──► status = self_declared (adult)
age >= 13 ──► status = self_declared (minor, restricted)
age < 13 ──► status = rejected (below minimum platform age)
```
-5
View File
@@ -4,11 +4,6 @@
> **Owner:** Platform team
> **Related:** World chunk data format & persistence, server authority + client
> prediction, content moderation & age verification, avatar component system
> **Note:** the recommendation here stands, but edits do **not** ride the
> `request_chunk_edit` ENet RPC (§4.2, §7.4). Per
> [`world_persistence_impl.md`](world_persistence_impl.md) the editor saves
> chunks over HTTP to the FastAPI backend, which is the sole writer; the
> validation/moderation boundary moves with it.
> **Godot version:** 4.6.2 stable
## 1. Overview
-56
View File
@@ -237,62 +237,6 @@ snapshot_state:
---
## 7a. Server Orchestration (implemented, PC3 Phase 2)
**One dedicated server process per world**, spawned on demand.
```
client backend (single worker) host
│ POST /api/worlds/{id}/join (bearer JWT) │
│ ───────────────────────────▶ GameServerSupervisor │
│ ensure_server(world_id): │
│ if none running for world ─── spawn:
│ godot --headless
│ res://scenes/server.tscn --
│ --port <p> --world <id>
│ --status-file <path>
│ ◀─────────────────────────── { host, port, world_id } │
│ │
│ ENet connect host:port ───────────────────────────────────▶ dedicated server
```
- **Supervisor** (`backend/app/services/game_servers.py`): in-process singleton,
so the backend MUST run single-worker. Keys servers by `world_id`. Allocates
ports from `GAME_SERVER_PORT_MIN..MAX`. A background reaper kills a world's
server once its **status file** (`{players, tick, updated}`, rewritten by the
server ~1×/s) reports `players == 0` for `GAME_SERVER_IDLE_TIMEOUT_SECONDS`.
No crash-restart — the next join respawns.
- **Enable flag:** `GAME_SERVERS_ENABLED` (default off). The checkpoint/playtest
launcher (`pm/checkpoint_ops.py`) turns it on, passes `GODOT_BIN`,
`GAME_PROJECT_DIR`, and `GAME_SERVER_HOST` (the LAN IP baked into the `.exe`).
A backend with it off returns **503** from `/join`, and the client falls back
to local single-player.
- **Per-world content:** all worlds currently load the same test level (M2/M7
work). `world_id` still scopes a distinct process + session.
### 7b. Client play path
`world_list.gd` → `POST /join` → `Session.begin(world_id, host, port)` →
load `game.tscn`. `game.gd` sees `Session.active` and connects a
`NetworkClient`:
- **Local player** keeps predicting with its own collision-aware
`CharacterBody3D` (`player.gd`) — that *is* the prediction. Its input frame
(`player.net_input`) is forwarded to the server every physics tick.
- **Correction backstop:** if the local position drifts from the server's by
more than `CORRECTION_THRESHOLD` (1.5 m — generous; catches teleport / a wall
walked through / real desync, not prediction noise) it snaps.
- **Remote players:** rendered from snapshots, position lerped. *Appearance sync
is a follow-up* — remotes currently show as labelled capsules; the server
does not yet replicate avatar loadouts.
- **Disconnect** → status message → back to the world list.
The `NetworkClient`'s own flat-plane prediction + input-replay reconciliation
(§5) is exercised by `smoke_test_network_client.gd`; the collision-aware game
path above uses the lighter `send_input()` + drift-snap instead.
---
## 8. Security & Anti-Cheat
- **No client-authoritative gameplay values.** Clients only send *inputs* and
+1 -5
View File
@@ -1,10 +1,6 @@
# World Chunk Data Format & Persistence Architecture
> **Status:** Proposed. The chunk format (§2, §3) is normative and implemented
> on the client. **§4 (SQLite schema) and §5 (write ownership / write-behind)
> are superseded by [`world_persistence_impl.md`](world_persistence_impl.md)**,
> which stores chunks in the existing Postgres backend and makes the FastAPI
> process — not the per-world Godot server — the sole writer.
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
-655
View File
@@ -1,655 +0,0 @@
# World Persistence: Server-Side Implementation Architecture
> **Status:** Accepted (design). Implementation is split across two follow-on
> tasks (backend endpoints; editor wiring).
> **Owner:** Platform team
> **Supersedes:** the *storage engine* and *write ownership* parts of
> [`world.md`](world.md) §4 and §5. Everything else in `world.md` — the chunk
> logical model, the JSON and binary encodings, checksums, revisions, content
> flags — stands unchanged and is normative.
> **Related:** [`world.md`](world.md) (chunk format), [`editor.md`](editor.md)
> (authoring surface), [`networking.md`](networking.md) (ENet sim transport),
> [`content_moderation.md`](content_moderation.md) (flags & gating).
## 1. Why this document exists
`world.md` was written during the client-side chunk-streaming work (M2). It
assumes **one** server that owns **one** embedded SQLite file and is both the
authoritative simulation and the authoritative store. That is not the system we
built. What actually exists today is:
| Process | Lifetime | Owns | Code |
|---|---|---|---|
| FastAPI backend | always on, single worker | Postgres (users, worlds, assets, avatar loadouts), game-server orchestration | `backend/app/` |
| Godot dedicated server, one per world | **spawned on join, reaped ~5 min after the last join when empty** | nothing durable; a 30 Hz in-memory player sim | `scripts/server.gd`, spawned by `backend/app/services/game_servers.py` |
| Godot client | player session | local chunk streaming, the in-game editor (planned) | `scripts/chunk_stream_manager.gd`, `scripts/chunk_data_decoder.gd` |
Nothing server-side has ever stored or served a real chunk. `server.gd`
`preload()`s `res://scenes/worlds/test_level.tscn` for **every** world and uses
`world_id` only for logging and the status file. `ChunkStreamManager` has a
`set_server_rpc()` seam that no code calls. The backend's `World` model is
metadata only (`id`, `name`, `description`, `owner_id`, `visibility`).
This document decides where chunk content lives, who may write it, how the
dedicated server gets it, and the exact API surface — so the two implementation
tasks that follow have no architectural decisions left to make.
---
## 2. Decisions at a glance
| # | Question | Decision |
|---|---|---|
| D1 | Where does chunk data live at rest? | **Postgres**, in the existing backend database. Not a per-world SQLite file. |
| D2 | Who writes it? | **The FastAPI backend, exclusively.** The editor writes through it over HTTP with optimistic concurrency. The dedicated Godot server never writes world content. |
| D3 | How does the dedicated server get content? | **HTTP pull from the backend at startup**, over a loopback-only `/api/internal` route authenticated by a per-spawn token the supervisor mints. Lazy fetch as players roam. |
| D4 | Edit while a live session exists? | **Out of scope this milestone, with a defined seam.** The editor is a non-joined client mode; writes always succeed and the response flags `live_session` so the UI can say "players see this when the session restarts". |
| D5 | Edit granularity on the wire | **Full-chunk PUT with `base_revision`**, not RFC-6902 deltas. |
| D6 | Client chunk transport | **HTTP from the backend**, not ENet from the dedicated server. The `set_server_rpc()` seam is satisfied by an HTTP-backed adapter. |
---
## 3. D1 — Chunk data lives in Postgres
### 3.1 Decision
World content (rooms, chunk payloads, chunk revisions) is stored in the
**existing Postgres database**, alongside `worlds`, in new tables created by an
Alembic migration. `world.md` §4's `world.db` SQLite file is **not** built.
The chunk **payload stays exactly what `world.md` §3.6 specifies**: the 48-byte
header + sectioned binary body, checksummed with SHA-256. It moves from a
SQLite `BLOB` to a Postgres `BYTEA`. Nothing about the format changes, so
`scripts/chunk_data_decoder.gd` remains correct and the format spec stays the
single contract.
### 3.2 Reasoning
1. **SQLite's premise does not hold here.** `world.md` §4 justifies SQLite as
"ideal for a server that owns the world store". No process in this system
owns a world store: the only process that could plausibly hold the file (the
dedicated Godot server) is *ephemeral* — reaped when idle, `SIGTERM`'d on
backend shutdown, `SIGKILL`'d 10 s later if it doesn't exit, and orphaned
entirely if the backend dies. Meanwhile the editor must write when no such
process exists. So the file would have to be shared by two processes with
independent lifetimes, which is precisely the workload SQLite's
single-writer/file-lock model handles worst.
2. **Postgres is already a hard dependency; SQLite would be a second one.** The
backend cannot start without Postgres. Adding a per-world SQLite file adds a
second storage engine, a second migration system (`world.md` §4.8 specifies a
hand-rolled `meta.schema_version` runner *next to* the Alembic we already
have), a second backup story, and a second consistency story.
3. **Referential integrity with metadata we already have.** `worlds` rows carry
`owner_id` and `visibility`, and every authorization rule for content derives
from them. In one database this is a join and a foreign key with
`ON DELETE CASCADE`; across two engines, deleting a world silently orphans a
file on disk and every ownership check becomes a cross-store read.
4. **Concurrency is the normal case, not the edge case.** Two creators editing
one world, or an editor writing while the backend serves chunks to a running
dedicated server, are ordinary. Postgres gives row-level MVCC and real
transactions for the read-modify-write in §5.4; SQLite gives a database-level
write lock and `SQLITE_BUSY`.
5. **The repo already has the precedent and the plumbing.** `avatar_loadouts`
(migration `0004`) stores a client-shaped document in Postgres JSONB rather
than inventing a side store, and `backend/app/database.py` already provides
the async session factory this code will use.
### 3.3 What we give up, and why it's acceptable
- **Zero-config embedding.** Irrelevant — the backend already requires a
configured Postgres.
- **`world.md` §5.5 write-behind + the `dirty` column.** Deliberately dropped
(§4.4). It existed to amortize disk I/O for a process that owned the file. Our
writes are human-rate editor saves, not sim-rate; each one is a single
transaction and is durable when the HTTP response returns. Dropping it also
deletes the entire "unclean shutdown recovery" scan in §5.5 — there is no
window in which committed content can be lost.
- **Sharding by `world_id` (`world.md` §8).** Postgres partitioning by
`world_id` remains available on the same table shape if a single table ever
becomes a bottleneck. At ~2–8 KB per chunk, 10 000 chunks is under 100 MB.
---
## 4. Schema (Alembic migration `0005_add_world_content_tables`)
New model module: `backend/app/models/world_content.py`, registered in
`backend/app/models/__init__.py`. `backend/app/models/world.py` is left alone
except for one added relationship, so nothing PC1/PC2 depends on moves.
Conventions follow the existing models: `Column(...)` declarative style,
`Integer` surrogate PKs, `DateTime(timezone=True)` with `server_default=func.now()`,
JSONB for document-shaped fields (as `avatar_loadout.py` does).
### 4.1 `world_rooms`
```python
class WorldRoom(Base):
__tablename__ = "world_rooms"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True)
slug = Column(String(64), nullable=False) # url-safe; "main" by default
name = Column(String(255), nullable=False)
# Chunk grid config. world.md §2.2 puts this on the world; it lives on the
# room so a world can later hold rooms with different grids, and so this
# migration is purely additive (the existing `worlds` table is untouched).
grid = Column(JSONB, nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}')
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
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)
__table_args__ = (UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),)
```
`World` gains
`rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")`.
Every world gets a `main` room: created in `create_world`, and lazily created on
first room read/write for worlds that predate the migration. `grid` defaults
match `Chunk.CHUNK_SIZE_*` in `scripts/chunk.gd`, which is the client's source
of truth for the same constants.
### 4.2 `world_chunks`
```python
class WorldChunk(Base):
__tablename__ = "world_chunks"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True) # denormalized: world-scoped queries
room_id = Column(Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=False, index=True)
chunk_key = Column(String(160), nullable=False, unique=True) # see §4.5
cx = Column(Integer, nullable=False)
cy = Column(Integer, nullable=False)
cz = Column(Integer, nullable=False, server_default="0")
format_version = Column(Integer, nullable=False, server_default="1")
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes, BYTEA
payload_len = Column(Integer, nullable=False)
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
revision = Column(Integer, nullable=False, server_default="1")
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
is_mature = Column(Boolean, nullable=False, server_default="false")
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
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)
__table_args__ = (
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
)
```
Deltas from `world.md` §4.4:
- **Surrogate `id` PK + unique coordinate constraint**, with `chunk_key` kept as
a unique denormalized column. The key string is the client-facing contract
(`Chunk.build_key`) and is embedded in the payload, so storing it gives a
single-index lookup by key; the coordinate constraint is what actually
enforces one chunk per cell.
- **`dirty` removed** — see §3.3.
- **Content flags folded into the chunk row as JSONB + an indexed `is_mature`
boolean**, instead of `world.md` §4.6's separate 11-column `content_flags`
table. The flags are a document the client already produces in exactly this
shape (`ChunkDataDecoder.ContentFlags.to_dict()`), the only query we anticipate
is "is this mature", and folding them in removes a second write per save and
the `object_id = ''` sentinel hack that §4.6 needed to work around SQLite's
NULL-in-primary-key behaviour (Postgres has the same NULL semantics, so the
hack would have carried over).
- **`world.md` §4.5 `chunk_objects` is deferred, not dropped.** It is a derived
index that exists to answer queries ("which chunks contain a door?") that no
code asks yet, and maintaining it means a delete-and-reinsert of every object
row on every save. It is fully rebuildable from `payload`, so it can be added
in its own migration the day a query needs it, with a backfill. Do **not**
build it in `0005`.
### 4.3 `world_chunk_revisions`
```python
class WorldChunkRevision(Base):
__tablename__ = "world_chunk_revisions"
id = Column(Integer, primary_key=True, index=True)
chunk_key = Column(String(160), nullable=False, index=True) # deliberately NOT a FK
world_id = Column(Integer, nullable=False, index=True) # also not a FK, same reason
revision = Column(Integer, nullable=False)
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
checksum = Column(String(64), nullable=False)
payload = Column(LargeBinary, nullable=True) # full payload at this revision
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
```
Kept from `world.md` §4.7 including its two design points: the full payload is
stored (rollback needs it) and `chunk_key`/`world_id` are **not** foreign keys,
so deleting a chunk or a world preserves the audit trail.
**Retention (new, required):** this table grows without bound — a creator
dragging a brush can produce hundreds of revisions per minute. The
implementation must ship a retention policy from day one: keep the newest
`WORLD_CHUNK_REVISION_KEEP` (default 20) payloads per `chunk_key` and null out
`payload` on older rows (keeping the metadata row for the audit trail), pruned
in the same transaction as the write. A `created_at` index supports a later
time-based prune.
### 4.4 Config additions (`backend/app/config.py`)
```
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB; a dense 32×32 uint16 layer is 2 KiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
```
### 4.5 Chunk key format
`world.md` §2.3 specifies `"{world_id}:{room_id}:{cx}:{cy}:{cz}"` and illustrates
it with `w_0001:r_0001`. Those ids were illustrative. The real key uses the
backend's identifiers:
```
"{world.id}:{room.slug}:{cx}:{cy}:{cz}" e.g. "12:main:3:-1:0"
```
The room **slug** rather than its numeric id, so a key is human-readable in logs
and stable if rooms are ever re-imported. `Chunk.build_key()` in
`scripts/chunk.gd` already produces this shape unmodified.
---
## 5. D2 — The FastAPI backend is the only writer
### 5.1 Decision
The in-game editor (`editor.md` §7.2) submits edits over **HTTP to the FastAPI
backend**, which validates, versions, and persists them. The dedicated Godot
server process **never writes world content** and does not sit in the edit path.
This preserves `world.md`'s and `editor.md`'s actual invariant — *clients never
touch storage; they submit edits to a server-authoritative tier that validates
them* — while identifying that tier as the always-on backend rather than the
ephemeral sim process. `editor.md` §4.2's claim that edits ride the same
`request_chunk_edit` RPC as gameplay is the one part of that doc this design
changes; the moderation and validation properties it derives from that claim are
unaffected, because the validation boundary moves with the writer.
### 5.2 Reasoning
1. **Nothing that can vanish in 300 seconds may own the write path.** The reaper
in `game_servers.py` `stop()`s an idle server with `SIGTERM` and escalates to
`SIGKILL` after 10 s; `supervisor.shutdown()` does the same to every server on
backend shutdown; and if the backend dies uncleanly the servers are orphaned
with no one to flush them. Because the sim process holds no unsaved world
content in this design, **no reap, crash, or kill can ever lose world data** —
that is the single strongest practical reason for this split.
2. **Editing must work with no dedicated server running.** A creator opening
their world to build should not have to spin up a 30 Hz sim process, burn a
port from the 100-port range, and hold it for 5 minutes past their last save.
3. **`server.gd` has no database access, no credentials, and no HTTP client.**
Routing writes through it means either shipping Postgres credentials into a
process spawned per join, or building an ENet→HTTP proxy inside the sim loop.
Both are strictly worse than the editor calling the backend directly.
4. **Authorization already lives in the backend.** `CurrentUser`, the
`owner_id` check, and `WorldVisibility` are all there. The dedicated server
has no user identities at all — it knows peer ids, not accounts.
### 5.3 Authorization
- **Read** a chunk: the same rule as `GET /api/worlds/{id}` — the world is
`PUBLIC`, or the caller owns it. Otherwise 404 (not 403), matching the
existing routers' deliberate non-disclosure.
- **Write** a chunk: caller must be `world.owner_id`, matching `update_world`;
403 otherwise. Collaborator/role-based editing is future work and slots in at
this one check.
- **Mature gating hook:** `is_mature` chunks are where `content_moderation.md`'s
age gate attaches on the read path. Specify the column and the index now; the
gate itself lands with that doc's work.
### 5.4 Concurrency
Optimistic concurrency exactly as `world.md` §5.4, expressed over HTTP:
1. Reader gets `revision` (header `X-Chunk-Revision`, or the JSON field).
2. Writer sends `base_revision`.
3. The service does `SELECT ... FOR UPDATE` on the chunk row, compares
`base_revision` to `revision`, and either commits at `revision + 1` or returns
**409** with `current_revision` and `current_checksum` so the editor can
re-fetch and re-apply.
4. `base_revision: 0` means "create; fail with 409 if it already exists".
### 5.5 Validation (the backend must not trust the client)
The write path decodes the submitted payload server-side before storing it. This
requires a **Python port of the `world.md` §3.6 codec** —
`backend/app/services/chunk_codec.py`, mirroring `scripts/chunk_data_decoder.gd`.
It checks: magic `AGPC`, `format_version <= 1`, declared vs. actual
`payload_len`, **recomputed** SHA-256 (the header's checksum is advisory), tile
layer dims equal the room's `grid.size`, tile indices in palette range, object
count ≤ `WORLD_CHUNK_MAX_OBJECTS`, object positions inside the chunk's bounds,
`chunk_key` in the payload matching the URL, and string-field length limits
(`world.md` §7). `content_flags` and `is_mature` are **recomputed** from the
payload's flag sections, never taken from the request body.
**Drift control (required):** the two codecs must be pinned by golden files —
fixture chunks under `tests/fixtures/chunks/` encoded by one implementation and
decoded byte-identically by the other, asserted in both the pytest suite and the
Godot headless suite. Without this the format has two implementations and no
contract test, which is exactly the failure `world.md` §5.2 was trying to avoid
by having one shared implementation.
---
## 6. D4 — Editing versus a live session (scope boundary)
**In scope for this milestone:**
- The editor is entered as **"Edit world" from the world browser**: a non-joined
client mode. It does **not** call `POST /api/worlds/{id}/join`, does not open
an ENet connection, and does not spawn a dedicated server. It loads chunks over
HTTP, edits them locally, and saves them over HTTP.
- Writes always succeed regardless of whether a session is live. The write
response carries `live_session: bool` (from `supervisor.debug_state()`), so the
editor can surface *"this world has players in it right now — they'll see your
changes when the session restarts."*
- A dedicated server started **after** a write sees the new content, because it
loads from the store at startup (§7).
**Explicitly out of scope, deferred:**
- Build-in-place while joined to a live session.
- `chunk_invalidated` push to connected clients (`world.md` §5.3) and hot-reload
of the dedicated server's collision world.
- Live collaborative editing / cursors (`editor.md` §9).
**The seam that makes the deferred work cheap:** the manifest endpoint (§7.2)
returns a monotonic `content_revision` for the world, bumped on every chunk
write. Hot reload is then: the dedicated server polls the manifest on a timer
(or the backend pushes to it over the same internal channel), re-fetches the
chunks whose revision changed, and rebuilds those collision bodies. Nothing in
this design has to change to add it.
**Why the editor must not join:** if it joined, the sim process would enforce
collision against content it loaded at spawn while the creator edits a diverging
local copy — players would walk through new walls and bounce off deleted ones,
with no error anywhere. Making build mode non-joined turns a silent correctness
bug into an explicit product boundary.
---
## 7. D3 — What the dedicated server does at startup
### 7.1 Decision
`server.gd` pulls its world's content from the backend over **HTTP** at startup,
authenticated by a **per-spawn token** the supervisor mints, and **does not bind
the ENet port until content is loaded**.
Rejected alternative — *supervisor writes a whole-world snapshot file at spawn
and passes `--world-file`*: attractive (no new auth surface, no HTTP client in
the sim process), but it makes spawn latency proportional to world size, gives
the server no way to fetch a chunk lazily as players roam beyond the preloaded
set, and still needs a separate channel for the hot-reload work above. Rejected —
*give the Godot process a Postgres connection*: there is no PG driver in
GDScript, and it would put database credentials in a process spawned per join.
### 7.2 Startup sequence
1. `_parse_args()` gains `--api-url` and `--server-token` (§7.3).
2. `GET /api/internal/worlds/{world_id}/manifest` → rooms, per-room `grid`,
`bounds`, `spawn`, `chunk_count`, and the world's `content_revision`.
3. `GET /api/internal/worlds/{world_id}/rooms/{slug}/chunks?` bounds query for
the spawn neighbourhood (radius from the spawn point, default 3 chunks), then
fetch each payload; decode with the existing `ChunkDataDecoder` and build
static collision from the tile layers and objects.
4. **Then** `network_peer.create_server(port)`, then write the status file.
5. **Fallback:** if the backend is unreachable, the world has no rooms, or the
room has zero chunks, load `test_level.tscn` as today and record
`"content": "fallback"` in the status file. A content outage must never make
a world un-joinable, and this keeps every PC3 flow working before any world
has authored content.
During the session the server may lazily fetch chunks as players approach
unloaded coordinates, through the same internal route. It **never writes**.
Dynamic entity runtime state (positions, health) stays in memory and out of
chunks, exactly as `world.md` §3.3 already specifies. Shutdown flushes nothing.
### 7.3 Internal authentication
`GameServerSupervisor._spawn()` generates `token = secrets.token_urlsafe(32)`,
stores it on the `_WorldServer` dataclass, and passes it on the command line
alongside the existing `--port/--world/--status-file`. A FastAPI dependency
`require_game_server` resolves `Authorization: Bearer <token>` against
`supervisor.validate_token(world_id, token)` — a dict lookup, since the
supervisor is an in-process singleton. Properties this gives us:
- A token grants access to **one** world and **dies with its process** (the
reaper's `del self._servers[wid]` invalidates it).
- No long-lived shared secret in config or on disk.
- It reuses the **already documented** single-worker constraint in
`game_servers.py`; it does not add a new one.
Rejected alternative — *let the dedicated server use the public routes with a
service user account*: the public routes enforce per-user visibility and
ownership, which a service identity would have to be exempted from, and it would
pollute `updated_by_id`/audit rows with a fake user. A separate `/api/internal`
prefix keeps the two auth models apart and can be blocked at the reverse proxy.
### 7.4 Lifecycle changes this requires in `game_servers.py`
These are real, load-bearing findings from the current implementation:
- **`_await_port()` is the wrong readiness signal now.** It returns as soon as
the UDP port is bound, and the port is bound before content exists today. With
content loading it must await a `"ready": true` field that `server.gd` writes
to the status file *after* content load + `create_server`. Otherwise the
backend hands a client an address for a server that is still fetching chunks.
- **`_await_port()` fails open.** After its 8 s deadline it returns normally,
with no error — `ensure_server` then reports success for a server that may
never have started. Content loading makes a slow start more likely, so the
readiness wait needs a real timeout error (503 to the joining client) and a
longer, configurable deadline.
- **The status file is the only server→backend channel**, and `_spawn` currently
passes no credentials at all — hence §7.3.
- **`player_count()` returns 0 when the status file is missing or unparseable**,
so a live server whose status write fails can be reaped after the idle timeout.
Harmless for data (the sim owns nothing durable — the point of D2) but worth a
comment where the fallback lives.
- **No crash restart** (documented in the module docstring): if a server dies,
the next join respawns it and it reloads content from the store. With D2 this
is now genuinely safe rather than merely tolerated.
---
## 8. API surface
All paths are under the existing `worlds` router prefix (`/api/worlds`) unless
noted. New router module: `backend/app/routers/world_content.py`, mounted at
`/api/worlds`; internal routes in `backend/app/routers/internal.py` at
`/api/internal`.
### 8.1 Public (editor & player client; JWT `CurrentUser`)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/worlds/{world_id}/rooms` | List rooms + grid config |
| `POST` | `/api/worlds/{world_id}/rooms` | Create a room (owner) |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Chunk **index** over a bounds query |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Fetch one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Write one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Write up to `WORLD_CHUNK_MAX_BATCH` chunks atomically |
| `DELETE` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Delete one chunk |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions` | Revision history (owner) |
| `POST` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback` | Restore a revision (owner) |
**Room list** — `GET .../rooms` → `200`
```json
[{ "id": 7, "slug": "main", "name": "Main",
"grid": { "size": [32, 32, 1], "origin": [0, 0, 0] },
"bounds": null,
"spawn": { "pos": [0, 1.5, 0], "yaw": 0.0 },
"chunk_count": 41 }]
```
**Chunk index** — `GET .../chunks?cx_min=&cx_max=&cy_min=&cy_max=&cz=0` → `200`.
Payloads are *not* included; this is `world.md` §5.1's `list_chunks(bounds)` and
lets a streamer decide what to fetch and skip anything already cached by
checksum.
```json
{ "room": "main", "content_revision": 118,
"chunks": [{ "key": "12:main:0:0:0", "cx": 0, "cy": 0, "cz": 0,
"revision": 4, "checksum": "9f86…", "payload_len": 2344,
"is_mature": false, "updated_at": "2026-09-14T10:02:11Z" }] }
```
Bounds are capped (default 32×32 chunks per query) and return `422` beyond that.
**Chunk fetch** — `GET .../chunks/{cx}/{cy}/{cz}`, content-negotiated:
- `Accept: application/octet-stream` (default for the game client) → `200` with
the raw `world.md` §3.6 bytes, `ETag: "<checksum>"`,
`X-Chunk-Revision: <n>`, `X-Chunk-Key: <key>`.
- `Accept: application/json` (tooling, tests, the editor's debug view) → the
§3.5 JSON encoding, decoded server-side from the payload. JSON is derived, never
stored, so there is no dual-write drift (`world.md` §4.4).
- `If-None-Match: "<checksum>"` → `304`. This is what makes the client's
`user://chunk_cache/` cache cheap and is why the checksum is the ETag.
- `404` if the chunk does not exist (an unwritten coordinate is not an error to
the streamer — it renders empty).
**Chunk write** — `PUT .../chunks/{cx}/{cy}/{cz}`
```
Content-Type: application/octet-stream
X-Base-Revision: 4 # 0 = create
<binary payload>
```
or, for clients that prefer JSON:
`{"base_revision": 4, "payload_b64": "…"}`.
Responses:
| Code | Body / meaning |
|---|---|
| `200` | `{"key", "revision", "checksum", "payload_len", "updated_at", "is_mature", "live_session"}` |
| `409` | `{"detail": "revision conflict", "current_revision": 6, "current_checksum": "…"}` |
| `403` | not the world owner |
| `404` | world or room not found / not visible |
| `413` | payload over `WORLD_CHUNK_MAX_PAYLOAD_BYTES` |
| `422` | decode/validation failure (`{"detail", "reason"}`, e.g. `checksum_mismatch`, `layer_dims`, `object_out_of_bounds`) |
**Batch write** — `PUT .../chunks` with
`{"chunks": [{"cx","cy","cz","base_revision","payload_b64"}, …]}`. Applied in
**one transaction, all-or-nothing**: if any entry conflicts, nothing is written
and the response is `409` naming the offending coordinate. Justification: an
editor brush stroke or an object drag routinely straddles a chunk boundary, and
without a batch the editor either issues N round trips or leaves half of a user
action persisted. Capped at `WORLD_CHUNK_MAX_BATCH` chunks and 8 MB.
### 8.2 D5 — Full-chunk PUT, not JSON-Patch deltas
`world.md` §5.4 specifies RFC-6902 deltas against `base_revision`. This design
sends **whole chunks** instead.
- A 32×32×1 chunk is ~2 KB of dense tile indices plus its objects — a few KB.
The bandwidth a patch saves is negligible at editor rates.
- The stored form is an opaque binary blob. Patching it means the backend must
decode → apply a JSON-Patch → re-encode → re-checksum, i.e. implement and test
a *second* mutation format on top of the codec, with its own failure modes, and
keep it in sync with the client's patch generator. That is the same dual-path
drift risk `world.md` §5.2 exists to prevent.
- Lost-update protection comes from `base_revision`, not from the patch, so
nothing is given up on correctness.
Delta encoding stays available as a pure bandwidth optimization later —
`world.md` §8 already files it as future work.
### 8.3 Internal (dedicated server; `require_game_server` token)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/internal/worlds/{world_id}/manifest` | Rooms, grids, spawn, `content_revision`, chunk counts |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks` | Bounds index (same shape as §8.1) |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Binary payload |
Read-only by construction — there is no internal write route, and adding one
would violate D2. The token is world-scoped, so a path whose `world_id` differs
from the token's world is `403`. These routes skip the per-user visibility check
(the sim process has no user) and apply no mature gating (gating is a client/user
concern; the server still needs collision).
### 8.4 D6 — The client streams chunks over HTTP, not over ENet
`world.md` §5.3 routes `request_chunk` / `chunk_data` over the game's ENet
connection. This design has the playing client fetch chunks from the **backend
over HTTP**, the same routes the editor uses.
- The dedicated server has no content the backend doesn't have; proxying bytes
through it adds a hop and puts multi-megabyte reliable transfers in contention
with the 30 Hz snapshot broadcast on the same ENet peer.
- HTTP gives caching for free — `ETag`/`If-None-Match` on the checksum, and a
local `user://chunk_cache/<checksum>` that survives sessions.
- `ApiClient` (`scripts/api_client.gd`) already carries the JWT and the result
contract; no new transport code.
**This does not require rewriting `ChunkStreamManager`.** It already calls
through a `set_server_rpc()` seam that only needs an object with
`request_chunk(key)`. The editor-wiring task supplies an `HttpChunkSource` that
implements that method against `ApiClient` and feeds
`_on_chunk_data_received(key, bytes)` — which also finally gives that dormant
seam a caller. Note the existing `_load_chunk()` builds its key with empty
world/room ids (`Chunk.build_key("", "", coord)`); the adapter must supply the
real world id and room slug.
---
## 9. What each follow-on task owns
**Backend task**
- `backend/app/models/world_content.py` + `0005_add_world_content_tables`
- `backend/app/services/chunk_codec.py` (Python port of `world.md` §3.6) and the
golden-file fixtures shared with the Godot suite
- `backend/app/services/world_store.py` (the `world.md` §5.1 service interface,
minus `flush_dirty`/`rebuild_index`, over Postgres)
- `backend/app/routers/world_content.py`, `backend/app/routers/internal.py`
- `game_servers.py`: per-spawn token, `--api-url`/`--server-token` args,
readiness-from-status-file instead of `_await_port` (§7.4)
- `create_world` creates the `main` room
**Client/editor task**
- `HttpChunkSource` adapter → `ChunkStreamManager.set_server_rpc()`
- Editor save path (single + batch PUT), 409 conflict re-fetch-and-reapply,
`live_session` warning UI
- "Edit world" entry from the world browser that does **not** join
- `server.gd`: arg parsing, manifest + chunk fetch before `create_server`,
collision build from decoded chunks, `test_level.tscn` fallback,
`"ready"`/`"content"` fields in the status file
---
## 10. Open questions (deliberately not decided here)
- **Hot reload / build-in-place** while a session is live (§6) — the
`content_revision` seam is in place for it.
- **Collaborator permissions** beyond `owner_id` — one check in §5.3.
- **Mature-content gating** on the read path — column and index exist; the
policy belongs to `content_moderation.md`.
- **`world_chunk_objects`** derived index — spec'd in `world.md` §4.5, build it
when a query needs it (§4.2).
- **Payload compression** — the §3.6 header reserves a flags bitmask; Postgres
TOAST already compresses `BYTEA`, so measure before adding a second layer.
- **Chunk streaming for very large worlds** — the bounds-query cap in §8.1 is a
guardrail, not a scaling plan.
-97
View File
@@ -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
View File
@@ -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).
+4 -10
View File
@@ -7,17 +7,14 @@ 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
Last verified against `main`: 2026-08-21 — migrations + pytest + all 10
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.
`smoke_test_enter_world.gd`) for what's actually verified.
## Backend — `backend/tests/` (pytest)
@@ -56,10 +53,8 @@ these yet):
## 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
**11 smoke tests merged into `main`.** Ten need a live backend, one
(`smoke_test_network_client.gd`) needs a live dedicated server instead —
`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.
@@ -77,7 +72,6 @@ local catalog/loadout/loader logic. Backend-dependent tests read
| `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
+2 -2
View File
@@ -7,7 +7,7 @@ advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="*.json"
include_filter=""
exclude_filter=""
export_path="export/linux/agp-game.x86_64"
encryption_include_filters=""
@@ -38,7 +38,7 @@ advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="*.json"
include_filter=""
exclude_filter=""
export_path="export/windows/agp-game.exe"
encryption_include_filters=""
-18
View File
@@ -14,8 +14,6 @@ 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]
@@ -45,22 +43,6 @@ move_down={
"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)
]
}
toggle_build_mode={
"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":4194333,"key_label":0,"unicode":0,"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]
+1
View File
@@ -0,0 +1 @@
-12
View File
@@ -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")
-6
View File
@@ -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")
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://bqs8xk2rig0dev"]
[ext_resource type="Script" path="res://scripts/tools/screenshot_rig.gd" id="1"]
[node name="ScreenshotRig" type="Node"]
script = ExtResource("1")
+1 -3
View File
@@ -1,11 +1,9 @@
[gd_scene load_steps=4 format=3 uid="uid://game_scene_pc2"]
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
[ext_resource type="Script" path="res://scripts/game.gd" id="0_game"]
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
[node name="Game" type="Node3D"]
script = ExtResource("0_game")
[node name="Level" parent="." instance=ExtResource("1_level")]
+2 -9
View File
@@ -83,11 +83,11 @@ visible = false
placeholder_text = "Email"
size_flags_horizontal = 3
[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
visible = false
text = "I am 18 or older"
size_flags_horizontal = 3
placeholder_text = "Date of birth (YYYY-MM-DD)"
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
@@ -112,12 +112,6 @@ layout_mode = 1
text = "Play"
size_flags_horizontal = 4
[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Customize Avatar"
size_flags_horizontal = 4
disabled = true
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Quit"
@@ -126,7 +120,6 @@ size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
+3 -15
View File
@@ -1,7 +1,6 @@
[gd_scene load_steps=5 format=3 uid="uid://player_scene_001"]
[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"]
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
[ext_resource type="Script" path="res://scripts/scripting/player_inventory.gd" id="2_inventory"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
radius = 0.35
@@ -12,8 +11,6 @@ radius = 0.35
height = 1.6
[node name="Player" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 1
script = ExtResource("1_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
@@ -22,15 +19,6 @@ shape = SubResource("CapsuleShape1")
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh1")
[node name="CameraPivot" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
[node name="SpringArm3D" type="SpringArm3D" parent="CameraPivot"]
spring_length = 3.5
margin = 0.2
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
current = true
[node name="Inventory" type="Node" parent="."]
script = ExtResource("2_inventory")
+1 -10
View File
@@ -1,15 +1,6 @@
[gd_scene load_steps=3 format=3]
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape_server_player"]
radius = 0.35
height = 1.6
[node name="Player" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 1
script = ExtResource("1")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape_server_player")
+6 -27
View File
@@ -1,6 +1,4 @@
[gd_scene load_steps=2 format=3 uid="uid://c49a36fc7db_test_level"]
[ext_resource type="Script" path="res://scripts/scripting/script_interpreter.gd" id="1_scripted_chest"]
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
size = Vector3(40, 1, 40)
@@ -46,18 +44,12 @@ radius = 0.5
subdivisions_ring = 8
subdivisions_height = 8
[sub_resource type="BoxShape3D" id="BoxShape3D_chest"]
size = Vector3(1, 0.8, 0.6)
[sub_resource type="BoxMesh" id="BoxMesh_chest"]
size = Vector3(1, 0.8, 0.6)
[sub_resource type="Environment" id="WorldEnvironment_env"]
background_mode = 1
background_color = Color(0.45, 0.62, 0.82, 1)
ambient_light_source = 2
ambient_light_color = Color(0.7, 0.75, 0.82, 1)
ambient_light_energy = 0.8
background_color = Color(0.4, 0.6, 0.8, 1)
ambient_light_source = 1
ambient_light_energy = 0.4
ambient_light_sky_energy = 1.0
[node name="TestLevel" type="Node3D"]
@@ -117,21 +109,8 @@ shape = SubResource("SphereShape3D_prop5")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
mesh = SubResource("SphereMesh_prop5")
[node name="ScriptedChest" type="StaticBody3D" parent="."]
position = Vector3(0, 1.0, 1.5)
script = ExtResource("1_scripted_chest")
behavior_path = "res://assets/scripting/behaviors/demo_chest.json"
interact_radius = 2.5
[node name="CollisionShape3D" type="CollisionShape3D" parent="ScriptedChest"]
shape = SubResource("BoxShape3D_chest")
[node name="MeshInstance3D" type="MeshInstance3D" parent="ScriptedChest"]
mesh = SubResource("BoxMesh_chest")
[node name="Sun" type="DirectionalLight3D" parent="."]
rotation = Vector3(-0.95, -0.5, 0)
light_energy = 1.3
rotation = Vector3(-0.785398, 0, 0)
shadow_enabled = true
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
+3 -37
View File
@@ -69,16 +69,6 @@ func clear_token() -> void:
var _base_url: String = "http://127.0.0.1:8000"
## Read-only access to the configured backend base URL (no trailing slash).
## Lets other client-side code that needs to build its own request outside
## the [method request] primitive -- e.g. [HttpChunkSource], which needs raw
## response bytes and custom headers `request()`'s JSON-only contract can't
## give it -- target the same backend without duplicating the env var /
## ProjectSettings resolution logic in `_ready()`.
var base_url: String:
get:
return _base_url
## Request timeout in seconds. 0 disables the timeout.
var timeout_seconds: float = 10.0
@@ -188,19 +178,18 @@ func login(username: String, password: String) -> Dictionary:
## Register a new user. On success stores the returned access token.
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
## and rejects under-18s (403).
## @param over_18 Must be true; the backend rejects registration otherwise.
func register(
username: String,
email: String,
password: String,
date_of_birth: String
over_18: bool
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"date_of_birth": date_of_birth,
"over_18": over_18,
}
var result := await request(
HTTPClient.METHOD_POST,
@@ -232,29 +221,6 @@ func get_me() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
## Fetch the current user's saved avatar loadout (requires auth).
## On success, result.data is {"data": <loadout dict>, "is_default": bool} --
## `is_default` true means the user has never customised and this is the
## server default.
func get_avatar_loadout() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
## Ask the backend for a dedicated server to host [param world_id] and return
## its address. On success result.data is {"world_id", "host", "port"}.
## 503 if this backend has no game-server orchestration.
func join_world(world_id: int) -> Dictionary:
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
## Save (replace) the current user's avatar loadout (requires auth).
## @param loadout A dict in the AvatarLoadout.to_json() shape
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
var body := JSON.stringify({"data": loadout})
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
-184
View File
@@ -1,184 +0,0 @@
## AvatarCatalog - Asset catalog management.
##
## Manages the mapping of asset IDs to their metadata and resource paths.
## Loads the catalog manifest from disk and provides lookup by ID and slot.
##
## The catalog is loaded from `res://assets/avatars/catalog.json` which follows
## the format specified in avatar.md §7.2.
class_name AvatarCatalog
extends RefCounted
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
## Emitted when the catalog is loaded successfully.
signal catalog_loaded
## Emitted when the catalog fails to load.
signal catalog_load_failed(error_message: String)
## Catalog manifest structure:
## { "assets": { "id": { "type", "slot", "path", "version", ... } } }
var _catalog: Dictionary = {}
var _loaded: bool = false
var _catalog_path: String = "res://assets/avatars/catalog.json"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Load the catalog from disk. Call this before any lookups.
func load_catalog(path: String = _catalog_path) -> Error:
_catalog_path = path
_catalog = {}
var file = FileAccess.open(path, FileAccess.READ)
if file == null:
var err = FileAccess.get_open_error()
catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err])
return err
var json_text: String = file.get_as_text()
file.close()
var result: Variant = JSON.parse_string(json_text)
if result == null or not result is Dictionary:
catalog_load_failed.emit("Invalid JSON in catalog: %s" % path)
return ERR_PARSE_ERROR
var json: Dictionary = result as Dictionary
if not json.has("assets"):
catalog_load_failed.emit("Catalog missing 'assets' key")
return ERR_INVALID_DATA
_catalog = json["assets"]
_loaded = true
catalog_loaded.emit()
return OK
## Check if the catalog has been loaded.
func is_loaded() -> bool:
return _loaded
## Resolve an asset by its ID.
## Returns a Dictionary with keys: type, slot, path, version, etc.
## Returns null if the asset ID is not found.
func resolve(id: String) -> Dictionary:
if not _loaded:
return {}
return _catalog.get(id, {})
## Check if an asset ID exists in the catalog.
func has_asset(id: String) -> bool:
if not _loaded:
return false
return _catalog.has(id)
## Get all assets that belong to a given slot.
## Returns an array of asset ID strings.
func get_assets_by_slot(slot: String) -> Array[String]:
if not _loaded:
return []
var result: Array[String] = []
for id in _catalog:
var asset: Dictionary = _catalog[id]
if asset.get("slot") == slot:
result.append(id)
return result
## Get all asset IDs in the catalog.
func get_all_ids() -> Array[String]:
if not _loaded:
return []
return _catalog.keys()
## Validate a loadout against the catalog.
## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]).
func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
var errors: Array[String] = []
# Check base_body
if loadout.base_body.is_empty():
errors.append("Loadout missing base_body")
elif not has_asset(loadout.base_body):
errors.append("Unknown base_body asset: %s" % loadout.base_body)
else:
var base: Dictionary = resolve(loadout.base_body)
if base.get("type") != "base_body":
errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body)
# Check each slot
for slot_id in loadout.slots:
var slot_value = loadout.slots[slot_id]
if slot_value == null:
continue
if slot_id == "accessory":
# Accessory is an array
if not slot_value is Array:
errors.append("Slot '%s' must be an array" % slot_id)
continue
for item_id in slot_value:
if not has_asset(item_id):
errors.append("Unknown accessory asset: %s" % item_id)
else:
var asset: Dictionary = resolve(item_id)
if asset.get("slot") != slot_id:
errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [
item_id, asset.get("slot"), slot_id
])
else:
# Exclusive slot - single value
if not slot_value is String:
errors.append("Slot '%s' must be a string or null" % slot_id)
continue
if not has_asset(slot_value):
errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value])
else:
var asset: Dictionary = resolve(slot_value)
if asset.get("slot") != slot_id:
errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [
slot_value, asset.get("slot"), slot_id
])
return {
"valid": errors.is_empty(),
"errors": errors
}
## Compute a hash of the loadout for change detection.
## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int
## is 64-bit signed, the value is just constrained to 32 bits of range).
static func compute_loadout_hash(loadout: AvatarLoadout) -> int:
var hasher = HashingContext.new()
hasher.start(HashingContext.HASH_SHA256)
hasher.update(loadout.base_body.to_utf8_buffer())
for slot_id in loadout.slots:
var slot_value = loadout.slots[slot_id]
hasher.update(slot_id.to_utf8_buffer())
if slot_value is Array:
for item in slot_value:
hasher.update(item.to_utf8_buffer())
elif slot_value is String:
hasher.update(slot_value.to_utf8_buffer())
var hash_bytes = hasher.finish()
return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24)
## Get the catalog path.
func get_catalog_path() -> String:
return _catalog_path
-1
View File
@@ -1 +0,0 @@
uid://ceyugve7rjxqs
-621
View File
@@ -1,621 +0,0 @@
## AvatarCustomizationUI
##
## The in-game avatar customization screen. Lets the player:
## - See a live preview of their avatar.
## - Browse and equip/unequip items per slot.
## - Adjust skin tone.
## - Save / load loadouts.
##
## The UI is built programmatically so it works without a pre-authored .tscn.
## It is a Control node that can be added to any scene.
class_name AvatarCustomizationUI
extends Control
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
const AvatarVisualizer := preload("res://scripts/avatar/avatar_visualizer.gd")
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when the player closes the customization screen.
signal closed
## Emitted when the loadout changes (after equip/unequip/skin-tone).
signal loadout_changed(loadout: AvatarLoadout)
# ---------------------------------------------------------------------------
# Exports / config
# ---------------------------------------------------------------------------
## Path to the asset catalog manifest.
@export var catalog_path: String = "res://assets/avatars/catalog.json"
## The default base body to use when none is equipped.
@export var default_base_body: String = "base_human_male_01"
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## The current loadout being edited.
var loadout: AvatarLoadout = null
## The AvatarSystem singleton (set in _ready).
var _system: Node = null
## The catalog (owned by AvatarSystem).
var _catalog: AvatarCatalog = null
## The loader (owned by AvatarSystem).
var _loader: AvatarLoader = null
## The visualizer that renders the preview.
var _visualizer: AvatarVisualizer = null
## The preview viewport container.
var _preview_container: SubViewportContainer = null
## The preview viewport.
var _preview_viewport: SubViewport = null
## The preview camera.
var _preview_camera: Camera3D = null
## The preview avatar root.
var _preview_root: Node3D = null
## Currently selected slot id.
var _selected_slot: String = ""
## Skin: a base colour picked from a swatch, plus a shade multiplier from the
## slider (0.5 = the swatch as-is, lower = darker, higher = lighter). The
## first swatch is the placeholder default yellow.
const SKIN_SWATCHES: Array[Color] = [
Color(0.96, 0.80, 0.19), # yellow (default)
Color(0.95, 0.78, 0.66), # pale
Color(0.83, 0.62, 0.46), # tan
Color(0.55, 0.38, 0.27), # brown
Color(0.33, 0.21, 0.15), # deep brown
Color(0.62, 0.62, 0.64), # grey
Color(0.78, 0.24, 0.24), # red
Color(0.30, 0.62, 0.34), # green
Color(0.26, 0.46, 0.76), # blue
Color(0.62, 0.36, 0.72), # purple
]
var _skin_base: Color = SKIN_SWATCHES[0]
## Turntable: the preview avatar's Y rotation (radians), whether the player is
## currently dragging it, and how long since they last touched it (drives the
## slow idle auto-spin). Starts facing the camera.
var _turntable_yaw: float = PI
var _turntable_dragging: bool = false
var _turntable_idle: float = 0.0
const _TURNTABLE_IDLE_SPIN_DELAY := 2.5
const _TURNTABLE_IDLE_SPIN_SPEED := 0.5
const _TURNTABLE_DRAG_SPEED := 0.01
## UI node references.
var _slot_list: VBoxContainer = null
var _item_list: VBoxContainer = null
var _slot_label: Label = null
var _item_label: Label = null
var _skin_tone_slider: HSlider = null
var _status_label: Label = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Grab the AvatarSystem autoload.
_system = get_node_or_null("/root/AvatarSystem")
if _system == null:
push_error("AvatarCustomizationUI: AvatarSystem autoload not found")
return
_catalog = _system.catalog
_loader = _system.loader
# Load the catalog if not already loaded.
if _catalog != null and not _catalog.is_loaded():
_catalog.load_catalog(catalog_path)
# Build the UI.
_build_ui()
# Seed from the player's live loadout (set at login) if there is one, so
# the screen opens showing what they're actually wearing; otherwise start
# from a bare default.
if _system != null and _system.current_loadout != null:
loadout = AvatarLoadout.from_json(_system.current_loadout.to_json())
if loadout == null or not loadout.validate():
loadout = AvatarLoadout.new()
loadout.base_body = default_base_body
# Initialize the visualizer.
_visualizer = AvatarVisualizer.new()
# Populate the slot list.
_populate_slots()
# Select the first slot.
if AvatarSlotRegistry.SLOT_IDS.size() > 0:
_select_slot(AvatarSlotRegistry.SLOT_IDS[0])
# Refresh the preview.
_refresh_preview()
# ---------------------------------------------------------------------------
# UI construction
# ---------------------------------------------------------------------------
## Build the entire UI tree programmatically.
func _build_ui() -> void:
# Root layout: horizontal split (preview | controls).
var root_hbox := HBoxContainer.new()
root_hbox.name = "RootLayout"
root_hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
root_hbox.add_theme_constant_override("separation", 16)
add_child(root_hbox)
# --- Left: preview (takes all the leftover width) ---
var preview_panel := PanelContainer.new()
preview_panel.name = "PreviewPanel"
preview_panel.custom_minimum_size = Vector2(420, 0)
preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
root_hbox.add_child(preview_panel)
var preview_margin := MarginContainer.new()
preview_margin.add_theme_constant_override("margin_left", 8)
preview_margin.add_theme_constant_override("margin_right", 8)
preview_margin.add_theme_constant_override("margin_top", 8)
preview_margin.add_theme_constant_override("margin_bottom", 8)
preview_panel.add_child(preview_margin)
var preview_vbox := VBoxContainer.new()
preview_vbox.add_theme_constant_override("separation", 6)
preview_margin.add_child(preview_vbox)
_preview_container = SubViewportContainer.new()
_preview_container.name = "PreviewViewportContainer"
_preview_container.stretch = true
_preview_container.custom_minimum_size = Vector2(400, 520)
_preview_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_preview_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
_preview_container.mouse_filter = Control.MOUSE_FILTER_STOP
_preview_container.gui_input.connect(_on_preview_gui_input)
preview_vbox.add_child(_preview_container)
var rotate_hint := Label.new()
rotate_hint.text = "drag to rotate"
rotate_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
rotate_hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.5))
rotate_hint.add_theme_font_size_override("font_size", 12)
preview_vbox.add_child(rotate_hint)
_preview_viewport = SubViewport.new()
_preview_viewport.name = "PreviewViewport"
_preview_viewport.size = Vector2i(400, 520)
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_preview_viewport.transparent_bg = true
# Its own 3D world -- a SubViewport that shares the (Control-only) main
# world has no scenario/environment set up and renders nothing.
_preview_viewport.own_world_3d = true
_preview_container.add_child(_preview_viewport)
# Environment: ambient fill so the model reads even from a bad light angle.
var world_env := WorldEnvironment.new()
world_env.name = "PreviewEnv"
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.10, 0.11, 0.14)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.62, 0.64, 0.7)
env.ambient_light_energy = 0.7
world_env.environment = env
_preview_viewport.add_child(world_env)
# Camera for the preview -- framed to fit the full ~2 m avatar.
_preview_camera = Camera3D.new()
_preview_camera.name = "PreviewCamera"
# look_at needs a global transform, which only exists once in the tree.
_preview_camera.look_at_from_position(
Vector3(0, 1.05, 3.5), Vector3(0, 0.98, 0), Vector3.UP)
_preview_camera.current = true
_preview_viewport.add_child(_preview_camera)
# Avatar root inside the viewport.
_preview_root = Node3D.new()
_preview_root.name = "AvatarPreviewRoot"
_preview_viewport.add_child(_preview_root)
# Key + fill lights so the avatar is clearly visible.
var key := DirectionalLight3D.new()
key.name = "PreviewKeyLight"
key.rotation_degrees = Vector3(-35, 35, 0)
key.light_energy = 1.0
_preview_viewport.add_child(key)
var fill := DirectionalLight3D.new()
fill.name = "PreviewFillLight"
fill.rotation_degrees = Vector3(-20, -140, 0)
fill.light_energy = 0.35
_preview_viewport.add_child(fill)
# --- Right: controls ---
var controls_panel := PanelContainer.new()
controls_panel.name = "ControlsPanel"
controls_panel.custom_minimum_size = Vector2(520, 0)
root_hbox.add_child(controls_panel)
var controls_margin := MarginContainer.new()
controls_margin.add_theme_constant_override("margin_left", 12)
controls_margin.add_theme_constant_override("margin_right", 12)
controls_margin.add_theme_constant_override("margin_top", 12)
controls_margin.add_theme_constant_override("margin_bottom", 12)
controls_panel.add_child(controls_margin)
var controls_vbox := VBoxContainer.new()
controls_vbox.name = "Controls"
controls_vbox.add_theme_constant_override("separation", 8)
controls_margin.add_child(controls_vbox)
# Title.
var title := Label.new()
title.text = "Avatar Customization"
title.add_theme_font_size_override("font_size", 24)
controls_vbox.add_child(title)
# Slot selector.
var slot_header := Label.new()
slot_header.text = "Slots"
slot_header.add_theme_font_size_override("font_size", 16)
controls_vbox.add_child(slot_header)
var slot_scroll := ScrollContainer.new()
slot_scroll.custom_minimum_size = Vector2(0, 160)
controls_vbox.add_child(slot_scroll)
_slot_list = VBoxContainer.new()
_slot_list.name = "SlotList"
_slot_list.add_theme_constant_override("separation", 4)
slot_scroll.add_child(_slot_list)
# Item selector for the selected slot.
var item_header := Label.new()
item_header.text = "Items"
item_header.add_theme_font_size_override("font_size", 16)
controls_vbox.add_child(item_header)
var item_scroll := ScrollContainer.new()
item_scroll.custom_minimum_size = Vector2(0, 200)
controls_vbox.add_child(item_scroll)
_item_list = VBoxContainer.new()
_item_list.name = "ItemList"
_item_list.add_theme_constant_override("separation", 4)
item_scroll.add_child(_item_list)
# Skin colour: a row of preset swatches + a shade slider that darkens or
# lightens the picked colour. The first swatch is the default yellow.
var skin_header := Label.new()
skin_header.text = "Skin Colour"
skin_header.add_theme_font_size_override("font_size", 16)
controls_vbox.add_child(skin_header)
var swatch_grid := GridContainer.new()
swatch_grid.columns = 5
swatch_grid.add_theme_constant_override("h_separation", 4)
swatch_grid.add_theme_constant_override("v_separation", 4)
controls_vbox.add_child(swatch_grid)
for c in SKIN_SWATCHES:
swatch_grid.add_child(_make_swatch(c))
var shade_label := Label.new()
shade_label.text = "Shade"
shade_label.add_theme_font_size_override("font_size", 13)
controls_vbox.add_child(shade_label)
_skin_tone_slider = HSlider.new()
_skin_tone_slider.name = "ShadeSlider"
_skin_tone_slider.min_value = 0.0
_skin_tone_slider.max_value = 1.0
_skin_tone_slider.step = 0.01
_skin_tone_slider.value = 0.5
_skin_tone_slider.custom_minimum_size = Vector2(0, 24)
_skin_tone_slider.value_changed.connect(_on_skin_tone_changed)
controls_vbox.add_child(_skin_tone_slider)
# Status / error label.
_status_label = Label.new()
_status_label.name = "StatusLabel"
_status_label.text = ""
_status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4))
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
controls_vbox.add_child(_status_label)
# Action buttons.
var button_row := HBoxContainer.new()
button_row.add_theme_constant_override("separation", 8)
controls_vbox.add_child(button_row)
var save_btn := Button.new()
save_btn.text = "Save Loadout"
save_btn.pressed.connect(_on_save_pressed)
button_row.add_child(save_btn)
var load_btn := Button.new()
load_btn.text = "Load Loadout"
load_btn.pressed.connect(_on_load_pressed)
button_row.add_child(load_btn)
var close_btn := Button.new()
close_btn.text = "Close"
close_btn.pressed.connect(_on_close_pressed)
button_row.add_child(close_btn)
# ---------------------------------------------------------------------------
# Slot / item population
# ---------------------------------------------------------------------------
## Populate the slot list with buttons for each slot.
func _populate_slots() -> void:
if _slot_list == null:
return
# Clear existing children.
for child in _slot_list.get_children():
child.queue_free()
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var btn := Button.new()
btn.name = "Slot_%s" % slot_id
btn.text = slot_id
btn.toggle_mode = true
btn.pressed.connect(_on_slot_button_pressed.bind(slot_id))
_slot_list.add_child(btn)
## Populate the item list for the currently selected slot.
func _populate_items() -> void:
if _item_list == null or _selected_slot.is_empty():
return
# Clear existing children.
for child in _item_list.get_children():
child.queue_free()
if _catalog == null:
return
# Get assets for this slot.
var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot)
# "None" button to unequip.
var none_btn := Button.new()
none_btn.text = "(none)"
none_btn.pressed.connect(_on_equip_pressed.bind(""))
_item_list.add_child(none_btn)
for asset_id in assets:
var btn := Button.new()
btn.text = asset_id
btn.pressed.connect(_on_equip_pressed.bind(asset_id))
_item_list.add_child(btn)
# ---------------------------------------------------------------------------
# Slot selection
# ---------------------------------------------------------------------------
## Handle a slot button press.
func _on_slot_button_pressed(slot_id: String) -> void:
_select_slot(slot_id)
## Select a slot and refresh the item list.
func _select_slot(slot_id: String) -> void:
_selected_slot = slot_id
# Update button toggle states.
if _slot_list != null:
for child in _slot_list.get_children():
if child is Button:
var btn := child as Button
btn.button_pressed = (btn.name == "Slot_%s" % slot_id)
_populate_items()
# ---------------------------------------------------------------------------
# Equip / unequip
# ---------------------------------------------------------------------------
## Handle an equip/unequip action for the selected slot.
func _on_equip_pressed(asset_id: String) -> void:
if loadout == null or _selected_slot.is_empty():
return
if _selected_slot == "accessory":
if asset_id.is_empty():
# Clear all accessories.
loadout.slots["accessory"] = []
else:
loadout.add_accessory(asset_id)
else:
loadout.set_equipment(_selected_slot, asset_id)
_refresh_preview()
loadout_changed.emit(loadout)
# ---------------------------------------------------------------------------
# Turntable (drag to rotate the preview, slow auto-spin when idle)
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
if _preview_root == null:
return
if _turntable_dragging:
_turntable_idle = 0.0
else:
_turntable_idle += delta
if _turntable_idle > _TURNTABLE_IDLE_SPIN_DELAY:
_turntable_yaw += delta * _TURNTABLE_IDLE_SPIN_SPEED
_preview_root.rotation.y = _turntable_yaw
func _on_preview_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_turntable_dragging = event.pressed
_turntable_idle = 0.0
elif event is InputEventMouseMotion and _turntable_dragging:
_turntable_yaw -= event.relative.x * _TURNTABLE_DRAG_SPEED
_turntable_idle = 0.0
# ---------------------------------------------------------------------------
# Preview refresh
# ---------------------------------------------------------------------------
## Rebuild the avatar preview from the current loadout.
func _refresh_preview() -> void:
if _visualizer == null or _preview_root == null:
return
# Clear the preview root.
for child in _preview_root.get_children():
child.queue_free()
# Validate the loadout.
if loadout == null or loadout.base_body.is_empty():
_status_label.text = "No base body selected."
return
# Build the render tree.
_visualizer.load_loadout(loadout, _preview_root)
# Show any validation errors.
if not loadout.validation_errors.is_empty():
_status_label.text = "\n".join(loadout.validation_errors)
else:
_status_label.text = ""
# ---------------------------------------------------------------------------
# Skin tone
# ---------------------------------------------------------------------------
## Build one clickable colour swatch.
func _make_swatch(color: Color) -> Button:
var b := Button.new()
b.custom_minimum_size = Vector2(40, 30)
b.tooltip_text = "#" + color.to_html(false)
for state in ["normal", "hover", "pressed", "focus", "disabled"]:
var sb := StyleBoxFlat.new()
sb.bg_color = color
sb.set_corner_radius_all(3)
if state == "hover" or state == "pressed" or state == "focus":
sb.set_border_width_all(2)
sb.border_color = Color.WHITE
b.add_theme_stylebox_override(state, sb)
b.pressed.connect(_on_skin_swatch_pressed.bind(color))
return b
func _on_skin_swatch_pressed(color: Color) -> void:
_skin_base = color
_apply_skin_colour()
func _on_skin_tone_changed(_value: float) -> void:
_apply_skin_colour()
## Combine the picked swatch with the shade slider and push it to the loadout.
func _apply_skin_colour() -> void:
if loadout == null:
return
var shade: float = _skin_tone_slider.value if _skin_tone_slider != null else 0.5
var col := _skin_base
if shade < 0.5:
col = _skin_base.lerp(Color.BLACK, (0.5 - shade) * 1.7)
elif shade > 0.5:
col = _skin_base.lerp(Color.WHITE, (shade - 0.5) * 1.3)
col.a = 1.0
loadout.skin_tone = col
_refresh_preview()
loadout_changed.emit(loadout)
# ---------------------------------------------------------------------------
# Save / load
# ---------------------------------------------------------------------------
func _on_save_pressed() -> void:
if loadout == null:
return
# Local cache (offline fallback / fast reopen).
var json := loadout.to_json()
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
# Apply to the live in-world avatar immediately.
if _system != null:
_system.set_loadout_from_dict(JSON.parse_string(json))
# Persist to the backend if we're signed in.
var api := get_node_or_null("/root/ApiClient")
if api != null and api.has_token():
_status_label.text = "Saving…"
var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json))
_status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error")
else:
_status_label.text = "Saved locally (not signed in)."
func _on_load_pressed() -> void:
if not FileAccess.file_exists("user://loadout.json"):
_status_label.text = "No saved loadout found."
return
var file := FileAccess.open("user://loadout.json", FileAccess.READ)
if file == null:
_status_label.text = "Failed to open saved loadout."
return
var json := file.get_as_text()
file.close()
var loaded := AvatarLoadout.from_json(json)
if not loaded.validation_errors.is_empty():
_status_label.text = "Saved loadout invalid: %s" % "\n".join(loaded.validation_errors)
return
loadout = loaded
_refresh_preview()
loadout_changed.emit(loadout)
_status_label.text = "Loadout loaded."
# ---------------------------------------------------------------------------
# Close
# ---------------------------------------------------------------------------
func _on_close_pressed() -> void:
closed.emit()
queue_free()
@@ -1 +0,0 @@
uid://crqtef5rifgrv
-562
View File
@@ -1,562 +0,0 @@
## AvatarLoader - glTF/GLB import, skeleton merge, and skin re-targeting.
##
## Loads base body and attachment GLB assets, extracts skeletons and meshes,
## and re-targets attachment skins to the canonical skeleton by bone name.
##
## Per the avatar architecture spec (§6.2, §6.3):
## - The base body carries the canonical skeleton.
## - Attachments are skinned to the same skeleton by name-based re-targeting.
## - Static accessories are parented to their mount bone.
## - Body-part slots hide the corresponding base mesh.
class_name AvatarLoader
extends Node
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Maximum number of attachments allowed in the accessory slot.
const MAX_ACCESSORY_COUNT := 8
## The skin albedo baked into the placeholder base body by
## gen_placeholder_avatars.gd. apply_skin_tone() finds the mesh surfaces
## painted this colour (head + arms) and retints them.
const PLACEHOLDER_SKIN_ALBEDO := Color(0.96, 0.80, 0.19)
## Skeleton bone names that form the canonical rig (§3.1).
const CANONICAL_BONES := [
"Root",
"Hips",
"Spine",
"Chest",
"Neck",
"Head",
"HeadTop",
"Jaw",
"LeftEye",
"RightEye",
"LeftShoulder",
"LeftUpperArm",
"LeftLowerArm",
"LeftHand",
"RightShoulder",
"RightUpperArm",
"RightLowerArm",
"RightHand",
"LeftUpperLeg",
"LeftLowerLeg",
"LeftFoot",
"RightUpperLeg",
"RightLowerLeg",
"RightFoot",
]
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a base body has been loaded successfully.
signal base_body_loaded(avatar_node: Node3D)
## Emitted when an attachment has been loaded and merged.
signal attachment_loaded(slot_id: String, attachment_node: Node3D)
## Emitted on error during loading.
signal load_error(error_msg: String)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## The fully assembled avatar node (Skeleton3D + meshes + attachments).
var avatar_node: Node3D = null
## The Skeleton3D belonging to the avatar.
var skeleton: Skeleton3D = null
## Mapping of bone name -> bone index in the skeleton.
var _bone_index_map: Dictionary = {}
## Loaded base body meshes, keyed by slot id.
var _base_meshes: Dictionary = {}
## Loaded attachment nodes, keyed by slot id.
var _attachments: Dictionary = {}
## Whether the base body has been loaded.
var is_base_loaded: bool = false
## The avatar catalog reference (set by AvatarSystem).
var catalog: AvatarCatalog = null
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Initialize the loader with a reference to the avatar catalog.
func init_loader(cat: AvatarCatalog) -> void:
catalog = cat
## Load the base body asset identified by [param base_body_id].
## Returns the AvatarNode3D on success, or null on failure.
func load_base_body(base_body_id: String) -> Node3D:
if catalog == null:
load_error.emit("AvatarCatalog not set on AvatarLoader")
return null
var asset_meta = catalog.resolve(base_body_id)
if asset_meta == null:
load_error.emit("Unknown base_body asset: %s" % base_body_id)
return null
if asset_meta.type != "base_body":
load_error.emit("Asset '%s' is not a base_body, got '%s'" % [
base_body_id, asset_meta.type])
return null
var path: String = asset_meta.path
avatar_node = _instance_glb_as_avatar(path)
if avatar_node == null:
load_error.emit("Failed to load base body scene: %s" % path)
return null
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
if skeleton == null:
load_error.emit("No Skeleton3D found in base body: %s" % path)
avatar_node.queue_free()
return null
# Build bone index map for fast lookups.
_bone_index_map.clear()
for i in skeleton.get_bone_count():
var bone_name: String = skeleton.get_bone_name(i)
if not bone_name.is_empty():
_bone_index_map[bone_name] = i
# Extract base body meshes and store them.
_extract_base_meshes(avatar_node)
is_base_loaded = true
base_body_loaded.emit(avatar_node)
return avatar_node
## Load and merge an attachment into the avatar.
## [param slot_id] is the slot to equip into.
## [param asset_id] is the catalog id of the attachment.
## Returns the attachment Node3D on success, or null on failure.
func load_attachment(slot_id: String, asset_id: String) -> Node3D:
if not is_base_loaded:
load_error.emit("Cannot load attachment: base body not loaded")
return null
if catalog == null:
load_error.emit("AvatarCatalog not set on AvatarLoader")
return null
var asset_meta = catalog.resolve(asset_id)
if asset_meta == null:
load_error.emit("Unknown attachment asset: %s" % asset_id)
return null
if asset_meta.type != "attachment":
load_error.emit("Asset '%s' is not an attachment, got '%s'" % [
asset_id, asset_meta.type])
return null
# Check slot exclusivity for accessory slot.
if slot_id == "accessory":
var current_accessories: Array = _attachments.get("accessory", [])
if current_accessories.size() >= MAX_ACCESSORY_COUNT:
load_error.emit("Accessory slot full (%d/%d)" % [
MAX_ACCESSORY_COUNT, MAX_ACCESSORY_COUNT])
return null
var path: String = asset_meta.path
var attachment_node = _instance_glb_as_attachment(path, slot_id)
if attachment_node == null:
load_error.emit("Failed to load attachment scene: %s" % path)
return null
# If the attachment carries its own skin, retarget it to the canonical
# skeleton; static props (the common case) are unaffected.
_retarget_skin(attachment_node)
# Mount to the appropriate bone via a BoneAttachment3D so it follows the
# skeleton. Falls back to a plain skeleton child if the mount bone is
# missing (a bad catalog entry) rather than dropping the attachment.
var mount_bone: String = asset_meta.get("mount_bone", "")
if mount_bone.is_empty():
mount_bone = _slot_to_mount_bone(slot_id)
var mount_node: Node3D
if not mount_bone.is_empty() and skeleton.find_bone(mount_bone) != -1:
var ba := BoneAttachment3D.new()
ba.name = "Mount_%s" % slot_id if slot_id != "accessory" else "Mount_accessory_%s" % asset_id
ba.bone_name = mount_bone
skeleton.add_child(ba)
ba.add_child(attachment_node)
mount_node = ba
else:
skeleton.add_child(attachment_node)
mount_node = attachment_node
# Store the top-level mount node so unload can free the whole unit.
if slot_id == "accessory":
var list: Array = _attachments.get("accessory", [])
list.append(mount_node)
_attachments["accessory"] = list
else:
if _attachments.has(slot_id) and is_instance_valid(_attachments[slot_id]):
_attachments[slot_id].queue_free()
_attachments[slot_id] = mount_node
attachment_loaded.emit(slot_id, attachment_node)
return attachment_node
## Remove an attachment from the given slot.
func unload_attachment(slot_id: String) -> void:
if slot_id == "accessory":
for node in _attachments.get("accessory", []):
if is_instance_valid(node):
node.queue_free()
_attachments.erase("accessory")
else:
var node = _attachments.get(slot_id)
if is_instance_valid(node):
node.queue_free()
_attachments.erase(slot_id)
## Hide or show the base mesh for a given body-part slot.
func set_base_mesh_visible(slot_id: String, visible: bool) -> void:
var mesh = _base_meshes.get(slot_id)
if mesh and mesh is Node3D:
mesh.visible = visible
## Retint the base body's skin surfaces (the ones the generator painted
## PLACEHOLDER_SKIN_ALBEDO -- head + arms) to [param color]. A no-op if the
## colour is unset (transparent). Real segmented bodies will do this per mesh.
func apply_skin_tone(color: Color) -> void:
if color.a <= 0.0:
return
if avatar_node == null or not is_instance_valid(avatar_node):
return
var skel := avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
if skel == null:
return
for child in skel.get_children():
if not (child is MeshInstance3D):
continue
var mi := child as MeshInstance3D
if mi.mesh == null:
continue
for s in mi.mesh.get_surface_count():
var mat := mi.get_active_material(s)
if mat is BaseMaterial3D and \
(mat as BaseMaterial3D).albedo_color.is_equal_approx(PLACEHOLDER_SKIN_ALBEDO):
var tinted := StandardMaterial3D.new()
tinted.albedo_color = color
tinted.roughness = 0.9
mi.set_surface_override_material(s, tinted)
## Get the current loadout as a dictionary (for serialization).
func get_current_loadout() -> Dictionary:
var loadout: Dictionary = {
"base_body": "",
"slots": {},
}
# Determine base body id from catalog.
if is_base_loaded and catalog != null:
# Walk through catalog to find which base_body loaded.
for id in catalog.get_all_ids():
var meta = catalog.resolve(id)
if meta and meta.type == "base_body":
loadout.base_body = id
break
# Collect equipped attachments.
for slot_id in _attachments:
if slot_id == "accessory":
loadout.slots[slot_id] = _attachments[slot_id] as Array
else:
var node = _attachments[slot_id]
if node is Node3D:
# Find the asset id by matching the node's name.
var aid: String = node.name
loadout.slots[slot_id] = aid
return loadout
## Reset the avatar to an unloaded state.
func reset() -> void:
if avatar_node and avatar_node is Node:
avatar_node.queue_free()
avatar_node = null
skeleton = null
_bone_index_map.clear()
_base_meshes.clear()
_attachments.clear()
is_base_loaded = false
# ---------------------------------------------------------------------------
# Internal: GLB Parsing
# ---------------------------------------------------------------------------
## Instantiate a base-body .glb (a Godot-imported PackedScene) into an avatar
## structure:
## Avatar (Node3D)
## Skeleton3D -- the canonical rig + skin-bound body meshes
## BodyMeshes (Node3D) -- unused container, kept for compatibility
## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment)
##
## Uses load().instantiate() rather than the runtime GLTFDocument path: the
## imported .scn is the one that actually ends up in an exported build, with
## the skeleton + skin set up by Godot's import pipeline. (The raw-bytes
## GLTFDocument path -- for future user-uploaded models -- misbehaved in a
## release build: no Skeleton3D produced, so the player fell back to a capsule.)
func _instance_glb_as_avatar(path: String) -> Node3D:
var packed = load(path)
if not (packed is PackedScene):
push_error("Base body is not an importable scene: %s" % path)
return null
var scene_root: Node = packed.instantiate()
if not (scene_root is Node3D):
push_error("Base body scene root is not a Node3D: %s" % path)
scene_root.free()
return null
var src_skel := _find_first_skeleton(scene_root)
if src_skel == null:
push_error("No Skeleton3D in base body scene: %s" % path)
scene_root.free()
return null
var avatar := Node3D.new()
avatar.name = "Avatar"
src_skel.get_parent().remove_child(src_skel)
src_skel.name = "Skeleton3D"
src_skel.transform = Transform3D.IDENTITY
_clear_owner_recursive(src_skel)
avatar.add_child(src_skel)
scene_root.free()
var body_meshes := Node3D.new()
body_meshes.name = "BodyMeshes"
avatar.add_child(body_meshes)
var attachments_container := Node3D.new()
attachments_container.name = "Attachments"
avatar.add_child(attachments_container)
return avatar
## Depth-first search for the first Skeleton3D under [param node].
func _find_first_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child in node.get_children():
var found := _find_first_skeleton(child)
if found:
return found
return null
## Instantiate an attachment .glb (imported PackedScene) into a single named
## Node3D the caller can mount to a bone as one unit.
func _instance_glb_as_attachment(path: String, slot_id: String) -> Node3D:
var packed = load(path)
if not (packed is PackedScene):
push_error("Attachment is not an importable scene: %s" % path)
return null
var scene_root: Node = packed.instantiate()
if not (scene_root is Node3D):
push_error("Attachment scene root is not a Node3D: %s" % path)
scene_root.free()
return null
var attachment := Node3D.new()
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
for child in scene_root.get_children().duplicate():
scene_root.remove_child(child)
_clear_owner_recursive(child)
attachment.add_child(child)
scene_root.free()
return attachment
## generate_scene() stamps every node with the GLB root as `owner`; that
## becomes inconsistent (and noisy) once we reparent the node elsewhere.
func _clear_owner_recursive(node: Node) -> void:
node.owner = null
for child in node.get_children():
_clear_owner_recursive(child)
## Index the base body's skinned meshes by slot so equipping a body-part
## attachment can hide the corresponding base mesh. generate_scene() parents
## skinned meshes under the Skeleton3D, so scan there.
func _extract_base_meshes(avatar: Node3D) -> void:
var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
if skel == null:
return
for child in skel.get_children():
if child is MeshInstance3D:
# The single "Body" placeholder mesh is registered only under
# "torso" -- mapping it to every body-part slot would make it
# vanish the moment any one body-part item is equipped. A real
# multi-mesh body registers each mesh under its own slot.
if child.name == "Body":
_base_meshes["torso"] = child
else:
var slot_id := _mesh_name_to_slot(child.name)
if slot_id != "":
_base_meshes[slot_id] = child
## Map a mesh name to a slot id based on naming conventions (§4.3).
func _mesh_name_to_slot(mesh_name: String) -> String:
if mesh_name == "Body":
# Generic body mesh - covers torso, legs, arms.
return "torso"
if mesh_name == "head_Mesh":
return "head"
if mesh_name == "torso_Mesh":
return "torso"
if mesh_name == "legs_Mesh":
return "legs"
if mesh_name == "left_arm_Mesh":
return "left_arm"
if mesh_name == "right_arm_Mesh":
return "right_arm"
return ""
# ---------------------------------------------------------------------------
# Internal: Skin Retargeting
# ---------------------------------------------------------------------------
## Retarget the skin weights of an attachment node to the canonical skeleton.
## Walks all MeshInstance3D children and remaps joint indices by bone name.
func _retarget_skin(attachment_node: Node3D) -> void:
if skeleton == null:
return
for child in attachment_node.get_children():
if child is MeshInstance3D:
var mesh_inst := child as MeshInstance3D
var mesh := mesh_inst.mesh
if mesh == null:
continue
# Note: MeshInstance3D.mesh is always a Mesh, never a MultiMesh —
# MultiMesh skinning goes through MultiMeshInstance3D.multimesh,
# a completely separate node/property, not reachable here.
if mesh is ArrayMesh:
var arr_mesh := mesh as ArrayMesh
var surface_count := arr_mesh.get_surface_count()
for surface_idx in surface_count:
var arrays := arr_mesh.surface_get_arrays(surface_idx)
# Static props (the common attachment) carry no bone/weight
# arrays at all -- nothing to retarget.
if arrays[Mesh.ARRAY_BONES] == null or arrays[Mesh.ARRAY_WEIGHTS] == null:
continue
var joints_array: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights_array: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if joints_array.is_empty():
continue
# Remap joint indices by bone name.
var new_joints := PackedInt32Array()
for i in range(0, joints_array.size(), 4):
for j in 4:
var old_joint := joints_array[i + j]
if old_joint < 0:
new_joints.append(0)
else:
var bone_name := skeleton.get_bone_name(old_joint)
var new_idx: int = _bone_index_map.get(bone_name, 0)
new_joints.append(new_idx)
arrays[Mesh.ARRAY_BONES] = new_joints
arr_mesh.surface_set_arrays(surface_idx, arrays)
## Parent an attachment node to its mount bone in the skeleton.
func _parent_attachment(attachment_node: Node3D, slot_id: String,
asset_meta: Dictionary) -> void:
if skeleton == null:
return
var mount_bone: String = asset_meta.get("mount_bone", "")
if mount_bone.is_empty():
# Try to infer from slot.
mount_bone = _slot_to_mount_bone(slot_id)
if mount_bone.is_empty():
return
var bone_idx := skeleton.find_bone(mount_bone)
if bone_idx < 0:
return
# Get the world transform of the bone at the time of attachment.
var bone_pose := skeleton.get_bone_pose(bone_idx)
var bone_transform := skeleton.global_transform * bone_pose
# Position the attachment at the bone's location.
attachment_node.global_transform = bone_transform
## Convert a slot id to its canonical mount bone name (§3.2).
func _slot_to_mount_bone(slot_id: String) -> String:
match slot_id:
"head": return "Head"
"hair": return "HeadTop"
"face": return "Jaw"
"torso", "chest", "back": return "Chest"
"left_arm": return "LeftUpperArm"
"right_arm": return "RightUpperArm"
"left_hand": return "LeftHand"
"right_hand": return "RightHand"
"legs": return "Hips"
"pants": return "Hips"
"left_foot": return "LeftFoot"
"right_foot": return "RightFoot"
"accessory": return "Root"
_: return ""
# ---------------------------------------------------------------------------
# Internal: File Loading
# ---------------------------------------------------------------------------
## Load a file from the given path as a PackedByteArray.
func _load_file(path: String) -> PackedByteArray:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return PackedByteArray()
var data := file.get_buffer(file.get_length())
file.close()
return data

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