Compare commits
270 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6e146e4b2 | |||
| 59ca5a4c6e | |||
| b422e41a0f | |||
| bd44ecba14 | |||
| bae326e14c | |||
| 140db9df91 | |||
| 2dff83bae3 | |||
| 1a8821e3c8 | |||
| 019dfca14f | |||
| 7be50e3313 | |||
| 29de1c5c3a | |||
| 92c10fa657 | |||
| 8357643072 | |||
| 736ebce0d0 | |||
| b60480798f | |||
| 77f7511a4f | |||
| 8be962248d | |||
| 7790670559 | |||
| c4eab8db92 | |||
| 4d754ae48e | |||
| e2a77b055f | |||
| 5966fa9044 | |||
| d1d03df2e6 | |||
| 4dda636ed0 | |||
| 945c498a31 | |||
| be0acb1862 | |||
| 8118f3074b | |||
| 4276a89a6a | |||
| 5803f47069 | |||
| e33b91d976 | |||
| 1c3c57dbd0 | |||
| fdc43bd5f1 | |||
| 406ff3457e | |||
| 3f7acf857c | |||
| 6648b2bba5 | |||
| bfcbd0486f | |||
| f70b5b2ee5 | |||
| 2fa7b1f475 | |||
| 7f753cb1b8 | |||
| 1be947f9ef | |||
| 66adcff145 | |||
| 88197ffe8f | |||
| 38d97a3051 | |||
| 174d3c2630 | |||
| b86e4053bd | |||
| 07f655815f | |||
| 2ffcd9cd3f | |||
| aeccc78143 | |||
| 1c64dd4967 | |||
| 4dd6c7c8af | |||
| e90b394f03 | |||
| 33b0c3f1fb | |||
| 2b88fc476e | |||
| 0ecc16d963 | |||
| 4ec71b87b9 | |||
| dc0c133af8 | |||
| 8f1a1110e2 | |||
| 0f7531008f | |||
| 12fef51020 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| 3a7b2ab306 | |||
| afc2211d70 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| fcfb767c49 | |||
| e2e771d2f8 | |||
| 8a5d3aabea | |||
| 3dd5416e81 | |||
| c5a5476620 | |||
| 06604a24a8 | |||
| 5c21e5051c | |||
| 3cb4d01073 | |||
| 3e81d60268 | |||
| b3d2d71789 | |||
| b3227f5b78 | |||
| 2ba6bc6b9f | |||
| 7d89e860fd | |||
| 03da22aa9e | |||
| 55a65bb7b1 | |||
| 62e15d0c87 | |||
| cbe5e992dd | |||
| d4998d7359 | |||
| ef78caa4de | |||
| 87575eec43 | |||
| 6d5051bbb2 | |||
| 72f39bc547 | |||
| 69b6f40eb9 | |||
| 4d467c0c9e | |||
| e43bc9ff10 | |||
| 5c91bb3f34 | |||
| ca7924d5e1 | |||
| 758307bbee | |||
| 478638d4ff | |||
| 7b074a01ed | |||
| c68877239f | |||
| ed6811a438 | |||
| f64ffd6422 | |||
| 7e28e2b525 | |||
| 99be8f1dab | |||
| b3e66fc072 | |||
| 634b2e39c0 | |||
| 1c75367c87 | |||
| f5a7162285 | |||
| 61dedcf73e | |||
| 580b95635f | |||
| ef21bf9d69 | |||
| e06cd2a23d | |||
| 77f9b74286 | |||
| 6238078565 | |||
| 6108c63c46 | |||
| 109c6ef1a0 | |||
| 33c5236a2d | |||
| 4bb4e9fb8a | |||
| ffbc4849a7 | |||
| a81d29c882 | |||
| 4236c4792b | |||
| a29cfc74af | |||
| cf8fd5a6fd | |||
| fc9a39de93 | |||
| aa2f79b125 | |||
| 8551d14591 | |||
| 073813f3e7 | |||
| cc971ee9f5 | |||
| 54abe7ee96 | |||
| a7cf385d0d | |||
| b5525665dd | |||
| ff610c0466 | |||
| acbc518b4a | |||
| 25a8900357 | |||
| 36fd406925 | |||
| 5a1f99eb28 | |||
| ddfe5dfaad | |||
| cb95895612 | |||
| 62889f39f1 | |||
| ef646a8e54 | |||
| e4d3fe566b | |||
| 1d0222d980 | |||
| 433d5d8f9f | |||
| 30b8dc6de2 | |||
| a9947a0a80 | |||
| dcaaf96d81 | |||
| 99762887be | |||
| dbdc01f8d6 | |||
| 1061dc0742 | |||
| 803405aba4 | |||
| ea6958c11e | |||
| 61ee81a974 | |||
| 88cd255040 | |||
| 93d83a28fc | |||
| 62f12e55db | |||
| 4c0af03573 | |||
| 2c3756b640 | |||
| 25f2dffc53 | |||
| 929c69e5da | |||
| 218e7e24af | |||
| d293ef2a08 | |||
| 689c51e179 | |||
| 5cfbcd8efd | |||
| 27461d9b5e | |||
| 10b372f379 | |||
| 1c5c0f8c8e | |||
| 5d7c61029c | |||
| 97e0804dfe | |||
| 342d0f69d9 | |||
| 97949d6b44 | |||
| 720cf6f6b9 | |||
| 4da8687f2c | |||
| 241fa9d0e0 | |||
| 48a43c44a5 | |||
| 48dd177fa2 | |||
| 11a90c58c3 | |||
| 1f85ecc812 | |||
| b547ca5785 | |||
| 3f707bf6b0 | |||
| d7d77474f9 | |||
| 2fe6d5dbf0 | |||
| 534da614dd | |||
| dc09f5ae37 | |||
| 295c44522c | |||
| 33d2ef7f8a | |||
| b51c90c360 | |||
| 4df839ccd8 | |||
| 2b1786558a | |||
| 7a6658798f | |||
| 46a2ff672c | |||
| f3c679faab | |||
| 5837fad5df | |||
| f659fd4d2a | |||
| fa6e9064dc | |||
| 3e030f8d45 | |||
| 7594039fd0 | |||
| 857cf08402 | |||
| 49d608d357 | |||
| aa8a497abc | |||
| a5dc5cb3b6 | |||
| ea5dd63cd7 | |||
| 5ebd60b0bb | |||
| b2a64a8933 | |||
| 9fcea730b9 | |||
| 5b3fc10649 | |||
| d752cf5280 | |||
| 504c92301a | |||
| 67f6a9c43f | |||
| 5c0a61870f | |||
| cc27d73ca6 | |||
| 119cd9400a | |||
| a576c25124 | |||
| ebf56dcece | |||
| 0b00b01502 | |||
| 8d50de075d | |||
| 2df195734b | |||
| 5090341591 | |||
| 62172e0ee4 | |||
| ce2690e8b9 | |||
| 8e2f17f159 | |||
| 2d4a7e91cb | |||
| 2ad8e34971 | |||
| 7e81c09265 | |||
| 4d690a25a5 | |||
| 20be724115 | |||
| 4ef6196091 | |||
| bd8f311a96 | |||
| d422eff912 | |||
| ae6ccc3e5f | |||
| fed16f1f1d | |||
| 9450fb86d0 | |||
| a863bb2ab4 | |||
| a1846f34b5 | |||
| 62447258b2 | |||
| c931670e9d | |||
| 68b783909d | |||
| 0b5034759c | |||
| 19a63bda11 | |||
| a3cdb18a5c | |||
| a3fc36db19 | |||
| 42914aec85 | |||
| 8b17cc3ddd | |||
| 9e2e0ed921 | |||
| a632f2d8a6 | |||
| 0b973b73ab | |||
| 6018396be2 | |||
| ee6513b8ad | |||
| 37a311b0c5 | |||
| 11e812cb6b | |||
| 5097cdd9f4 | |||
| 2224c34aa8 | |||
| 2c5f6fcb04 | |||
| 7cd41dc920 | |||
| c5d2eb6bd2 | |||
| b650572426 | |||
| 7d84dd5ec8 | |||
| f0d10fc4ae | |||
| 1295b6a389 | |||
| 2ddcd58902 | |||
| 1ba334e983 | |||
| 80bc27426e | |||
| a82ab52919 | |||
| 092ba57977 | |||
| af847ddf78 | |||
| 2b068cce15 | |||
| 41fcbe746e | |||
| 24d2029955 |
+13
@@ -0,0 +1,13 @@
|
||||
# Godot 4 editor cache/import directory
|
||||
.godot/
|
||||
|
||||
# Godot export artifacts
|
||||
export/
|
||||
*.tmp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Godot UID files (regenerated from path)
|
||||
*.uid
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
63f7b34db8d8cdea90c76aacccf841ec
|
||||
::res://::1787275036
|
||||
export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
::res://addons/::1787272930
|
||||
::res://assets/::1787272930
|
||||
::res://backend/::1787272930
|
||||
alembic.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
docker-compose.yml::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
pytest.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
requirements.txt::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
::res://backend/alembic/::1787272930
|
||||
::res://backend/alembic/versions/::1787272930
|
||||
::res://backend/app/::1787272930
|
||||
::res://backend/app/models/::1787272930
|
||||
::res://backend/app/routers/::1787272930
|
||||
::res://backend/app/services/::1787272930
|
||||
::res://backend/tests/::1787272930
|
||||
::res://docs/::1787272930
|
||||
::res://docs/architecture/::1787272930
|
||||
avatar.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
content_moderation.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
editor.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
networking.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
pc2_test_level.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
scripting_sandbox.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
world.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
::res://docs/checkpoints/::1787272930
|
||||
PC1.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
::res://docs/planning/::1787272930
|
||||
client-server-wiring.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
economy.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
social.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
::res://docs/testing/::1787275036
|
||||
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
test_inventory.md::TextFile::-1::1787275036::0::1::::<><><>0<>0<><>::
|
||||
::res://scenes/::1787275036
|
||||
game.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scenes/worlds/test_level.tscn<>res://scenes/player.tscn
|
||||
main.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
|
||||
main_menu.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd
|
||||
player.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/player.gd
|
||||
server.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/server.gd
|
||||
server_player.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
|
||||
world_list.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/world_list.gd
|
||||
::res://scenes/worlds/::1787272930
|
||||
test_level.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::
|
||||
::res://scripts/::1787275098
|
||||
api_client.gd::GDScript::5412288119287126975::1787272930::0::1::::<>Node<><>0<>0<><>::
|
||||
chunk.gd::GDScript::7364239597441807588::1787272930::0::1::::Chunk<>Node3D<><>0<>0<><>::
|
||||
chunk_data_decoder.gd::GDScript::3922068956043581888::1787272930::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
|
||||
chunk_scene.gd::GDScript::9207602004456600331::1787272930::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
|
||||
chunk_stream_manager.gd::GDScript::2366645676568508746::1787272930::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
|
||||
dynamic_object_node.gd::GDScript::8863349716576659118::1787272930::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
|
||||
main_menu.gd::GDScript::2834562513277893052::1787272930::0::1::::<>Control<><>0<>0<><>::
|
||||
network_client.gd::GDScript::5408296350353838185::1787275098::0::1::::NetworkClient<>Node<><>0<>0<><>::
|
||||
network_player_proxy.gd::GDScript::4787436336771232797::1787275036::0::1::::<>Node<><>0<>0<><>::
|
||||
network_receiver_proxy.gd::GDScript::6742760436861380520::1787275036::0::1::::<>Node<><>0<>0<><>::
|
||||
player.gd::GDScript::4510795778937304264::1787275036::0::1::::Player<>CharacterBody3D<><>0<>0<><>::
|
||||
player_spawner.gd::GDScript::3637338339553764865::1787275036::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
|
||||
server.gd::GDScript::6336910781803684784::1787275036::0::1::::<>Node<><>0<>0<><>::
|
||||
smoke_test_api.gd::GDScript::1760923834350699002::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_auth.gd::GDScript::3027816090663230577::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_e2e.gd::GDScript::9086409960595132737::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_enter_world.gd::GDScript::4171815758762850910::1787275036::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_main_menu_back.gd::GDScript::4436937414562273157::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_main_menu_full_flow.gd::GDScript::1948803640558784862::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_main_menu_ui.gd::GDScript::396938597300585484::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_network_client.gd::GDScript::3109386529184855644::1787275067::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_player.gd::GDScript::3497042634752676337::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_test_level.gd::GDScript::2607555612829866949::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
smoke_test_world_list.gd::GDScript::4300757286283894147::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
|
||||
static_object_node.gd::GDScript::5276389741929225897::1787272930::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
|
||||
tile_def.gd::GDScript::2430528079284085109::1787272930::0::1::::TileDef<>RefCounted<><>0<>0<><>::
|
||||
tile_layer.gd::GDScript::4797906255131447844::1787272930::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
|
||||
tile_layer_node.gd::GDScript::9033225196818247616::1787272930::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
|
||||
world_list.gd::GDScript::6119591848847211153::1787275036::0::1::::<>Control<><>0<>0<><>::
|
||||
world_object.gd::GDScript::6429674491464200335::1787272930::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
|
||||
@@ -0,0 +1,5 @@
|
||||
[folding]
|
||||
|
||||
node_unfolds=[NodePath("."), PackedStringArray("Layout"), NodePath("CenterContainer"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/TitleLabel"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/CustomizeButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/PlayButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/QuitButton"), PackedStringArray("Layout")]
|
||||
resource_unfolds=[]
|
||||
nodes_folded=[]
|
||||
@@ -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=[]
|
||||
@@ -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.
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"assets": {
|
||||
"base_human_male_01": {
|
||||
"type": "base_body",
|
||||
"slot": "base_body",
|
||||
"path": "res://assets/avatars/base_human_male_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Human Male Base"
|
||||
},
|
||||
"head_cyber_01": {
|
||||
"type": "body_part",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Database (async driver)
|
||||
DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT / Auth
|
||||
ALGORITHM=RS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
# Directory where the RSA keypair is loaded/generated at runtime.
|
||||
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
|
||||
RSA_KEY_DIR=~/.agp/keys
|
||||
|
||||
# App
|
||||
APP_NAME=AGP Backend
|
||||
APP_VERSION=0.1.0
|
||||
DEBUG=true
|
||||
CORS_ORIGINS=http://localhost:8080,http://localhost:3000
|
||||
|
||||
# File uploads
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_UPLOAD_SIZE_MB=100
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -0,0 +1,42 @@
|
||||
# Alembic configuration file.
|
||||
# Run: alembic upgrade head (to apply migrations)
|
||||
# Run: alembic revision --autogenerate -m "description" (to generate migration)
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Alembic environment configuration."""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
from app.models import user, session, world, asset # noqa: F401
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = settings.DATABASE_URL
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations():
|
||||
"""Run migrations in 'online' mode with async engine."""
|
||||
connectable = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade database schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade database schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2024-01-01 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade database schema."""
|
||||
# Create ENUM types
|
||||
world_visibility = postgresql.ENUM(
|
||||
"public", "private", "friends_only", name="world_visibility"
|
||||
)
|
||||
world_visibility.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
asset_type = postgresql.ENUM(
|
||||
"image", "audio", "video", "document", "model_3d", "other", name="asset_type"
|
||||
)
|
||||
asset_type.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Create users table
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("username", sa.String(length=50), nullable=False),
|
||||
sa.Column("email", sa.String(length=120), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(length=255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
||||
|
||||
# Create sessions table
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("token", sa.String(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index(op.f("ix_sessions_token"), "sessions", ["token"], unique=True)
|
||||
|
||||
# Create worlds table
|
||||
op.create_table(
|
||||
"worlds",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"visibility",
|
||||
postgresql.ENUM(
|
||||
"public", "private", "friends_only", name="world_visibility",
|
||||
create_type=False,
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||
)
|
||||
op.create_index(op.f("ix_worlds_id"), "worlds", ["id"], unique=False)
|
||||
|
||||
# Create assets table
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"asset_type",
|
||||
postgresql.ENUM(
|
||||
"image", "audio", "video", "document", "model_3d", "other",
|
||||
name="asset_type", create_type=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("file_path", sa.String(length=512), nullable=False),
|
||||
sa.Column("file_size", sa.Integer(), nullable=True),
|
||||
sa.Column("mime_type", sa.String(length=128), nullable=True),
|
||||
sa.Column("metadata_json", sa.Text(), nullable=True),
|
||||
sa.Column("world_id", sa.Integer(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"]),
|
||||
)
|
||||
op.create_index(op.f("ix_assets_id"), "assets", ["id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade database schema."""
|
||||
op.drop_index(op.f("ix_assets_id"), table_name="assets")
|
||||
op.drop_table("assets")
|
||||
op.drop_index(op.f("ix_worlds_id"), table_name="worlds")
|
||||
op.drop_table("worlds")
|
||||
op.drop_index(op.f("ix_sessions_token"), table_name="sessions")
|
||||
op.drop_table("sessions")
|
||||
op.drop_index(op.f("ix_users_username"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_id"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||
op.drop_table("users")
|
||||
|
||||
# Drop ENUM types
|
||||
postgresql.ENUM(name="asset_type").drop(op.get_bind(), checkfirst=True)
|
||||
postgresql.ENUM(name="world_visibility").drop(op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add date_of_birth column to users table
|
||||
|
||||
Revision ID: 0002_add_date_of_birth_to_users
|
||||
Revises: 0001_initial
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0002_add_date_of_birth_to_users"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add date_of_birth column to users table."""
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("date_of_birth", sa.Date(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove date_of_birth column from users table."""
|
||||
op.drop_column("users", "date_of_birth")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add refresh_tokens table
|
||||
|
||||
Revision ID: 0003_add_refresh_tokens_table
|
||||
Revises: 0002_add_date_of_birth_to_users
|
||||
Create Date: 2025-07-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0003_add_refresh_tokens_table"
|
||||
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the refresh_tokens table."""
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the refresh_tokens table."""
|
||||
op.drop_table("refresh_tokens")
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Application configuration via pydantic-settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import (
|
||||
RSAPrivateKey,
|
||||
RSAPublicKey,
|
||||
generate_private_key,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
)
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
|
||||
"""Load existing RSA keys or generate a new pair.
|
||||
|
||||
Keys are stored as PEM files in *key_dir*. The directory is created
|
||||
if it does not exist. Files are named ``private.pem`` and
|
||||
``public.pem`` and are **not** committed to git.
|
||||
"""
|
||||
private_path = key_dir / "private.pem"
|
||||
public_path = key_dir / "public.pem"
|
||||
|
||||
if private_path.exists() and public_path.exists():
|
||||
private_pem = private_path.read_bytes()
|
||||
public_pem = public_path.read_bytes()
|
||||
else:
|
||||
# Generate a new 2048-bit RSA key pair
|
||||
private_key = generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.PKCS8,
|
||||
NoEncryption(),
|
||||
)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
Encoding.PEM,
|
||||
PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
key_dir.mkdir(parents=True, exist_ok=True)
|
||||
private_path.write_bytes(private_pem)
|
||||
public_path.write_bytes(public_pem)
|
||||
|
||||
return private_pem, public_pem
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# App
|
||||
APP_NAME: str = "AGP Backend"
|
||||
APP_VERSION: str = "0.1.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Database (async)
|
||||
DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT / Auth
|
||||
ALGORITHM: str = "RS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
|
||||
# RS256 key storage. Defaults to a directory outside the repo so the
|
||||
# generated keypair is never accidentally committed.
|
||||
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
||||
|
||||
# File uploads
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 100
|
||||
GLTF_MAX_FILE_SIZE_MB: int = 200
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
@property
|
||||
def rsa_key_dir(self) -> Path:
|
||||
return Path(self.RSA_KEY_DIR)
|
||||
|
||||
@property
|
||||
def private_pem(self) -> bytes:
|
||||
"""Return the private key PEM bytes (loads/generates on first call)."""
|
||||
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||
return private_pem
|
||||
|
||||
@property
|
||||
def public_pem(self) -> bytes:
|
||||
"""Return the public key PEM bytes (loads/generates on first call)."""
|
||||
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||
return public_pem
|
||||
|
||||
@property
|
||||
def private_key(self) -> RSAPrivateKey:
|
||||
"""Return the deserialized RSA private key object."""
|
||||
return load_pem_private_key(self.private_pem, password=None)
|
||||
|
||||
@property
|
||||
def public_key(self) -> RSAPublicKey:
|
||||
"""Return the deserialized RSA public key object."""
|
||||
return load_pem_public_key(self.public_pem)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached settings instance."""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Convenience singleton
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Async database engine and session factory."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency that yields an async database session.
|
||||
|
||||
Handlers are responsible for committing or rolling back their own
|
||||
transactions. This avoids the double-commit problem where the
|
||||
session is committed both here and in the handler.
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared FastAPI dependencies, including JWT-based auth."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> User:
|
||||
"""Resolve the JWT bearer token to a User, or raise 401."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
user_id_int = int(user_id)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id_int))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is inactive",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, users, worlds, assets
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown events."""
|
||||
# Startup: initialize database, connect to Redis, etc.
|
||||
yield
|
||||
# Shutdown: close connections, cleanup
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Agentic Game Platform - Backend API",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
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(assets.router, prefix="/api/assets", tags=["assets"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Simple health check endpoint."""
|
||||
return {"status": "ok", "app": settings.APP_NAME}
|
||||
@@ -0,0 +1,6 @@
|
||||
from app.models.user import User
|
||||
from app.models.session import Session
|
||||
from app.models.world import World
|
||||
from app.models.asset import Asset
|
||||
|
||||
__all__ = ["User", "Session", "World", "Asset"]
|
||||
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
|
||||
from sqlalchemy.dialects.postgresql import ENUM
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AssetType(str, enum.Enum):
|
||||
IMAGE = "image"
|
||||
AUDIO = "audio"
|
||||
VIDEO = "video"
|
||||
DOCUMENT = "document"
|
||||
MODEL_3D = "model_3d"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class Asset(Base):
|
||||
__tablename__ = "assets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
asset_type = Column(
|
||||
ENUM(AssetType, name="asset_type", create_type=False),
|
||||
nullable=False,
|
||||
)
|
||||
file_path = Column(String(512), nullable=False)
|
||||
file_size = Column(Integer, nullable=True)
|
||||
mime_type = Column(String(128), nullable=True)
|
||||
metadata_json = Column(Text, nullable=True)
|
||||
world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
is_public = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
owner = relationship("User", back_populates="assets")
|
||||
world = relationship("World", back_populates="assets")
|
||||
@@ -0,0 +1,41 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
token = Column(String, unique=True, index=True, nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
# RS256 JWTs are ~420+ chars; 512 is sufficient and matches the migration.
|
||||
token = Column(String(512), unique=True, index=True, nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default="true")
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="refresh_tokens")
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(120), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
date_of_birth = Column(Date, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||
worlds = relationship("World", back_populates="owner")
|
||||
assets = relationship("Asset", back_populates="owner")
|
||||
|
||||
@property
|
||||
def is_adult(self) -> bool:
|
||||
"""Return True if the user is 18 or older based on date_of_birth."""
|
||||
if self.date_of_birth is None:
|
||||
return False
|
||||
today = date.today()
|
||||
age = today.year - self.date_of_birth.year
|
||||
# Adjust if birthday hasn't occurred yet this year
|
||||
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
|
||||
age -= 1
|
||||
return age >= 18
|
||||
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import ENUM
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WorldVisibility(enum.Enum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
FRIENDS_ONLY = "friends_only"
|
||||
|
||||
|
||||
class World(Base):
|
||||
__tablename__ = "worlds"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
visibility = Column(
|
||||
ENUM(
|
||||
WorldVisibility,
|
||||
name="world_visibility",
|
||||
create_type=False,
|
||||
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||
),
|
||||
default=WorldVisibility.PUBLIC,
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
owner = relationship("User", back_populates="worlds")
|
||||
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Asset management router."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.asset import Asset, AssetType
|
||||
from app.models.world import World
|
||||
from app.services import gltf as gltf_service
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Pydantic schemas ---
|
||||
|
||||
class AssetCreate(BaseModel):
|
||||
"""Schema for creating an asset reference."""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
asset_type: AssetType
|
||||
world_id: Optional[int] = None
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
"""Schema for asset responses."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
asset_type: AssetType
|
||||
file_path: str
|
||||
file_size: Optional[int]
|
||||
mime_type: Optional[str]
|
||||
world_id: Optional[int]
|
||||
owner_id: int
|
||||
is_public: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssetUpdate(BaseModel):
|
||||
"""Schema for updating an asset."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Strip path components and unsafe characters from a filename."""
|
||||
# Remove any directory components (path traversal protection).
|
||||
base = os.path.basename(filename.replace("\\", "/"))
|
||||
# Keep only safe characters.
|
||||
safe = "".join(c for c in base if c.isalnum() or c in "._-")
|
||||
return safe or "file"
|
||||
|
||||
|
||||
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
|
||||
"""Detect the asset type from filename and content."""
|
||||
if filename and gltf_service.looks_like_gltf(content, filename):
|
||||
return AssetType.MODEL_3D
|
||||
# Fallback: let the caller decide based on mime_type or default.
|
||||
return AssetType.OTHER
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
||||
async def create_asset(
|
||||
payload: AssetCreate,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new asset reference."""
|
||||
# Verify world ownership if world_id is provided
|
||||
if payload.world_id is not None:
|
||||
world = await db.get(World, payload.world_id)
|
||||
if world is None:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
if world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
||||
|
||||
asset = Asset(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
asset_type=payload.asset_type,
|
||||
file_path="", # Will be set after upload
|
||||
owner_id=current_user.id,
|
||||
world_id=payload.world_id,
|
||||
is_public=False,
|
||||
)
|
||||
db.add(asset)
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.get("/", response_model=list[AssetResponse])
|
||||
async def list_assets(
|
||||
current_user: CurrentUser,
|
||||
world_id: Optional[int] = Query(None, description="Filter by world"),
|
||||
asset_type: Optional[AssetType] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List assets with optional filters."""
|
||||
stmt = select(Asset).where(Asset.owner_id == current_user.id)
|
||||
|
||||
if world_id is not None:
|
||||
stmt = stmt.where(Asset.world_id == world_id)
|
||||
if asset_type is not None:
|
||||
stmt = stmt.where(Asset.asset_type == asset_type)
|
||||
|
||||
stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset)
|
||||
result = await db.execute(stmt)
|
||||
assets = result.scalars().all()
|
||||
return assets
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
async def get_asset(
|
||||
asset_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single asset by ID."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
return asset
|
||||
|
||||
|
||||
@router.patch("/{asset_id}", response_model=AssetResponse)
|
||||
async def update_asset(
|
||||
asset_id: int,
|
||||
payload: AssetUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an asset."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(asset, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=204)
|
||||
async def delete_asset(
|
||||
asset_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete an asset."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
await db.delete(asset)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{asset_id}/upload", response_model=AssetResponse)
|
||||
async def upload_asset_file(
|
||||
asset_id: int,
|
||||
current_user: CurrentUser,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a file for an existing asset."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or asset.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
# Sanitize filename to prevent path traversal.
|
||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
||||
|
||||
# Read the file content.
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Determine which size limit to apply.
|
||||
if gltf_service.looks_like_gltf(content, file.filename):
|
||||
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
else:
|
||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
|
||||
if file_size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes",
|
||||
)
|
||||
|
||||
# Write the file to the upload directory.
|
||||
upload_dir = Path(settings.UPLOAD_DIR)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
stored_name = f"{asset_id}_{safe_filename}"
|
||||
file_path = upload_dir / stored_name
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Detect asset type from content/filename.
|
||||
detected_type = _detect_asset_type(file.filename, content)
|
||||
|
||||
# Update asset record.
|
||||
asset.file_path = str(file_path)
|
||||
asset.mime_type = file.content_type
|
||||
asset.file_size = file_size
|
||||
|
||||
# If this is a glTF file, extract and store metadata.
|
||||
if detected_type == AssetType.MODEL_3D:
|
||||
try:
|
||||
metadata = gltf_service.extract_gltf_metadata(content)
|
||||
asset.metadata_json = json.dumps(metadata)
|
||||
# Also set the asset type to model_3d if it was auto-detected.
|
||||
if asset.asset_type == AssetType.OTHER:
|
||||
asset.asset_type = AssetType.MODEL_3D
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid glTF file: {exc}",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Authentication router - login, register, token refresh."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.session import RefreshToken
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Pydantic schemas ---
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
access_token_expires_minutes: int
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
over_18: bool # Simple boolean confirmation instead of date_of_birth
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
date_of_birth: str | None = None
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _create_access_token(data: dict) -> str:
|
||||
"""Create a signed access token using RS256.
|
||||
|
||||
Includes a unique ``jti`` claim so that concurrent/same-second calls
|
||||
(e.g. back-to-back /refresh requests) still produce distinct tokens —
|
||||
without it, two tokens minted for the same user within the same
|
||||
second are byte-identical since ``sub``/``exp`` are the only claims.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
to_encode["jti"] = str(uuid.uuid4())
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def _create_refresh_token(data: dict) -> str:
|
||||
"""Create a signed refresh token using RS256.
|
||||
|
||||
Includes a unique ``jti`` claim so that concurrent calls produce
|
||||
distinct tokens even when issued within the same second.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
to_encode["jti"] = str(uuid.uuid4())
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
"""Calculate age in years from a birth date.
|
||||
|
||||
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.
|
||||
today: The reference date. Defaults to today's date (UTC).
|
||||
|
||||
Returns:
|
||||
Age in whole years as of the reference date.
|
||||
"""
|
||||
if today is None:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
age = today.year - birth_date.year
|
||||
# Adjust if birthday hasn't occurred yet this year
|
||||
if (today.month, today.day) < (birth_date.month, birth_date.day):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
|
||||
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||
"""Create and persist a refresh token for the given user."""
|
||||
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
|
||||
refresh_token_record = RefreshToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
token=refresh_token_value,
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
)
|
||||
db.add(refresh_token_record)
|
||||
return refresh_token_value
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user and return access + refresh tokens.
|
||||
|
||||
Requires over_18 boolean confirmation; rejects users who are not 18+.
|
||||
"""
|
||||
if not req.over_18:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Must be 18 or older to register",
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
result = await db.execute(select(User).where(User.username == req.username))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
hashed_password=hash_password(req.password),
|
||||
date_of_birth=None, # No longer collecting DOB
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # flush to get user.id before creating refresh token
|
||||
await db.refresh(user)
|
||||
|
||||
# Create access token
|
||||
access_token = _create_access_token({"sub": str(user.id)})
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate user and return access + refresh tokens.
|
||||
|
||||
Accepts form-encoded credentials (username/password) as expected by
|
||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.username == form.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(form.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is inactive",
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = _create_access_token({"sub": str(user.id)})
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange a valid refresh token for a new access token.
|
||||
|
||||
Validates the refresh token against the database:
|
||||
- Must exist in the refresh_tokens table
|
||||
- Must not be expired (expires_at > now)
|
||||
- Must be active (is_active == true)
|
||||
|
||||
On success, returns a new access token. The refresh token itself is
|
||||
NOT rotated (the client keeps using the same refresh token).
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
# Step 1: Decode and validate the JWT signature/claims
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
req.refresh_token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user_id_str: str | None = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
# Step 2: Look up the refresh token in the database
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token == req.refresh_token,
|
||||
RefreshToken.user_id == user_id,
|
||||
)
|
||||
)
|
||||
stored_token = result.scalar_one_or_none()
|
||||
|
||||
if stored_token is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 3: Check if the token is expired
|
||||
if datetime.now(timezone.utc) >= stored_token.expires_at:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 4: Check if the token is active
|
||||
if not stored_token.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 5: Issue a new access token
|
||||
access_token = _create_access_token({"sub": str(user_id)})
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=req.refresh_token, # Return the same refresh token
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user(current_user: CurrentUser):
|
||||
"""Get current user profile from the JWT token."""
|
||||
return current_user
|
||||
@@ -0,0 +1,106 @@
|
||||
"""User management router."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── Pydantic schemas ────────────────────────────────────────────────────────
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_profile(
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""Get the currently authenticated user's profile."""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get a user by ID. Only the owner can view their own profile."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Only the owner may view their own profile.
|
||||
if user.id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view this user")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_update: UserUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Update a user. Only the owner can update their own account."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Only the owner may update their own account.
|
||||
if user.id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this user")
|
||||
|
||||
if user_update.email is not None:
|
||||
user.email = user_update.email
|
||||
if user_update.is_active is not None:
|
||||
user.is_active = user_update.is_active
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Delete a user. Only the owner can delete their own account."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Only the owner may delete their own account.
|
||||
if user.id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this user")
|
||||
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Worlds router - CRUD for game worlds."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.world import World, WorldVisibility
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
# --- Pydantic schemas ---
|
||||
|
||||
class WorldCreate(BaseModel):
|
||||
"""Schema for creating a world."""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
||||
|
||||
|
||||
class WorldUpdate(BaseModel):
|
||||
"""Schema for updating a world."""
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
visibility: WorldVisibility | None = None
|
||||
|
||||
|
||||
class WorldResponse(BaseModel):
|
||||
"""Schema for world responses."""
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
visibility: WorldVisibility
|
||||
owner_id: int
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/", response_model=list[WorldResponse])
|
||||
async def list_worlds(
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
visibility: WorldVisibility | None = None,
|
||||
):
|
||||
"""List worlds visible to the current user.
|
||||
|
||||
PUBLIC worlds are visible to everyone.
|
||||
PRIVATE/FRIENDS_ONLY worlds are visible to their owner.
|
||||
"""
|
||||
query = select(World).options(selectinload(World.owner))
|
||||
|
||||
# Filter by visibility if specified
|
||||
if visibility:
|
||||
query = query.where(World.visibility == visibility)
|
||||
if visibility != WorldVisibility.PUBLIC:
|
||||
# Requesting a non-public visibility explicitly must not bypass
|
||||
# ownership — otherwise any authenticated user could list every
|
||||
# PRIVATE/FRIENDS_ONLY world in the system just by passing the
|
||||
# filter, defeating the point of the check below.
|
||||
query = query.where(World.owner_id == current_user.id)
|
||||
else:
|
||||
# Show PUBLIC worlds + owner's own worlds
|
||||
query = query.where(
|
||||
(World.visibility == WorldVisibility.PUBLIC)
|
||||
| (World.owner_id == current_user.id)
|
||||
)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
worlds = result.scalars().all()
|
||||
return worlds
|
||||
|
||||
|
||||
@router.get("/{world_id}", response_model=WorldResponse)
|
||||
async def get_world(
|
||||
world_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Get a single world by ID, respecting visibility."""
|
||||
result = await db.execute(
|
||||
select(World).where(World.id == world_id).options(selectinload(World.owner))
|
||||
)
|
||||
world = result.scalar_one_or_none()
|
||||
if not world:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
|
||||
# Allow access if world is PUBLIC or owned by current user
|
||||
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
|
||||
return world
|
||||
|
||||
|
||||
@router.post("/", response_model=WorldResponse, status_code=201)
|
||||
async def create_world(
|
||||
world_data: WorldCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Create a new world."""
|
||||
world = World(
|
||||
name=world_data.name,
|
||||
description=world_data.description,
|
||||
owner_id=current_user.id,
|
||||
visibility=world_data.visibility,
|
||||
)
|
||||
db.add(world)
|
||||
await db.commit()
|
||||
await db.refresh(world)
|
||||
return world
|
||||
|
||||
|
||||
@router.put("/{world_id}", response_model=WorldResponse)
|
||||
async def update_world(
|
||||
world_id: int,
|
||||
world_data: WorldUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Update an existing world."""
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if not world:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
|
||||
if world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
||||
|
||||
update_data = world_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(world, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(world)
|
||||
return world
|
||||
|
||||
|
||||
@router.delete("/{world_id}", status_code=204)
|
||||
async def delete_world(
|
||||
world_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Delete a world."""
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if not world:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
|
||||
if world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
||||
|
||||
await db.delete(world)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Lightweight glTF / GLB metadata extraction.
|
||||
|
||||
This module intentionally avoids heavyweight dependencies such as trimesh or
|
||||
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
|
||||
vertex counts, animation presence, texture references, and the bounding box)
|
||||
a small amount of glTF-specific parsing is sufficient and keeps the backend
|
||||
dependency-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Any, Optional
|
||||
|
||||
# GLB binary header magic bytes ("glTF").
|
||||
_GLB_MAGIC = b"glTF"
|
||||
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
|
||||
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
|
||||
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
|
||||
|
||||
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
|
||||
# but be lenient with null bytes and other whitespace that real exporters emit.
|
||||
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
|
||||
|
||||
|
||||
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
|
||||
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
|
||||
if content.startswith(_GLB_MAGIC):
|
||||
return True
|
||||
|
||||
if filename:
|
||||
lowered = filename.lower()
|
||||
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
|
||||
return True
|
||||
|
||||
# A bare JSON buffer (e.g. uploaded without an extension) that has the
|
||||
# glTF asset marker is also a glTF file.
|
||||
stripped = content.lstrip()
|
||||
if stripped.startswith(b"{"):
|
||||
try:
|
||||
payload = json.loads(stripped.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _parse_glb(content: bytes) -> dict[str, Any]:
|
||||
"""Parse the JSON chunk of a GLB binary container."""
|
||||
if len(content) < 12:
|
||||
raise ValueError("GLB file is too short")
|
||||
|
||||
magic, _version, _length = struct.unpack_from("<III", content, 0)
|
||||
if magic != _GLB_MAGIC_INT:
|
||||
raise ValueError("invalid GLB magic")
|
||||
|
||||
offset = 12
|
||||
json_chunk: Optional[dict[str, Any]] = None
|
||||
|
||||
while offset + 8 <= len(content):
|
||||
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
|
||||
offset += 8
|
||||
chunk_end = offset + chunk_length
|
||||
if chunk_end > len(content):
|
||||
raise ValueError("GLB chunk overruns file boundary")
|
||||
|
||||
chunk_data = content[offset:chunk_end]
|
||||
if chunk_type == _GLB_JSON_CHUNK_TYPE:
|
||||
try:
|
||||
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
|
||||
|
||||
offset = chunk_end
|
||||
|
||||
if json_chunk is None:
|
||||
raise ValueError("GLB file does not contain a JSON chunk")
|
||||
|
||||
return json_chunk
|
||||
|
||||
|
||||
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
|
||||
"""Parse a plain .gltf JSON file."""
|
||||
stripped = content.lstrip()
|
||||
try:
|
||||
payload = json.loads(stripped.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"invalid glTF JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("glTF JSON root must be an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
|
||||
"""Return POSITION accessor counts for every mesh primitive."""
|
||||
counts: list[int] = []
|
||||
accessors = gltf.get("accessors", [])
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attributes = primitive.get("attributes", {})
|
||||
position_index = attributes.get("POSITION")
|
||||
if position_index is None:
|
||||
continue
|
||||
try:
|
||||
accessor = accessors[int(position_index)]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
count = accessor.get("count")
|
||||
if isinstance(count, int):
|
||||
counts.append(count)
|
||||
return counts
|
||||
|
||||
|
||||
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
|
||||
"""Return min/max bounds for every POSITION accessor that declares them."""
|
||||
bounds: list[list[float]] = []
|
||||
accessors = gltf.get("accessors", [])
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attributes = primitive.get("attributes", {})
|
||||
position_index = attributes.get("POSITION")
|
||||
if position_index is None:
|
||||
continue
|
||||
try:
|
||||
accessor = accessors[int(position_index)]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
minimum = accessor.get("min")
|
||||
maximum = accessor.get("max")
|
||||
if isinstance(minimum, list) and isinstance(maximum, list):
|
||||
bounds.append([float(x) for x in minimum])
|
||||
bounds.append([float(x) for x in maximum])
|
||||
return bounds
|
||||
|
||||
|
||||
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
|
||||
"""Collect external texture source references from the image array."""
|
||||
refs: list[str] = []
|
||||
for index, image in enumerate(gltf.get("images", [])):
|
||||
uri = image.get("uri")
|
||||
if isinstance(uri, str) and not uri.startswith("data:"):
|
||||
refs.append(uri)
|
||||
continue
|
||||
|
||||
name = image.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
refs.append(name)
|
||||
continue
|
||||
|
||||
if isinstance(uri, str):
|
||||
# Embedded data URI; keep a stable, non-huge reference.
|
||||
refs.append(f"data:{index}")
|
||||
continue
|
||||
|
||||
buffer_view = image.get("bufferView")
|
||||
if buffer_view is not None:
|
||||
refs.append(f"bufferView:{buffer_view}")
|
||||
continue
|
||||
|
||||
refs.append(f"image:{index}")
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
|
||||
"""Extract upload-relevant metadata from glTF/GLB bytes.
|
||||
|
||||
Raises ValueError for malformed glTF content. The returned dict contains:
|
||||
mesh_count, vertex_count, has_animation, texture_refs,
|
||||
bounding_box_min, bounding_box_max.
|
||||
"""
|
||||
if content.startswith(_GLB_MAGIC):
|
||||
gltf = _parse_glb(content)
|
||||
else:
|
||||
gltf = _parse_gltf_json(content)
|
||||
|
||||
position_counts = _position_accessor_counts(gltf)
|
||||
bounds = _position_bounds(gltf)
|
||||
|
||||
if bounds:
|
||||
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
|
||||
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
|
||||
else:
|
||||
bounding_box_min = [0.0, 0.0, 0.0]
|
||||
bounding_box_max = [0.0, 0.0, 0.0]
|
||||
|
||||
return {
|
||||
"mesh_count": len(gltf.get("meshes", [])),
|
||||
"vertex_count": sum(position_counts),
|
||||
"has_animation": bool(gltf.get("animations")),
|
||||
"texture_refs": _texture_refs(gltf),
|
||||
"bounding_box_min": bounding_box_min,
|
||||
"bounding_box_max": bounding_box_max,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: agp_user
|
||||
POSTGRES_PASSWORD: agp_password
|
||||
POSTGRES_DB: agp_db
|
||||
ports:
|
||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U agp_user -d agp_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "${REDIS_HOST_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,25 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
psycopg2-binary==2.9.10
|
||||
asyncpg==0.31.0
|
||||
redis==5.2.1
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
# passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
|
||||
# backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed --
|
||||
# hashing/verifying a password then raises "password cannot be longer than
|
||||
# 72 bytes" (a misleading downstream symptom of the real AttributeError:
|
||||
# module 'bcrypt' has no attribute '__about__') on every call. Without this
|
||||
# pin, `pip install -r requirements.txt` silently grabs whatever the latest
|
||||
# bcrypt is and breaks register/login outright. Pin below 4.1 explicitly.
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.20
|
||||
email-validator==2.2.0
|
||||
httpx==0.28.1
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
pytest-cov==6.0.0
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
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.
|
||||
|
||||
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
|
||||
keys, but the tables are listed explicitly for clarity.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
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."""
|
||||
|
||||
async def _truncate() -> None:
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
|
||||
)
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_truncate())
|
||||
yield
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the 18+ age gate on user registration."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.routers.auth import calculate_age
|
||||
|
||||
|
||||
class TestCalculateAge:
|
||||
"""Unit tests for the calculate_age helper."""
|
||||
|
||||
def test_exactly_18(self):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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_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_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 TestRegisterRequestOver18Field:
|
||||
"""Tests for RegisterRequest's over_18 field.
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Integration tests for the auth endpoints (register + login).
|
||||
|
||||
These tests hit the real FastAPI app against the real configured database
|
||||
(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated
|
||||
in-memory DB), so they exercise the full stack: Pydantic validation → DB →
|
||||
JWT → response. Requires migrations already applied (alembic upgrade head)
|
||||
before running.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
from app.routers.auth import pwd_context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test.
|
||||
|
||||
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
||||
(sync) TestClient runs each request through anyio's blocking-portal
|
||||
machinery, which can hand different requests different event loops.
|
||||
asyncpg connections are bound to the loop they were created on, so
|
||||
reusing one from the shared engine's connection pool across requests
|
||||
raises "cannot perform operation: another operation is in progress" the
|
||||
moment a test makes more than one request (register-then-login,
|
||||
register-twice, etc.) -- confirmed this is the actual cause, not a bug
|
||||
in the endpoints themselves. NullPool sidesteps it: every checkout is a
|
||||
brand-new connection, never handed to a different loop than the one
|
||||
that opened it.
|
||||
"""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegister:
|
||||
"""Tests for POST /api/auth/register."""
|
||||
|
||||
def test_register_success(self):
|
||||
"""A valid registration returns 201 with a token."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
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)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "kid",
|
||||
"email": "kid@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": False,
|
||||
},
|
||||
)
|
||||
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."""
|
||||
client = TestClient(app)
|
||||
# First registration
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "dup1@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
# Second registration with same username, different email
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "dup2@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Username already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_rejects_duplicate_email(self):
|
||||
"""Registering the same email twice returns 409."""
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user1",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user2",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
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",
|
||||
json={
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLogin:
|
||||
"""Tests for POST /api/auth/login."""
|
||||
|
||||
def _create_user(self, username: str, password: str, email: str = None):
|
||||
"""Helper to create a user via register."""
|
||||
client = TestClient(app)
|
||||
if email is None:
|
||||
email = f"{username}@example.com"
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def test_login_success(self):
|
||||
"""Correct credentials return a token."""
|
||||
self._create_user("loginuser", "correctpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "loginuser", "password": "correctpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_login_wrong_password(self):
|
||||
"""Wrong password returns 401."""
|
||||
# Distinct username from test_login_success -- there's no per-test
|
||||
# DB rollback/isolation here (real Postgres via the app's own
|
||||
# dependency-overridden session), so reusing "loginuser" collided
|
||||
# with the user that test already created (409 on register instead
|
||||
# of the intended 401-on-login assertion this test is actually for).
|
||||
self._create_user("loginuser_wrongpass", "correctpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "loginuser_wrongpass", "password": "wrongpass"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect username or password" in resp.json()["detail"]
|
||||
|
||||
def test_login_nonexistent_user(self):
|
||||
"""Non-existent user returns 401 (not 404)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "nobody", "password": "any"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect username or password" in resp.json()["detail"]
|
||||
|
||||
def test_login_accepts_form_encoded(self):
|
||||
"""Login accepts application/x-www-form-urlencoded (OAuth2)."""
|
||||
self._create_user("formuser", "formpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "formuser", "password": "formpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.json()
|
||||
|
||||
def test_login_token_is_valid_jwt(self):
|
||||
"""The returned token is a valid JWT that can be decoded."""
|
||||
self._create_user("jwttoken", "jwt123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "jwttoken", "password": "jwt123"},
|
||||
)
|
||||
token = resp.json()["access_token"]
|
||||
# Verify it's a 3-part JWT
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for expired-token rejection across auth endpoints.
|
||||
|
||||
Covers two distinct expiry paths:
|
||||
|
||||
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
|
||||
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
|
||||
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
|
||||
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
|
||||
signature is still valid.
|
||||
|
||||
Requires migrations already applied (alembic upgrade head) before running.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from jose import jwt
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
from app.models.session import RefreshToken
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test."""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mint_access_token(exp: datetime) -> str:
|
||||
"""Mint an RS256 access token with an explicit ``exp`` claim."""
|
||||
return jwt.encode(
|
||||
{"sub": "1", "exp": exp},
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def _expire_refresh_token_in_db(token: str) -> None:
|
||||
"""Set the persisted refresh token's ``expires_at`` into the past."""
|
||||
|
||||
async def _do():
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
from sqlalchemy import update
|
||||
|
||||
await session.execute(
|
||||
update(RefreshToken)
|
||||
.where(RefreshToken.token == token)
|
||||
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_do())
|
||||
|
||||
|
||||
def _register_user(client: TestClient, username: str) -> str:
|
||||
"""Register a user and return its refresh token."""
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["refresh_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expired access token (GET /api/auth/me)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpiredAccessToken:
|
||||
def test_expired_access_token_is_rejected_by_me(self):
|
||||
"""An access JWT with a past exp claim returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
expired_token = _mint_access_token(
|
||||
datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
)
|
||||
resp = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Could not validate credentials" in resp.json()["detail"]
|
||||
|
||||
def test_garbage_access_token_is_rejected_by_me(self):
|
||||
"""A non-JWT bearer token returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer not-a-real-jwt"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_bearer_token_returns_401(self):
|
||||
"""No Authorization header at all returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expired refresh token (POST /api/auth/refresh)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpiredRefreshToken:
|
||||
def test_expired_refresh_token_is_rejected(self):
|
||||
"""A refresh token whose DB expires_at is in the past returns 401."""
|
||||
client = TestClient(app)
|
||||
refresh_token = _register_user(client, "expired_rt_user")
|
||||
|
||||
# Force the persisted record to be expired while its JWT signature
|
||||
# is still valid.
|
||||
_expire_refresh_token_in_db(refresh_token)
|
||||
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
|
||||
def test_expired_refresh_token_jwt_is_rejected(self):
|
||||
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
|
||||
client = TestClient(app)
|
||||
expired_jwt = jwt.encode(
|
||||
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": expired_jwt},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for the lightweight glTF metadata extraction service."""
|
||||
|
||||
import json
|
||||
import struct
|
||||
import pytest
|
||||
|
||||
from app.services.gltf import (
|
||||
extract_gltf_metadata,
|
||||
looks_like_gltf,
|
||||
_parse_glb,
|
||||
_parse_gltf_json,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_gltf_json(
|
||||
meshes: int = 1,
|
||||
vertices_per_mesh: int = 24,
|
||||
has_animations: bool = False,
|
||||
images: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Build a minimal glTF JSON structure."""
|
||||
accessors = []
|
||||
meshes_data = []
|
||||
for i in range(meshes):
|
||||
acc_index = len(accessors)
|
||||
accessors.append({
|
||||
"bufferView": 0,
|
||||
"componentType": 5126, # FLOAT
|
||||
"count": vertices_per_mesh,
|
||||
"type": "VEC3",
|
||||
"max": [1.0, 1.0, 1.0],
|
||||
"min": [-1.0, -1.0, -1.0],
|
||||
})
|
||||
meshes_data.append({
|
||||
"primitives": [{
|
||||
"attributes": {"POSITION": acc_index},
|
||||
}],
|
||||
})
|
||||
gltf = {
|
||||
"asset": {"version": "2.0"},
|
||||
"accessors": accessors,
|
||||
"meshes": meshes_data,
|
||||
}
|
||||
if has_animations:
|
||||
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
|
||||
if images:
|
||||
gltf["images"] = images
|
||||
return gltf
|
||||
|
||||
|
||||
def _make_glb(gltf_json: dict) -> bytes:
|
||||
"""Wrap *gltf_json* in a minimal GLB binary container."""
|
||||
json_bytes = json.dumps(gltf_json).encode("utf-8")
|
||||
json_length = len(json_bytes)
|
||||
# Pad JSON chunk to 4-byte boundary.
|
||||
json_padding = (4 - json_length % 4) % 4
|
||||
json_chunk = json_bytes + b"\x00" * json_padding
|
||||
json_chunk_type = 0x4E4F534A # "JSON"
|
||||
# No BIN chunk.
|
||||
bin_length = 0
|
||||
bin_chunk_type = 0x004E4942
|
||||
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
|
||||
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
|
||||
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
|
||||
if bin_length > 0:
|
||||
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
|
||||
else:
|
||||
bin_header = b""
|
||||
return header + json_header + json_chunk + bin_header
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# looks_like_gltf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLooksLikeGltf:
|
||||
def test_glb_magic(self):
|
||||
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
|
||||
|
||||
def test_gltf_extension(self):
|
||||
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
|
||||
|
||||
def test_glb_extension(self):
|
||||
assert looks_like_gltf(b"some binary", "model.glb") is True
|
||||
|
||||
def test_json_with_asset_version(self):
|
||||
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
|
||||
assert looks_like_gltf(payload) is True
|
||||
|
||||
def test_plain_json_no_asset(self):
|
||||
payload = json.dumps({"foo": "bar"}).encode()
|
||||
assert looks_like_gltf(payload) is False
|
||||
|
||||
def test_non_json_bytes(self):
|
||||
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_glb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseGlb:
|
||||
def test_parses_valid_glb(self):
|
||||
gltf_json = _make_gltf_json()
|
||||
glb = _make_glb(gltf_json)
|
||||
result = _parse_glb(glb)
|
||||
assert result["asset"]["version"] == "2.0"
|
||||
assert len(result["meshes"]) == 1
|
||||
|
||||
def test_rejects_too_short(self):
|
||||
with pytest.raises(ValueError, match="too short"):
|
||||
_parse_glb(b"glTF\x00\x00")
|
||||
|
||||
def test_rejects_invalid_magic(self):
|
||||
with pytest.raises(ValueError, match="invalid GLB magic"):
|
||||
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_gltf_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseGltfJson:
|
||||
def test_parses_valid_gltf(self):
|
||||
gltf_json = _make_gltf_json()
|
||||
result = _parse_gltf_json(json.dumps(gltf_json).encode())
|
||||
assert result["asset"]["version"] == "2.0"
|
||||
|
||||
def test_rejects_non_object(self):
|
||||
with pytest.raises(ValueError, match="root must be an object"):
|
||||
_parse_gltf_json(b"[1, 2, 3]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_gltf_metadata (GLB path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataGlb:
|
||||
def test_basic_glb(self):
|
||||
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 1
|
||||
assert meta["vertex_count"] == 24
|
||||
assert meta["has_animation"] is False
|
||||
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
|
||||
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
|
||||
assert meta["texture_refs"] == []
|
||||
|
||||
def test_multiple_meshes(self):
|
||||
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 3
|
||||
assert meta["vertex_count"] == 36
|
||||
|
||||
def test_with_animation(self):
|
||||
gltf_json = _make_gltf_json(has_animations=True)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["has_animation"] is True
|
||||
|
||||
def test_with_external_texture(self):
|
||||
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["texture_refs"] == ["textures/rock.png"]
|
||||
|
||||
def test_with_named_texture(self):
|
||||
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["texture_refs"] == ["my_texture"]
|
||||
|
||||
def test_empty_meshes(self):
|
||||
gltf_json = _make_gltf_json(meshes=0)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 0
|
||||
assert meta["vertex_count"] == 0
|
||||
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
|
||||
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_gltf_metadata (plain .gltf JSON path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataGltfJson:
|
||||
def test_plain_gltf(self):
|
||||
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
|
||||
raw = json.dumps(gltf_json).encode()
|
||||
meta = extract_gltf_metadata(raw)
|
||||
assert meta["mesh_count"] == 2
|
||||
assert meta["vertex_count"] == 16
|
||||
assert meta["has_animation"] is False
|
||||
|
||||
def test_plain_gltf_with_animation(self):
|
||||
gltf_json = _make_gltf_json(has_animations=True)
|
||||
raw = json.dumps(gltf_json).encode()
|
||||
meta = extract_gltf_metadata(raw)
|
||||
assert meta["has_animation"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataErrors:
|
||||
def test_invalid_glb(self):
|
||||
with pytest.raises(ValueError):
|
||||
extract_gltf_metadata(b"not a glb")
|
||||
|
||||
def test_invalid_gltf_json(self):
|
||||
with pytest.raises(ValueError):
|
||||
_parse_gltf_json(b"[1, 2, 3]")
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the POST /api/auth/refresh endpoint.
|
||||
|
||||
These tests exercise the full stack: register → get refresh token →
|
||||
refresh → new access token. Requires migrations already applied
|
||||
(alembic upgrade head) before running.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test."""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refresh endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRefresh:
|
||||
"""Tests for POST /api/auth/refresh."""
|
||||
|
||||
def _register_user(self, username: str, password: str, email: str = None):
|
||||
"""Helper to register a user and return the refresh token."""
|
||||
client = TestClient(app)
|
||||
if email is None:
|
||||
email = f"{username}@example.com"
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["refresh_token"]
|
||||
|
||||
def test_refresh_success(self):
|
||||
"""A valid refresh token returns a new access token."""
|
||||
refresh_token = self._register_user("refep_refreshuser", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
# The refresh token in the response is the same one we sent
|
||||
assert data["refresh_token"] == refresh_token
|
||||
|
||||
def test_refresh_returns_different_access_token(self):
|
||||
"""Refreshing produces a new access token (different from any previous)."""
|
||||
refresh_token = self._register_user("refep_refreshtoken", "secret123")
|
||||
client = TestClient(app)
|
||||
|
||||
# First refresh
|
||||
resp1 = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
access_token_1 = resp1.json()["access_token"]
|
||||
|
||||
# Second refresh
|
||||
resp2 = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
access_token_2 = resp2.json()["access_token"]
|
||||
|
||||
# Each refresh should produce a new access token
|
||||
assert access_token_1 != access_token_2
|
||||
|
||||
def test_refresh_invalid_token(self):
|
||||
"""An invalid (garbage) refresh token returns 401."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": "not.a.jwt.token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
|
||||
def test_refresh_empty_body(self):
|
||||
"""Missing refresh_token field returns 422 validation error."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_refresh_missing_field(self):
|
||||
"""No body at all returns 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
content="{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_refresh_with_login_token(self):
|
||||
"""A refresh token obtained from login also works for /refresh."""
|
||||
client = TestClient(app)
|
||||
# Register first
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "refep_loginrefuser",
|
||||
"email": "loginref@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
# Login to get a refresh token
|
||||
login_resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "refep_loginrefuser", "password": "secret123"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
refresh_token = login_resp.json()["refresh_token"]
|
||||
|
||||
# Use it to refresh
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.json()
|
||||
|
||||
def test_refresh_access_token_is_valid_jwt(self):
|
||||
"""The access token returned by /refresh is a valid JWT."""
|
||||
from jose import jwt
|
||||
|
||||
refresh_token = self._register_user("refep_jwttoken", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
access_token = resp.json()["access_token"]
|
||||
|
||||
# Verify it's a 3-part JWT
|
||||
parts = access_token.split(".")
|
||||
assert len(parts) == 3
|
||||
|
||||
# Verify it decodes with the public key
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert "sub" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
def test_refresh_expires_minutes_in_response(self):
|
||||
"""The response includes access_token_expires_minutes."""
|
||||
refresh_token = self._register_user("refep_expiretest", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token_expires_minutes" in resp.json()
|
||||
assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for refresh token uniqueness (jti claim) and model column length."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.routers.auth import _create_refresh_token
|
||||
from app.models.session import RefreshToken
|
||||
|
||||
|
||||
class TestRefreshTokenJtiUniqueness:
|
||||
"""Verify that _create_refresh_token produces unique tokens even when
|
||||
called with identical data in rapid succession (issue #1 from review)."""
|
||||
|
||||
def test_two_calls_produce_different_tokens(self):
|
||||
"""Two calls with the same sub produce different JWTs because of jti."""
|
||||
token_a = _create_refresh_token({"sub": "42"})
|
||||
token_b = _create_refresh_token({"sub": "42"})
|
||||
assert token_a != token_b
|
||||
|
||||
def test_jti_is_valid_uuid(self):
|
||||
"""The jti claim inside the refresh token is a valid UUID."""
|
||||
token = _create_refresh_token({"sub": "1"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert "jti" in payload
|
||||
# Verify it parses as a UUID (raises ValueError if not)
|
||||
uuid.UUID(payload["jti"])
|
||||
|
||||
def test_each_token_has_unique_jti(self):
|
||||
"""Generate 100 tokens for the same user — all jtis must differ."""
|
||||
jtis = set()
|
||||
for _ in range(100):
|
||||
token = _create_refresh_token({"sub": "999"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
jtis.add(payload["jti"])
|
||||
assert len(jtis) == 100
|
||||
|
||||
def test_jti_survives_decode(self):
|
||||
"""The jti claim is present and correct after decode."""
|
||||
token = _create_refresh_token({"sub": "7"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert payload["sub"] == "7"
|
||||
assert "jti" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
class TestRefreshTokenModelColumnLength:
|
||||
"""Verify the model's token column length matches the migration (issue #2)."""
|
||||
|
||||
def test_token_column_has_length_512(self):
|
||||
"""RefreshToken.token column should be String(512), not unbounded."""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# Get the column definition from the model's table
|
||||
column = RefreshToken.__table__.columns["token"]
|
||||
# The type should be String with length 512
|
||||
assert column.type.length == 512
|
||||
|
||||
def test_token_column_type_is_string(self):
|
||||
"""The token column should be a String type."""
|
||||
from sqlalchemy import String as SAString
|
||||
|
||||
column = RefreshToken.__table__.columns["token"]
|
||||
assert isinstance(column.type, SAString)
|
||||
@@ -0,0 +1,426 @@
|
||||
# Content Moderation & Age Verification Architecture
|
||||
|
||||
> **Status:** Proposed
|
||||
> **Owner:** Platform team
|
||||
> **Related:** Server authority + client prediction, avatar component system, backend auth (JWT)
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document specifies the **content moderation** and **age verification**
|
||||
architecture for **agp-game**, a Godot-based multiplayer game built by
|
||||
autonomous agents. It defines:
|
||||
|
||||
- How **content flags** propagate through the system across the three primary
|
||||
content types: **assets**, **rooms**, and **scripts**.
|
||||
- How flags are **evaluated, aggregated, and enforced** at load time, at
|
||||
runtime, and at the network boundary.
|
||||
- The **age verification flow**, combining a lightweight **self-declaration**
|
||||
with an optional **ID check hook** for higher-assurance verification.
|
||||
|
||||
The goal is a **single, consistent moderation pipeline** that applies the same
|
||||
rules to user-generated content (UGC) regardless of whether it is a 3D asset, a
|
||||
room definition, or a script, and that gates access to mature content behind a
|
||||
verifiable age gate.
|
||||
|
||||
### 1.1 Design Goals
|
||||
|
||||
- **Uniformity:** One flag vocabulary and one propagation mechanism across
|
||||
assets, rooms, and scripts.
|
||||
- **Composability:** Flags on a room aggregate the flags of the assets and
|
||||
scripts it contains; flags on a world aggregate the flags of its rooms.
|
||||
- **Enforcement at the boundary:** The server is authoritative for what content
|
||||
a client may load or receive, mirroring the server-authoritative networking
|
||||
model.
|
||||
- **Privacy-preserving age checks:** Self-declaration requires no PII; the ID
|
||||
check hook is optional, pluggable, and never stores raw identity documents.
|
||||
- **Auditability:** Every flag decision is logged with a reason, an actor, and
|
||||
a timestamp so moderation actions are reviewable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Content Flag Vocabulary
|
||||
|
||||
All content carries a set of **content flags**. A flag is a typed, immutable
|
||||
label with a **severity** and a **scope**. Flags are the atomic unit of
|
||||
moderation; everything else (aggregation, enforcement, gating) operates on
|
||||
flags.
|
||||
|
||||
### 2.1 Flag Definition
|
||||
|
||||
A flag is a structured record:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|-----------|--------------------------------------------------------------------------|
|
||||
| `id` | `string` | Stable machine-readable identifier, e.g. `violence.gore`. |
|
||||
| `label` | `string` | Human-readable name, e.g. "Graphic Gore". |
|
||||
| `category` | `enum` | One of `violence`, `sexual`, `language`, `substance`, `gambling`, `horror`, `hate`, `custom`. |
|
||||
| `severity` | `enum` | One of `none`, `mild`, `moderate`, `severe`. |
|
||||
| `scope` | `enum` | One of `visual`, `audio`, `text`, `behavior`, `interaction`. |
|
||||
| `mature` | `bool` | Whether this flag triggers the age gate (see §5). |
|
||||
| `reason` | `string` | Free-text justification supplied by the flagger. |
|
||||
| `source` | `enum` | One of `author`, `moderator`, `automated`, `community`. |
|
||||
|
||||
### 2.2 Canonical Flag Set
|
||||
|
||||
The platform defines a **canonical registry** of flags so that different
|
||||
content types and different flaggers use the same vocabulary. Examples:
|
||||
|
||||
| id | category | severity | scope | mature |
|
||||
|-----------------------------|------------|-----------|--------------|--------|
|
||||
| `violence.none` | violence | none | visual | false |
|
||||
| `violence.mild` | violence | mild | visual | false |
|
||||
| `violence.gore` | violence | severe | visual | true |
|
||||
| `sexual.suggestive` | sexual | mild | visual | true |
|
||||
| `sexual.explicit` | sexual | severe | visual | true |
|
||||
| `language.mild` | language | mild | text | false |
|
||||
| `language.profanity` | language | moderate | text | false |
|
||||
| `substance.references` | substance | mild | text | false |
|
||||
| `gambling.simulated` | gambling | moderate | behavior | true |
|
||||
| `horror.jumpscare` | horror | mild | audio/visual | false |
|
||||
| `hate.hateful` | hate | severe | text | true |
|
||||
|
||||
The registry is **data-driven** (a JSON/YAML manifest shipped with the server)
|
||||
so new flags can be added without code changes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Flag Propagation Model
|
||||
|
||||
Flags propagate through the system along the **content dependency graph**. The
|
||||
three content types form a hierarchy:
|
||||
|
||||
```
|
||||
World
|
||||
└── Room
|
||||
├── Asset (mesh, texture, audio, animation, material)
|
||||
└── Script (behavior, dialogue, event logic)
|
||||
```
|
||||
|
||||
### 3.1 Propagation Rules
|
||||
|
||||
1. **Explicit flags** are set directly on a content item by its author or a
|
||||
moderator.
|
||||
2. **Inherited flags** flow upward: a room's effective flag set is the **union**
|
||||
of its own explicit flags and the flags of every asset and script it
|
||||
references.
|
||||
3. **Aggregation** uses the **maximum severity** per category across all
|
||||
contributing items. A room containing one `violence.gore` asset and one
|
||||
`violence.none` asset is `violence.severe`.
|
||||
4. **Maturity is monotonic:** if any contributing item is `mature`, the
|
||||
containing room and world are `mature`.
|
||||
5. **Cycles are impossible** by construction: content references form a DAG
|
||||
(an asset cannot contain a room; a room cannot contain itself).
|
||||
|
||||
### 3.2 Propagation Through the Pipeline
|
||||
|
||||
Flags are attached at **authoring time** and carried through every stage:
|
||||
|
||||
```
|
||||
Authoring ──► Upload ──► Validation ──► Storage ──► Distribution ──► Runtime
|
||||
│ │ │ │ │ │
|
||||
│ flag │ flag │ automated │ flag │ flag │ flag
|
||||
│ declared │ preserved │ scan adds │ indexed │ re-checked │ enforced
|
||||
│ │ │ flags │ │ │
|
||||
```
|
||||
|
||||
| Stage | Responsibility |
|
||||
|--------------|-------------------------------------------------------------------------|
|
||||
| **Authoring**| Author declares flags in the editor/upload UI. |
|
||||
| **Upload** | Flags are validated against the canonical registry; unknown flags rejected. |
|
||||
| **Validation**| Automated scanner (hash-based + heuristic) adds flags for detected content. |
|
||||
| **Storage** | Flags are stored alongside the content record and indexed for queries. |
|
||||
| **Distribution**| Flags are re-checked against the current registry before serving. |
|
||||
| **Runtime** | The client enforces flags before loading content (see §4). |
|
||||
|
||||
### 3.3 Data Model
|
||||
|
||||
Flags are stored as a **normalized join table** so they can be queried and
|
||||
aggregated efficiently:
|
||||
|
||||
```
|
||||
content_item (id, type, owner_id, ...)
|
||||
content_flag (content_id, flag_id, source, reason, created_at, actor_id)
|
||||
flag_registry (id, category, severity, scope, mature, ...)
|
||||
```
|
||||
|
||||
Aggregation is computed by a SQL view / query that joins a content item's
|
||||
transitive dependencies. Because the graph is a DAG, this is a bounded
|
||||
recursive query (PostgreSQL `WITH RECURSIVE`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Enforcement
|
||||
|
||||
Enforcement happens at three layers, in order:
|
||||
|
||||
### 4.1 Load-Time Enforcement (Server)
|
||||
|
||||
Before a client is allowed to **request** content, the server evaluates the
|
||||
content's effective flag set against the requesting account's **content
|
||||
preferences** and **age verification status**:
|
||||
|
||||
- If the content is `mature` and the account is **not age-verified**, the
|
||||
request is **rejected** with a `403 MATURE_CONTENT_REQUIRES_VERIFICATION`.
|
||||
- If the content's flags exceed the account's **content filter** (a per-account
|
||||
severity threshold per category), the request is rejected or the offending
|
||||
content is **stripped** (e.g., audio muted, mesh hidden, script not loaded).
|
||||
|
||||
### 4.2 Runtime Enforcement (Client)
|
||||
|
||||
The client receives a **manifest** of allowed content from the server. It never
|
||||
loads content that is not in the manifest. This prevents a malicious or buggy
|
||||
client from bypassing the server's filter by loading content directly.
|
||||
|
||||
- Assets not in the manifest are **not instantiated**.
|
||||
- Scripts not in the manifest are **not executed**.
|
||||
- Rooms not in the manifest are **not entered**.
|
||||
|
||||
### 4.3 Network Boundary Enforcement
|
||||
|
||||
The server **filters replication** based on flags:
|
||||
|
||||
- Mature or filtered content is **not replicated** to clients that cannot
|
||||
receive it.
|
||||
- A room's flag set is broadcast in the room metadata so clients can show a
|
||||
warning before entry.
|
||||
|
||||
### 4.4 Enforcement Decision Flow
|
||||
|
||||
```
|
||||
Client requests content C for account A
|
||||
│
|
||||
▼
|
||||
Compute effective flags F(C) [union + max-severity aggregation]
|
||||
│
|
||||
▼
|
||||
Is A age-verified? ──no──► Is F(C).mature? ──yes──► REJECT (403)
|
||||
│ yes │ no
|
||||
▼ ▼
|
||||
Does F(C) exceed A's filter? ──► yes ──► REJECT or STRIP
|
||||
│ no
|
||||
▼
|
||||
Serve content + manifest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Age Verification Flow
|
||||
|
||||
Age verification is a **two-tier** system:
|
||||
|
||||
- **Tier 1 — Self-declaration:** The user asserts their date of birth. This is
|
||||
fast, requires no PII beyond a birthdate, and is sufficient for most content.
|
||||
- **Tier 2 — ID check (optional hook):** A pluggable identity-verification
|
||||
provider confirms the user is an adult. This raises the account's assurance
|
||||
level and unlocks the highest maturity tier.
|
||||
|
||||
### 5.1 Verification States
|
||||
|
||||
An account has an **age verification status**:
|
||||
|
||||
| Status | Meaning |
|
||||
|-------------------|----------------------------------------------------------------------|
|
||||
| `unverified` | No declaration on file. |
|
||||
| `self_declared` | User declared a birthdate; **not** independently confirmed. |
|
||||
| `id_verified` | User passed the ID check hook; independently confirmed adult. |
|
||||
| `rejected` | ID check failed or declaration was inconsistent. |
|
||||
|
||||
### 5.2 Tier 1 — Self-Declaration Flow
|
||||
|
||||
```
|
||||
User submits { date_of_birth }
|
||||
│
|
||||
▼
|
||||
Server validates format & plausibility (age 13–120, not in future)
|
||||
│
|
||||
▼
|
||||
Compute age from DOB
|
||||
│
|
||||
▼
|
||||
age >= 18 ──► status = self_declared (adult)
|
||||
age >= 13 ──► status = self_declared (minor, restricted)
|
||||
age < 13 ──► status = rejected (below minimum platform age)
|
||||
```
|
||||
|
||||
- The DOB is stored **encrypted at rest** and is **never exposed** to other
|
||||
players.
|
||||
- A user may **upgrade** from `self_declared` to `id_verified` at any time via
|
||||
Tier 2.
|
||||
|
||||
### 5.3 Tier 2 — ID Check Hook
|
||||
|
||||
The ID check is an **optional, pluggable hook**. The platform defines an
|
||||
interface; concrete providers (e.g., a third-party KYC service) implement it.
|
||||
|
||||
```
|
||||
User requests ID verification
|
||||
│
|
||||
▼
|
||||
Server creates a verification session (idempotent, expiring)
|
||||
│
|
||||
▼
|
||||
Server calls provider hook: verify(identity_token) -> { adult: bool, ref: string }
|
||||
│
|
||||
▼
|
||||
adult == true ──► status = id_verified, store provider ref (not raw doc)
|
||||
adult == false ─► status = rejected
|
||||
provider error ──► status unchanged, retry allowed
|
||||
```
|
||||
|
||||
**Privacy guarantees:**
|
||||
|
||||
- The platform **never stores** the raw identity document or image.
|
||||
- Only the provider's **opaque reference** and the boolean `adult` result are
|
||||
persisted.
|
||||
- The hook is **asynchronous and idempotent**; a session can be retried without
|
||||
double-charging or double-verifying.
|
||||
|
||||
### 5.4 Hook Interface
|
||||
|
||||
The ID check hook is defined as a backend interface (FastAPI dependency /
|
||||
abstract service):
|
||||
|
||||
```python
|
||||
class IdentityVerificationProvider(Protocol):
|
||||
def create_session(self, user_id: str) -> VerificationSession: ...
|
||||
def verify(self, session_id: str, identity_token: str) -> VerificationResult: ...
|
||||
|
||||
class VerificationResult:
|
||||
adult: bool
|
||||
provider_ref: str | None
|
||||
error: str | None
|
||||
```
|
||||
|
||||
A default **no-op provider** returns `adult=False` with a clear error, so the
|
||||
system runs without any third-party integration until one is configured.
|
||||
|
||||
### 5.5 Age Gate Enforcement
|
||||
|
||||
The age gate is enforced at **load time** (see §4.1):
|
||||
|
||||
| Verification status | Can access `mature` content? |
|
||||
|---------------------|------------------------------|
|
||||
| `unverified` | No |
|
||||
| `self_declared` | Yes (adult) / No (minor) |
|
||||
| `id_verified` | Yes |
|
||||
| `rejected` | No |
|
||||
|
||||
---
|
||||
|
||||
## 6. Moderation Workflow
|
||||
|
||||
### 6.1 Flagging Sources
|
||||
|
||||
Content can be flagged by:
|
||||
|
||||
- **Author** (self-declared at upload).
|
||||
- **Automated scanner** (hash-based detection, heuristic analysis).
|
||||
- **Community** (players report content; reports become flags after review).
|
||||
- **Moderator** (manual review and override).
|
||||
|
||||
### 6.2 Review Queue
|
||||
|
||||
Community and automated flags enter a **review queue**. A moderator either:
|
||||
|
||||
- **Approves** the flag (it becomes authoritative), or
|
||||
- **Rejects** the flag (it is discarded), or
|
||||
- **Escalates** to a senior moderator for severe/hate flags.
|
||||
|
||||
### 6.3 Actions
|
||||
|
||||
Once a flag is authoritative, a moderator can take actions:
|
||||
|
||||
| Action | Effect |
|
||||
|-----------------|---------------------------------------------------------------|
|
||||
| `warn` | Notify the author; content remains. |
|
||||
| `restrict` | Content hidden from users below a filter threshold. |
|
||||
| `age_gate` | Content marked `mature`; requires age verification. |
|
||||
| `remove` | Content taken down; not served. |
|
||||
| `ban_author` | Author's upload privileges suspended. |
|
||||
|
||||
### 6.4 Audit Log
|
||||
|
||||
Every flag and action is written to an **audit log**:
|
||||
|
||||
```
|
||||
audit_event (id, content_id, actor_id, action, flag_id, reason, created_at)
|
||||
```
|
||||
|
||||
This log is append-only and used for transparency and dispute resolution.
|
||||
|
||||
---
|
||||
|
||||
## 7. Client-Side Integration (Godot)
|
||||
|
||||
### 7.1 Content Manifest
|
||||
|
||||
The server sends each client a **content manifest** — the set of content the
|
||||
client is permitted to load, derived from the account's verification status and
|
||||
filter. The Godot client:
|
||||
|
||||
- Loads only manifest-listed assets.
|
||||
- Executes only manifest-listed scripts.
|
||||
- Shows a **maturity warning** dialog before entering a room whose effective
|
||||
flags include `mature` content.
|
||||
|
||||
### 7.2 Flag Metadata on Content
|
||||
|
||||
Each content resource carries a `content_flags` metadata field (a Godot
|
||||
`Dictionary` or custom `Resource`). The client uses this for local UI decisions
|
||||
(e.g., showing a warning icon) but **never** as the authority for whether to
|
||||
load — the server manifest is authoritative.
|
||||
|
||||
### 7.3 Age Verification UI
|
||||
|
||||
The client exposes an **age verification screen**:
|
||||
|
||||
- Tier 1: a date-of-birth picker.
|
||||
- Tier 2: a button that opens the ID check provider flow (deep link / webview).
|
||||
|
||||
The client calls the backend endpoints (§8) and reflects the resulting status.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backend API Surface
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|-----------------------------------|------------------------------------------------|
|
||||
| `POST` | `/v1/age/declare` | Submit self-declared DOB (Tier 1). |
|
||||
| `POST` | `/v1/age/verify/session` | Create an ID-check session (Tier 2). |
|
||||
| `POST` | `/v1/age/verify/confirm` | Confirm an ID-check session with a token. |
|
||||
| `GET` | `/v1/age/status` | Return the account's verification status. |
|
||||
| `GET` | `/v1/content/{id}/flags` | Return a content item's effective flags. |
|
||||
| `POST` | `/v1/content/{id}/flags` | Add a flag (author/community). |
|
||||
| `POST` | `/v1/moderation/review/{flag_id}` | Moderator approve/reject/escalate. |
|
||||
| `POST` | `/v1/moderation/action` | Apply a moderation action. |
|
||||
| `GET` | `/v1/manifest` | Return the client's permitted content manifest.|
|
||||
|
||||
All endpoints require a valid **JWT** (matching the existing auth stack). The
|
||||
age endpoints additionally require the account to be in a non-`rejected` state.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security & Privacy Considerations
|
||||
|
||||
- **DOB encryption:** Self-declared birthdates are encrypted at rest; only the
|
||||
derived age and verification status are used in authorization decisions.
|
||||
- **No raw identity storage:** The ID check hook persists only a provider
|
||||
reference and a boolean result.
|
||||
- **Server authority:** The client manifest prevents bypass; the client never
|
||||
trusts its own flag metadata for access decisions.
|
||||
- **Rate limiting:** Age-declaration and ID-check endpoints are rate-limited to
|
||||
prevent abuse and enumeration.
|
||||
- **Auditability:** All flag and verification events are logged append-only.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions / Future Work
|
||||
|
||||
- **Regional age thresholds:** Different jurisdictions may require different
|
||||
minimum ages; the threshold should be configurable per region.
|
||||
- **Parental controls:** A future feature may let a parent set a content filter
|
||||
on a minor's account.
|
||||
- **Automated scanner accuracy:** The heuristic scanner may produce false
|
||||
positives; a confidence score should be attached to automated flags.
|
||||
- **Offline / local content:** How flags apply to locally-authored content that
|
||||
is never uploaded remains to be defined.
|
||||
@@ -0,0 +1,369 @@
|
||||
# Editor Architecture: Godot Editor Plugin vs. In-Game Editor
|
||||
|
||||
> **Status:** Proposed
|
||||
> **Owner:** Platform team
|
||||
> **Related:** World chunk data format & persistence, server authority + client
|
||||
> prediction, content moderation & age verification, avatar component system
|
||||
> **Godot version:** 4.6.2 stable
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document evaluates the two primary ways to build the **world/level
|
||||
authoring tool** for **agp-game**:
|
||||
|
||||
- **(a) Godot editor plugin** — extend the Godot editor itself with custom
|
||||
panels, dock widgets, and tools that operate on the project's scenes and
|
||||
resources.
|
||||
- **(b) In-game editor** — a fully custom editor built as game UI (Godot
|
||||
`Control` nodes) that runs inside the running game, editing the game's own
|
||||
world data.
|
||||
|
||||
The goal is to pick the approach that best fits the platform's constraints:
|
||||
a **server-authoritative** multiplayer game where the **world is stored as
|
||||
versioned chunks** (see `world.md`), content is **moderation-flagged** (see
|
||||
`content_moderation.md`), and the client is a **shipped binary** that players
|
||||
run (not a dev tool).
|
||||
|
||||
This document is a **decision record**: it lays out the trade-offs, gives a
|
||||
clear recommendation, and defines the resulting architecture boundaries.
|
||||
|
||||
---
|
||||
|
||||
## 2. Context & Constraints
|
||||
|
||||
Before comparing the two approaches, we must fix the constraints that drive the
|
||||
decision. These come from the existing architecture docs.
|
||||
|
||||
### 2.1 The World Is Data, Not Scenes
|
||||
|
||||
`world.md` defines the world as **chunk data** (JSON for authoring, binary for
|
||||
runtime/network), persisted in SQLite on the server. A chunk is the atomic unit
|
||||
of load, save, and stream. The **server is the only writer** to the store;
|
||||
clients submit **deltas** against a `base_revision` and the server validates and
|
||||
applies them.
|
||||
|
||||
**Implication:** The editor's output is **chunk payloads**, not `.tscn` scene
|
||||
files. Whatever editor we build must serialize to the chunk contract, not to
|
||||
Godot scene resources.
|
||||
|
||||
### 2.2 Server-Authoritative & Moderation-Aware
|
||||
|
||||
- The server validates every edit (schema, bounds, moderation) before
|
||||
persisting (`world.md` §5.4).
|
||||
- Content carries **content flags** aggregated from referenced assets/scripts
|
||||
(`content_moderation.md` §3). Mature content is gated behind age verification.
|
||||
- The client receives a **content manifest** and never loads content not in it.
|
||||
|
||||
**Implication:** An editor that runs on a **player's machine** cannot be the
|
||||
authority on what gets published. Editing must go through the server's
|
||||
validation pipeline, and the editor must surface moderation flags.
|
||||
|
||||
### 2.3 The Client Is a Shipped Binary
|
||||
|
||||
The game is a multiplayer product. Players run a **release build**. The Godot
|
||||
editor is a **development tool** that players do not have.
|
||||
|
||||
**Implication:** If authoring is a first-class *player* feature (user-generated
|
||||
content), the editor must ship inside the game binary. If authoring is a
|
||||
*developer/platform* feature, it can live in the editor.
|
||||
|
||||
### 2.4 Two Distinct Audiences
|
||||
|
||||
The evaluation must separate two audiences, because they have opposite needs:
|
||||
|
||||
| Audience | Who | Where they run | What they build |
|
||||
|----------|-----|----------------|-----------------|
|
||||
| **Platform/dev** | The team building the game | Godot editor (dev machines) | Base worlds, rooms, templates, test levels |
|
||||
| **Players (UGC)** | End users | The shipped game | Their own rooms/worlds, published to the server |
|
||||
|
||||
The two approaches map cleanly onto these audiences, and the right answer is
|
||||
likely **both** — but with a clear primary recommendation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Approach (a): Godot Editor Plugin
|
||||
|
||||
### 3.1 What It Is
|
||||
|
||||
A Godot editor plugin is a set of scripts (and optionally scenes) that extend
|
||||
the editor via the `EditorPlugin` API. It can add:
|
||||
|
||||
- **Dock panels** (`add_control_to_dock`) — e.g., a chunk/tile palette panel.
|
||||
- **Toolbar buttons** (`add_control_to_container`).
|
||||
- **Inspector plugins** (`EditorInspectorPlugin`) — custom property editors.
|
||||
- **Import plugins** (`EditorImportPlugin`) — custom asset importers.
|
||||
- **Scene/Node plugins** (`EditorNode3DGizmoPlugin`) — custom gizmos.
|
||||
- **Menu entries** (`add_tool_menu_item`).
|
||||
|
||||
Plugins are enabled per-project via `project.godot` (`[editor_plugins]`).
|
||||
|
||||
### 3.2 Pros
|
||||
|
||||
- **Full editor power for free:** You get the scene tree, inspector, viewport
|
||||
navigation, undo/redo, gizmos, and asset pipeline — all battle-tested.
|
||||
- **Native Godot editing:** Editing `.tscn` scenes, materials, and nodes is
|
||||
exactly what the editor is built for. Great for authoring **base content**
|
||||
(rooms, templates, avatar base bodies).
|
||||
- **Fast iteration for devs:** The platform team can build and test levels
|
||||
directly in the tool they already use.
|
||||
- **Undo/redo, selection, snapping** come from the editor's `EditorUndoRedoManager`.
|
||||
- **No runtime footprint:** Editor-only code is stripped from export builds
|
||||
(`@tool` scripts and editor plugins are not shipped to players).
|
||||
|
||||
### 3.3 Cons
|
||||
|
||||
- **Not available to players.** Players don't run the Godot editor. Any
|
||||
player-facing UGC authoring cannot use this approach.
|
||||
- **Tied to the editor's UX.** You inherit the editor's look and interaction
|
||||
model; deep customization is limited by what the editor API exposes.
|
||||
- **Editor API churn.** `EditorPlugin` APIs change between Godot versions;
|
||||
plugins are more brittle across upgrades than game code.
|
||||
- **Scene-centric, not data-centric.** The editor is optimized for scenes and
|
||||
resources. Our world is **chunk data** (`world.md`). We'd have to build a
|
||||
translation layer (scene ↔ chunk) and keep it in sync — a real source of
|
||||
drift.
|
||||
- **Single-user, local.** The editor edits local files. It does not natively
|
||||
talk to our server-authoritative chunk store or the moderation pipeline.
|
||||
- **Headless/CI friction.** Validating plugin behavior in CI requires running
|
||||
the editor headless, which is heavier than testing game code.
|
||||
|
||||
### 3.4 Fit for Our Constraints
|
||||
|
||||
- **Platform/dev authoring:** Excellent. This is the natural home for building
|
||||
base worlds, rooms, and templates.
|
||||
- **Player UGC:** Not applicable. Players cannot run the editor.
|
||||
- **Server-authoritative chunk store:** Poor fit natively. The editor edits
|
||||
local scenes; we'd bolt on a chunk export/import and a server sync layer.
|
||||
|
||||
---
|
||||
|
||||
## 4. Approach (b): In-Game Editor
|
||||
|
||||
### 4.1 What It Is
|
||||
|
||||
A fully custom editor built as **game UI** — Godot `Control` nodes (panels,
|
||||
buttons, toolbars) that run inside the running game. It edits the game's own
|
||||
world data model (chunks) and submits edits through the normal server pipeline.
|
||||
|
||||
### 4.2 Pros
|
||||
|
||||
- **Ships to players.** The editor is part of the game binary, so **player UGC
|
||||
authoring is possible** — the core requirement for a user-generated-content
|
||||
platform.
|
||||
- **Data-native.** The editor operates directly on the **chunk model**
|
||||
(`world.md`), so there is no scene↔chunk translation layer and no drift.
|
||||
- **Server-authoritative by construction.** Edits go through the same
|
||||
`request_chunk_edit` RPCs as gameplay, so validation, revisioning, and
|
||||
moderation apply uniformly. The editor cannot bypass the server.
|
||||
- **Moderation-aware.** Because edits flow through the server, content flags are
|
||||
computed and enforced at the boundary (`content_moderation.md` §4). The editor
|
||||
can surface flags and gate mature content.
|
||||
- **One codebase, one UX.** The editor uses the same UI toolkit and art style as
|
||||
the game; no separate editor theme to maintain.
|
||||
- **Testable in CI.** The editor is ordinary game code; it can be exercised
|
||||
headlessly and unit-tested like any other system.
|
||||
|
||||
### 4.3 Cons
|
||||
|
||||
- **We build everything.** No free scene tree, inspector, gizmos, or undo/redo.
|
||||
We must implement selection, snapping, undo/redo, viewport navigation, and
|
||||
property editing ourselves.
|
||||
- **Runtime footprint.** Editor UI ships in the binary (though it can be gated
|
||||
behind a feature flag / separate scene that is only instantiated on demand).
|
||||
- **More upfront work.** A usable in-game editor is a large feature; it needs
|
||||
its own architecture (tool modes, gizmos, palette, inspector).
|
||||
- **Not ideal for deep asset authoring.** Modeling a mesh or authoring a
|
||||
material is better done in the Godot editor / DCC tools. The in-game editor
|
||||
should focus on **world composition** (placing tiles and objects), not asset
|
||||
creation.
|
||||
|
||||
### 4.4 Fit for Our Constraints
|
||||
|
||||
- **Platform/dev authoring:** Workable but not ideal for deep asset work.
|
||||
- **Player UGC:** The **only** viable approach. Players can only author through
|
||||
the shipped game.
|
||||
- **Server-authoritative chunk store:** Excellent fit. The editor is a client
|
||||
that submits validated deltas.
|
||||
|
||||
---
|
||||
|
||||
## 5. Comparison Matrix
|
||||
|
||||
| Criterion | (a) Editor plugin | (b) In-game editor |
|
||||
|-----------|-------------------|--------------------|
|
||||
| Ships to players | ❌ No | ✅ Yes |
|
||||
| Player UGC authoring | ❌ Impossible | ✅ Core capability |
|
||||
| World data model fit | ⚠️ Scene-centric; needs translation layer | ✅ Chunk-native |
|
||||
| Server-authoritative edits | ⚠️ Bolted on | ✅ Native (same RPCs) |
|
||||
| Moderation enforcement | ⚠️ Manual | ✅ At the boundary |
|
||||
| Free editor features (undo, gizmos, inspector) | ✅ Rich | ❌ Build ourselves |
|
||||
| Runtime footprint | ✅ None (stripped from export) | ⚠️ Ships in binary (gated) |
|
||||
| Dev iteration speed | ✅ Fast | ⚠️ Slower to build |
|
||||
| Deep asset authoring (mesh/material) | ✅ Native | ❌ Out of scope |
|
||||
| CI testability | ⚠️ Heavier (headless editor) | ✅ Ordinary game code |
|
||||
| Editor API churn risk | ⚠️ Higher | ✅ None (game code) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommendation
|
||||
|
||||
**Primary recommendation: build the in-game editor (b) as the platform's
|
||||
authoring surface, and use a lightweight Godot editor plugin (a) only as an
|
||||
internal dev convenience.**
|
||||
|
||||
### 6.1 Rationale
|
||||
|
||||
1. **Player UGC is the deciding factor.** The platform's value proposition is
|
||||
user-generated content (rooms, worlds, avatars). Players cannot run the Godot
|
||||
editor, so **only an in-game editor can deliver player authoring**. This is a
|
||||
hard requirement that the editor-plugin approach cannot meet.
|
||||
|
||||
2. **The world is data, and the in-game editor is data-native.** `world.md`
|
||||
defines chunks as the atomic unit. An in-game editor edits chunks directly
|
||||
and submits them through the server pipeline. An editor plugin would force a
|
||||
scene↔chunk translation layer that is a constant source of drift and bugs.
|
||||
|
||||
3. **Server authority and moderation fall out naturally.** Because in-game
|
||||
edits use the same `request_chunk_edit` RPCs as gameplay, validation,
|
||||
revisioning, optimistic concurrency, and content-flag enforcement
|
||||
(`content_moderation.md`) apply uniformly. The editor cannot bypass the
|
||||
server — which is exactly what a moderation-aware UGC platform needs.
|
||||
|
||||
4. **The editor-plugin's strengths are not what we need.** Its free features
|
||||
(scene tree, inspector, gizmos) are for editing Godot scenes and resources.
|
||||
Our authoring is world composition against a chunk contract, not scene
|
||||
editing. The plugin's main win — deep asset authoring — is better served by
|
||||
DCC tools and the Godot editor for the *platform team*, not by a plugin we
|
||||
must maintain.
|
||||
|
||||
### 6.2 The Role of the Editor Plugin (Dev Convenience Only)
|
||||
|
||||
We still recommend a **small, optional** editor plugin for the **platform team**
|
||||
to author **base content** (base rooms, templates, avatar base bodies, test
|
||||
levels) directly in the Godot editor. This is a dev-time convenience, not a
|
||||
product feature:
|
||||
|
||||
- It can export a scene/resource as a **chunk** (reusing the same encoder as the
|
||||
in-game editor) so dev-authored content flows into the same pipeline.
|
||||
- It is **not shipped** to players and is **not** the authoring surface for UGC.
|
||||
- It should be kept thin to minimize editor-API churn risk.
|
||||
|
||||
### 6.3 Scope Boundary
|
||||
|
||||
To keep the in-game editor tractable, define its scope clearly:
|
||||
|
||||
- **In scope (world composition):** placing tiles, placing static/dynamic
|
||||
objects, setting transforms, editing object properties, saving/loading chunks,
|
||||
submitting edits to the server, showing moderation flags.
|
||||
- **Out of scope (asset creation):** modeling meshes, authoring materials,
|
||||
rigging avatars. These are done in DCC tools / the Godot editor and imported
|
||||
as catalog assets (see `avatar.md` §7). The in-game editor **references**
|
||||
catalog assets by id; it does not create them.
|
||||
|
||||
---
|
||||
|
||||
## 7. Resulting Architecture
|
||||
|
||||
### 7.1 Shared Editor Core
|
||||
|
||||
Both the in-game editor and the (optional) dev plugin should share a single
|
||||
**editor core** that operates on the chunk model. This avoids two divergent
|
||||
implementations.
|
||||
|
||||
```
|
||||
EditorCore (shared, data-native)
|
||||
├── ChunkEditorModel # in-memory chunk being edited (tiles, objects, flags)
|
||||
├── ChunkEncoder # JSON/binary encode/decode (world.md §3)
|
||||
├── EditSession # base_revision tracking, delta generation
|
||||
├── EditSubmitter # request_chunk_edit RPC wrapper + conflict handling
|
||||
├── UndoRedoStack # command pattern over chunk mutations
|
||||
├── SelectionManager # selected tiles/objects
|
||||
├── ToolRegistry # brush, object-place, select, erase, etc.
|
||||
└── ModerationView # surfaces content flags; gates mature content
|
||||
```
|
||||
|
||||
### 7.2 In-Game Editor (Primary)
|
||||
|
||||
```
|
||||
InGameEditor (Control-based UI, shipped in binary, gated by feature flag)
|
||||
├── EditorViewport # custom 2D/3D viewport with camera navigation
|
||||
├── Toolbar # tool mode switcher
|
||||
├── PalettePanel # tile/object palette (from catalog)
|
||||
├── InspectorPanel # property editor for selected object
|
||||
├── ChunkBrowser # load/save chunks, list rooms
|
||||
└── EditorCore # shared core (above)
|
||||
```
|
||||
|
||||
- Instantiated only when the player enters "build mode" (a separate scene /
|
||||
feature flag), so the runtime footprint is negligible during normal play.
|
||||
- Submits edits via `request_chunk_edit`; handles `chunk_edit_result` conflicts
|
||||
by re-fetching and re-applying (`world.md` §5.4).
|
||||
|
||||
### 7.3 Dev Editor Plugin (Optional, Thin)
|
||||
|
||||
```
|
||||
DevChunkPlugin (EditorPlugin, dev-only, not shipped)
|
||||
├── Dock panel: "Chunk Export"
|
||||
├── Reads the current scene / selected nodes
|
||||
├── Maps scene content → chunk model (via EditorCore.ChunkEncoder)
|
||||
└── Exports a .chunk.json / submits to a dev server
|
||||
```
|
||||
|
||||
Kept thin to minimize editor-API churn. Its only job is to let the platform team
|
||||
author base content in the editor and push it into the same chunk pipeline.
|
||||
|
||||
### 7.4 Data Flow
|
||||
|
||||
```
|
||||
[In-game editor] --request_chunk_edit(delta, base_revision)--> [Server]
|
||||
^ | validate
|
||||
| | apply
|
||||
+--chunk_edit_result(ok/new_revision | conflict)-----------| persist
|
||||
v
|
||||
SQLite (world.db)
|
||||
|
||||
[Dev plugin] --export chunk JSON--> [EditorCore.ChunkEncoder] --> [Server]
|
||||
```
|
||||
|
||||
Both paths converge on the **same server validation pipeline**, so moderation
|
||||
and revisioning are uniform regardless of authoring surface.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| In-game editor is a large build | Scope it to world composition (§6.3); reuse EditorCore; ship behind a feature flag |
|
||||
| Custom viewport/gizmos are hard | Start with a 2D tile editor (matches `CHUNK_SIZE_Z=1` default); add 3D later |
|
||||
| Undo/redo complexity | Use a command pattern over chunk mutations; test headlessly |
|
||||
| Editor-plugin API churn | Keep the plugin thin; treat it as dev-only convenience, not a product surface |
|
||||
| Drift between dev plugin and in-game editor | Both use the same `EditorCore`/`ChunkEncoder` |
|
||||
| Players bypass moderation | Impossible by construction: edits go through server validation |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions / Future Work
|
||||
|
||||
- **3D editing:** The default world is flat (`CHUNK_SIZE_Z=1`). A 3D in-game
|
||||
editor (volumetric chunks) is future work (`world.md` §8).
|
||||
- **Collaborative editing:** Multiple players editing the same chunk concurrently
|
||||
— the optimistic-concurrency model already supports it, but the UX (live
|
||||
cursors, conflict resolution) is unspecified.
|
||||
- **Template library:** A curated set of room templates players can start from.
|
||||
- **Publish/review flow:** How a player's authored room enters the moderation
|
||||
review queue (`content_moderation.md` §6) and becomes publicly joinable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Summary
|
||||
|
||||
| Concern | Decision |
|
||||
|---------|----------|
|
||||
| Primary authoring surface | **In-game editor** (ships to players, enables UGC) |
|
||||
| Dev authoring convenience | Thin, optional **editor plugin** (not shipped) |
|
||||
| World data model | Both operate on the **chunk model** via a shared `EditorCore` |
|
||||
| Server authority | Edits go through `request_chunk_edit`; server validates |
|
||||
| Moderation | Enforced at the server boundary for both paths |
|
||||
| In-game editor scope | World composition only; asset creation is out of scope |
|
||||
| Runtime footprint | Editor gated behind a feature flag / separate scene |
|
||||
@@ -0,0 +1,286 @@
|
||||
# Multiplayer Networking Architecture
|
||||
|
||||
> **Status:** Proposed
|
||||
> **Owner:** Platform team
|
||||
> **Related:** Server authority + client prediction
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document specifies the multiplayer networking architecture for **agp-game**,
|
||||
a Godot-based game built by autonomous agents. The architecture follows a
|
||||
**server-authoritative model with client-side prediction and reconciliation**,
|
||||
which is the industry standard for fast-paced, competitive multiplayer games.
|
||||
|
||||
The goals of this architecture are:
|
||||
|
||||
- **Fairness & anti-cheat:** The server is the single source of truth for all
|
||||
gameplay state. Clients never directly mutate world state.
|
||||
- **Responsiveness:** Client-side prediction hides network latency so local input
|
||||
feels immediate.
|
||||
- **Determinism:** The server runs a fixed-timestep simulation so that state is
|
||||
reproducible and testable.
|
||||
- **Scalability:** A clear separation between transport, simulation, and
|
||||
replication layers allows the server to be tuned independently.
|
||||
|
||||
---
|
||||
|
||||
## 2. Transport Choice: ENet over WebSocket
|
||||
|
||||
### 2.1 Decision
|
||||
|
||||
We choose **ENet** as the primary transport for gameplay traffic.
|
||||
|
||||
### 2.2 Justification
|
||||
|
||||
| Criterion | ENet | WebSocket (TCP) |
|
||||
|----------------------|----------------------------------------|-------------------------------------|
|
||||
| **Reliability model**| Reliable + unreliable channels | Reliable only (TCP) |
|
||||
| **Latency** | Low; unreliable packets skip retransmit| Head-of-line blocking on loss |
|
||||
| **Packet ordering** | Per-channel ordering | Global strict ordering |
|
||||
| **Congestion control**| Lightweight, game-oriented | TCP-style (slow start, backoff) |
|
||||
| **Godot support** | First-class (`ENetConnection`, `MultiplayerPeerENet`) | Supported but TCP-based |
|
||||
| **Use case fit** | Real-time action game | Chat, lobby, HTTP-style messaging |
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- **Movement & state replication** are latency-sensitive and tolerate occasional
|
||||
packet loss. ENet's *unreliable* channel lets us send position snapshots at high
|
||||
frequency without head-of-line blocking.
|
||||
- **RPCs that must be delivered** (e.g., interactions, spawns) use ENet's
|
||||
*reliable* channel, giving us exactly the semantics we need without TCP's
|
||||
retransmission stalls.
|
||||
- Godot's `MultiplayerPeerENet` is mature and integrates directly with the
|
||||
high-level multiplayer API (`MultiplayerAPI`, `rpc()`).
|
||||
|
||||
### 2.3 WebSocket as a Secondary Transport
|
||||
|
||||
WebSocket remains available as a **secondary transport** for:
|
||||
|
||||
- **Lobby / matchmaking** messaging (low-frequency, must-be-reliable).
|
||||
- **Browser builds** where raw UDP/ENet is unavailable.
|
||||
- **Admin / telemetry** channels.
|
||||
|
||||
The transport layer is abstracted behind an interface so gameplay code does not
|
||||
depend on the underlying protocol.
|
||||
|
||||
---
|
||||
|
||||
## 3. Server-Side Game Loop
|
||||
|
||||
### 3.1 Tick Rate
|
||||
|
||||
- **Simulation tick rate:** **30 Hz** (33.33 ms per tick).
|
||||
- **Replication / snapshot rate:** **30 Hz**, aligned with the simulation tick.
|
||||
|
||||
**Justification:**
|
||||
|
||||
- 30 Hz is sufficient for smooth movement and interaction in a top-down / 2D
|
||||
action game while keeping CPU and bandwidth costs predictable.
|
||||
- A fixed timestep (rather than delta-time) makes the simulation deterministic
|
||||
and testable.
|
||||
- The tick rate is a **configuration constant** so it can be raised (e.g., to
|
||||
60 Hz) for competitive modes without architectural changes.
|
||||
|
||||
### 3.2 Authority Model
|
||||
|
||||
- **Server is authoritative** for:
|
||||
- Player position, velocity, and physics state.
|
||||
- Health, damage, and death.
|
||||
- Item pickups, inventory, and world-object state.
|
||||
- Spawning and despawning of entities.
|
||||
- Game rules, scoring, and win/lose conditions.
|
||||
- **Client is authoritative** for:
|
||||
- Its own *input* (which keys/buttons are pressed).
|
||||
- Local camera and UI.
|
||||
- Cosmetic / non-gameplay presentation.
|
||||
|
||||
### 3.3 Server Loop Pseudocode
|
||||
|
||||
```
|
||||
while server_running:
|
||||
process_network_inbound() # read client inputs & RPCs
|
||||
for _ in range(ticks_to_advance):
|
||||
simulate_tick() # fixed 30 Hz step
|
||||
broadcast_snapshots() # replicate authoritative state
|
||||
process_network_outbound() # flush reliable/unreliable channels
|
||||
```
|
||||
|
||||
The server accumulates real elapsed time and advances the simulation in fixed
|
||||
`TICK_DURATION` increments, decoupling simulation from frame rate.
|
||||
|
||||
---
|
||||
|
||||
## 4. RPC Call Patterns
|
||||
|
||||
RPCs are grouped by direction and reliability. All gameplay RPCs are routed
|
||||
through the high-level `MultiplayerAPI` with explicit `rpc_id` targeting.
|
||||
|
||||
### 4.1 Client → Server (Input & Requests)
|
||||
|
||||
| RPC | Channel | Payload | Notes |
|
||||
|------------------------------|-----------|-------------------------------------------|-------|
|
||||
| `request_move_input` | unreliable| `{ seq, input_state, timestamp }` | Batched per tick; drives prediction |
|
||||
| `request_interact` | reliable | `{ target_id }` | Open door, pick up item, use object |
|
||||
| `request_attack` | reliable | `{ target_id, aim_dir }` | Combat action |
|
||||
| `request_join` | reliable | `{ player_name, profile }` | Session setup |
|
||||
| `request_respawn` | reliable | `{}` | After death |
|
||||
|
||||
**Design notes:**
|
||||
|
||||
- **Input is batched** per client tick and sent with a monotonically increasing
|
||||
`seq` so the server can detect gaps and the client can reconcile.
|
||||
- **Interactions are reliable** because they are discrete, must-happen events
|
||||
(opening a door, picking up an item). Losing them would corrupt world state.
|
||||
|
||||
### 4.2 Server → Client (State & Events)
|
||||
|
||||
| RPC | Channel | Payload | Notes |
|
||||
|------------------------------|-----------|-------------------------------------------|-------|
|
||||
| `snapshot_state` | unreliable| `{ tick, entities[], player_states[] }` | Periodic authoritative state |
|
||||
| `spawn_entity` | reliable | `{ entity_id, type, transform, props }` | New entity enters world |
|
||||
| `despawn_entity` | reliable | `{ entity_id, reason }` | Entity removed |
|
||||
| `interact_result` | reliable | `{ request_seq, success, result }` | Acknowledges interaction |
|
||||
| `damage_event` | reliable | `{ target_id, amount, source_id }` | Combat feedback |
|
||||
| `game_event` | reliable | `{ type, payload }` | Score, win, round start/end |
|
||||
|
||||
### 4.3 Broadcast vs. Targeted
|
||||
|
||||
- **Broadcast (all clients):** `snapshot_state`, `spawn_entity`,
|
||||
`despawn_entity`, `game_event`.
|
||||
- **Targeted (single client):** `interact_result`, `damage_event` (to affected
|
||||
players), and any player-private data.
|
||||
|
||||
---
|
||||
|
||||
## 5. Client-Side Prediction & Reconciliation
|
||||
|
||||
### 5.1 Prediction
|
||||
|
||||
- The client runs a **local copy of the movement simulation** using the same
|
||||
physics constants as the server.
|
||||
- When the player provides input, the client:
|
||||
1. Applies the input to its local player immediately.
|
||||
2. Renders the predicted position.
|
||||
3. Sends the input to the server (batched, with `seq`).
|
||||
|
||||
### 5.2 Reconciliation
|
||||
|
||||
- The server sends authoritative `snapshot_state` at 30 Hz containing the
|
||||
server's tick number and authoritative player positions.
|
||||
- The client keeps a **ring buffer of predicted states** keyed by input `seq`.
|
||||
- On receiving a snapshot:
|
||||
1. Find the last predicted state that matches the server's tick.
|
||||
2. Compute the **error** between predicted and authoritative position.
|
||||
3. If error exceeds a threshold, **snap or smoothly interpolate** the local
|
||||
player to the authoritative position (error correction).
|
||||
4. Re-simulate any inputs that occurred after the server's tick (rollback).
|
||||
|
||||
### 5.3 Interpolation of Remote Entities
|
||||
|
||||
- **Local player:** predicted + reconciled (see above).
|
||||
- **Remote players / entities:** rendered with **interpolation delay** of
|
||||
`2 × TICK_DURATION` (≈ 66 ms) to smooth out snapshot jitter.
|
||||
- The client buffers the last N snapshots and interpolates between them.
|
||||
|
||||
### 5.4 Prediction Buffer Sizing
|
||||
|
||||
- Buffer size is adaptive based on measured RTT.
|
||||
- Default: hold up to `ceil(RTT / TICK_DURATION) + 2` predicted states.
|
||||
|
||||
---
|
||||
|
||||
## 6. Entity & World State Replication
|
||||
|
||||
### 6.1 Entity Model
|
||||
|
||||
Every networked entity has:
|
||||
|
||||
- A globally unique `entity_id` (server-assigned).
|
||||
- An `EntityType` (player, enemy, item, projectile, door, ...).
|
||||
- A `Transform` (position, rotation).
|
||||
- A typed `props` dictionary of gameplay state (health, state machine, etc.).
|
||||
|
||||
### 6.2 Snapshot Format
|
||||
|
||||
```
|
||||
snapshot_state:
|
||||
tick: uint32
|
||||
entities:
|
||||
- entity_id: uint32
|
||||
type: uint8
|
||||
transform: { x, y, rot }
|
||||
props: { ... } # delta-encoded where possible
|
||||
player_states:
|
||||
- peer_id: int
|
||||
entity_id: uint32
|
||||
health: int
|
||||
state: uint8
|
||||
```
|
||||
|
||||
### 6.3 Delta Compression
|
||||
|
||||
- Only **changed** entity fields are sent per snapshot (dirty-flag per entity).
|
||||
- Static or unchanged entities are omitted from the snapshot.
|
||||
- This keeps bandwidth proportional to *activity*, not world size.
|
||||
|
||||
---
|
||||
|
||||
## 7. Connection & Session Lifecycle
|
||||
|
||||
1. **Connect:** Client opens an ENet connection to the server.
|
||||
2. **Authenticate / Join:** Client sends `request_join`; server validates and
|
||||
assigns a `peer_id` and spawns the player entity.
|
||||
3. **Spawn:** Server broadcasts `spawn_entity` for the new player to all clients.
|
||||
4. **Play:** Input → prediction → server simulation → snapshot → reconciliation.
|
||||
5. **Disconnect:** Server removes the player entity, broadcasts `despawn_entity`,
|
||||
and cleans up session state.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security & Anti-Cheat
|
||||
|
||||
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
||||
*requests*; the server computes all outcomes.
|
||||
- **Input validation:** Server clamps movement speed, rejects out-of-bounds
|
||||
positions, and validates interaction targets.
|
||||
- **Rate limiting:** Per-peer input and RPC rate limits prevent flooding.
|
||||
- **Sequence validation:** Gaps or replays in input `seq` are detected and
|
||||
rejected.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing & Determinism
|
||||
|
||||
- The simulation is a **pure function of (state, inputs, tick)** so it can be
|
||||
unit-tested headlessly.
|
||||
- A **replay system** records inputs and can re-simulate to verify server and
|
||||
client produce identical results.
|
||||
- Network code is tested with a **mock transport** that can inject latency,
|
||||
jitter, and packet loss.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions / Future Work
|
||||
|
||||
- **Interest management / spatial partitioning:** For large worlds, restrict
|
||||
snapshots to entities near each client (AOI culling).
|
||||
- **Server-side physics:** Decide whether to use Godot's physics server on the
|
||||
host or a custom deterministic physics step.
|
||||
- **Dedicated server vs. listen server:** The architecture supports both; a
|
||||
dedicated headless server is the target for production.
|
||||
- **WebSocket fallback:** Implement the transport abstraction for browser builds.
|
||||
|
||||
---
|
||||
|
||||
## 11. Summary
|
||||
|
||||
| Concern | Decision |
|
||||
|------------------------|-------------------------------------------------|
|
||||
| Transport | ENet (primary), WebSocket (secondary) |
|
||||
| Tick rate | 30 Hz fixed timestep |
|
||||
| Authority | Server-authoritative; client sends input only |
|
||||
| Prediction | Client-side prediction + reconciliation |
|
||||
| Remote interpolation | 2-tick interpolation delay |
|
||||
| Replication | Delta-compressed snapshots at 30 Hz |
|
||||
| Interaction RPCs | Reliable channel |
|
||||
| Movement input | Unreliable channel, batched with sequence |
|
||||
@@ -0,0 +1,194 @@
|
||||
# PC2 Test Level & Player Spawn — Design Note
|
||||
|
||||
> **Status:** Decided (PC2)
|
||||
> **Owner:** Architecture
|
||||
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||
> (2) build the player & spawn it. Both must be able to follow this note
|
||||
> without further design decisions.
|
||||
|
||||
## 1. Decision: static scene, not chunk streaming
|
||||
|
||||
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||
scene.** It is **not** built through the chunk streaming system
|
||||
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||
`chunk_data_decoder.gd`).
|
||||
|
||||
This is a deliberate decision, not a default:
|
||||
|
||||
- **The streaming system has never been exercised with real content.** It is a
|
||||
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||
chunk authoring/streaming endpoint to integrate against.
|
||||
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||
streaming is server-authoritative streaming around a moving player across a
|
||||
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||
clearly out of checkpoint scope.
|
||||
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||
|
||||
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||
of the *world data* half.
|
||||
|
||||
## 2. Where things live
|
||||
|
||||
| Artifact | Path | Owned by |
|
||||
|---|---|---|
|
||||
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||
| Player scene | `scenes/player.tscn` | player task |
|
||||
| Player controller script | `scripts/player.gd` | player task |
|
||||
| Game composition scene | `scenes/game.tscn` | player task |
|
||||
|
||||
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||
|
||||
## 3. Player spawn convention
|
||||
|
||||
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||
That node's world transform is the spawn point.
|
||||
|
||||
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||
hint." (Confirmed present in Godot 4.6.2.)
|
||||
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||
player (spawn yaw is the player's concern, default forward is fine).
|
||||
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||
the floor and get popped out by the physics solver.
|
||||
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||
multiple spawns, that is a later convention — not PC2.)
|
||||
|
||||
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||
|
||||
```
|
||||
# in player.gd _ready()
|
||||
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||
if spawn is Node3D:
|
||||
global_position = spawn.global_position
|
||||
else:
|
||||
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||
```
|
||||
|
||||
The lookup runs from the current scene root so it finds the marker whether it is
|
||||
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||
on it (see §6).
|
||||
|
||||
## 4. Composition (who puts level + player together)
|
||||
|
||||
`scenes/game.tscn` is the PC2 playable entry point:
|
||||
|
||||
```
|
||||
Game (Node3D)
|
||||
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||
└── Player (instance of res://scenes/player.tscn)
|
||||
```
|
||||
|
||||
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||
instantiation, not code, so there is nothing to keep in sync.
|
||||
- The **level scene is self-contained and has zero dependency on the player** —
|
||||
it loads and is smoke-testable on its own. The level task does not instance or
|
||||
reference `player.tscn`.
|
||||
- The **player scene is self-contained and has zero dependency on any specific
|
||||
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||
|
||||
## 5. Level content requirements (level task)
|
||||
|
||||
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||
children:
|
||||
|
||||
| Node | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||
|
||||
Notes:
|
||||
|
||||
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||
behavior belongs to the player (or a future game-mode script), not the level.
|
||||
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||
coordinates, no thin walls the capsule can tunnel through.
|
||||
|
||||
## 6. Player contract (player task)
|
||||
|
||||
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||
|
||||
Minimum for PC2:
|
||||
|
||||
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||
the lowest-risk choice; third-person is fine too but is more work.
|
||||
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||
gracefully.
|
||||
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||
may be split into its own task if the controller grows.
|
||||
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||
|
||||
The player must work when instanced under `Game` alongside a level; it must not
|
||||
assume it is the only node in the scene.
|
||||
|
||||
## 7. Entry point / how a human plays it
|
||||
|
||||
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||
`application/run/main_scene` to it.
|
||||
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||
for this note** and out of scope for the level/player tasks. The current menu
|
||||
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||
the test level, that navigation wiring is its own small task — do not fold it
|
||||
into level or player.
|
||||
|
||||
## 8. Validation (both tasks must run this before "done")
|
||||
|
||||
```sh
|
||||
# one-time, first checkout only — builds the class/import cache
|
||||
godot --headless --path . --import
|
||||
|
||||
# then, to catch parse/load errors:
|
||||
godot --headless --path . --quit
|
||||
```
|
||||
|
||||
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||
and asserts the player's global position equals the `PlayerSpawn` position
|
||||
(within a small epsilon). This is the check that proves the spawn convention
|
||||
actually wired through — not just "the scene loads."
|
||||
|
||||
## 9. Explicitly out of scope for PC2
|
||||
|
||||
- Networking / multiplayer / prediction (single-player, local-only).
|
||||
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||
- Avatar customization UI and glTF avatar loading.
|
||||
- Join-a-world navigation from the world list.
|
||||
- Any content moderation / age-gating logic in the level.
|
||||
|
||||
## 10. Summary
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||
| Level location | `scenes/worlds/test_level.tscn` |
|
||||
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||
@@ -0,0 +1,159 @@
|
||||
# GDScript Sandboxing for User-Written Scripts
|
||||
|
||||
> **Status:** Proposed
|
||||
> **Owner:** Platform team
|
||||
> **Related:** Content moderation & age verification (scripts are one of the
|
||||
> three moderated content types), avatar component system
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document evaluates how to safely run **user-written scripts** as part of
|
||||
UGC (user-generated content) in **agp-game**, and recommends an approach.
|
||||
|
||||
**Bottom line: Godot 4 has no built-in security sandbox for GDScript, and
|
||||
none of the three options named in the original task hold up under testing.**
|
||||
A script running anywhere inside the Godot process — however it's loaded,
|
||||
parented, or wrapped — runs with full engine privileges: arbitrary process
|
||||
execution, full filesystem read/write, and raw network access. Sandboxing
|
||||
has to happen *outside* the scripting language, not through an in-engine
|
||||
restriction mechanism, because no such mechanism exists.
|
||||
|
||||
This was verified directly against Godot 4.6.2 (`godot --headless`, evidence
|
||||
in `sandbox_probe/` on this branch) rather than assumed from documentation,
|
||||
some of which (see §2.1) describes APIs that don't actually exist in this
|
||||
version.
|
||||
|
||||
## 2. Options Evaluated
|
||||
|
||||
### 2.1 Godot's built-in "ScriptServer" restrictions — does not exist
|
||||
|
||||
There is no `ScriptServer` class in Godot 4.6.2:
|
||||
|
||||
```
|
||||
ClassDB.class_exists("ScriptServer") → false
|
||||
```
|
||||
|
||||
There's also no project setting to disable engine subsystems for scripts —
|
||||
none of `application/run/disable_os`, `disable_file_access`, or
|
||||
`disable_network` exist as settings, and `ProjectSettings.get_property_list()`
|
||||
returns nothing matching "sandbox", "restrict", or "security". Any plan that
|
||||
assumes a flag like this exists needs to be dropped; it was likely conflated
|
||||
with sandboxing features from a different engine/scripting ecosystem, or
|
||||
with **export template stripping** (which controls what ships in a build,
|
||||
not what a script can call at runtime).
|
||||
|
||||
Directly probed capabilities of an ordinary GDScript running as a normal
|
||||
node script, no special setup:
|
||||
|
||||
| Capability | Available to any script? |
|
||||
|---|---|
|
||||
| `OS.execute(...)` — run arbitrary processes | ✅ Yes |
|
||||
| `FileAccess.open(...)` — read/write any path the OS user can reach | ✅ Yes (read `/etc/hostname`, write `/tmp/...` both succeeded) |
|
||||
| `TCPServer` / `HTTPClient` / `HTTPRequest` / `PacketPeerUDP` / `WebSocketPeer` | ✅ All instantiate and work |
|
||||
| `ClassDB.instantiate(...)` — construct any engine class by name | ✅ Yes |
|
||||
| Loading and running a second script from source text at runtime (`GDScript.new()` + `.reload()`) | ✅ Yes |
|
||||
|
||||
**Verdict: rejected.** There is nothing to configure here — the restriction
|
||||
surface this option assumed doesn't exist in the engine.
|
||||
|
||||
### 2.2 Isolated SubViewport with a limited API — doesn't isolate scripts
|
||||
|
||||
`SubViewport` is a **rendering and input isolation** primitive (it gives a
|
||||
scene subtree its own render target and can gate input propagation) — it is
|
||||
not a security or scripting boundary. A script attached to any node inside a
|
||||
`SubViewport` still runs in the same process, on the same GDScript VM, with
|
||||
the exact same access to `OS`, `FileAccess`, networking classes, and
|
||||
`ClassDB` as a script anywhere else in the tree. Nothing about being
|
||||
"inside" a SubViewport changes what API calls are reachable from a script.
|
||||
|
||||
**Verdict: rejected.** This solves a rendering/UI-composition problem, not
|
||||
the security problem the task is asking about. Worth keeping in mind for
|
||||
other purposes (e.g. isolated preview rendering of user avatars), but it
|
||||
provides zero script sandboxing.
|
||||
|
||||
### 2.3 Custom GDScript interpreter with an allowlist — right idea, wrong layer for "custom interpreter"
|
||||
|
||||
Two ways to read this option, tested separately:
|
||||
|
||||
**(a) Write/embed a separate GDScript parser+interpreter.** Not
|
||||
practical — this means re-implementing (and then indefinitely maintaining,
|
||||
in lockstep with every Godot release) a full language interpreter outside
|
||||
the engine. Far more engineering than the problem justifies.
|
||||
|
||||
**(b) Use Godot's built-in `Expression` class as a restricted subset.**
|
||||
`Expression` *looks* like a natural allowlist mechanism — it only evaluates
|
||||
a single expression, no control flow, no arbitrary statements. But it isn't
|
||||
safe by default: it can still reach through to unsafe engine methods if you
|
||||
aren't careful about what object you hand it as a base instance:
|
||||
|
||||
```gdscript
|
||||
var expr = Expression.new()
|
||||
expr.parse("get_environment('HOME')", ["OS"])
|
||||
expr.execute([], OS) # → "/home/daddy" (successfully called OS.get_environment)
|
||||
```
|
||||
|
||||
So `Expression` is a viable building block only if the host **never passes
|
||||
a base instance (or arguments) that exposes anything sensitive** — i.e. the
|
||||
allowlisting work still has to happen at the call site, `Expression` itself
|
||||
enforces none of it.
|
||||
|
||||
**Static text pattern-matching (blocklisting dangerous substrings like
|
||||
`"OS.execute"` before parsing) was also probed and is not a real
|
||||
defense** — it's trivially defeated by string concatenation, `call()`,
|
||||
`Callable`, or any other indirection, and produces false confidence.
|
||||
|
||||
**Verdict: partially viable, but only as a foundation, not a solution on
|
||||
its own.** A hand-built, narrow, data-driven DSL (not full GDScript, not
|
||||
raw `Expression`) with an explicit allowlist of safe operations and *no
|
||||
ability to reference engine singletons or arbitrary objects* is the only
|
||||
version of "custom interpreter" that's actually safe. This is real
|
||||
engineering effort, not a config flag.
|
||||
|
||||
## 3. Recommendation
|
||||
|
||||
**Don't run untrusted GDScript in-process at all.** Given none of the three
|
||||
in-engine options provide a real boundary, the two approaches actually worth
|
||||
building are, in order of recommended priority:
|
||||
|
||||
1. **Primary: a narrow, non-Turing-complete content DSL**, not general
|
||||
scripting. Most UGC scripting needs in a game like this (custom
|
||||
interactions, simple triggers, item behaviors) don't need a general
|
||||
programming language — they need a small set of declarative
|
||||
verbs/conditions (e.g. `on_interact: give_item("torch")`,
|
||||
`on_enter: play_sound("chime")`). Expressed as data (JSON/Resource, not
|
||||
code) and interpreted by trusted, hand-written GDScript on the engine
|
||||
side. This has *zero* attack surface by construction — there's no
|
||||
interpreter to escape because there's no general execution, just a
|
||||
fixed, reviewed set of verbs. This is almost certainly sufficient for
|
||||
the actual UGC use cases in this game and should be scoped against real
|
||||
feature requirements before assuming more is needed.
|
||||
|
||||
2. **If genuinely general scripting becomes a hard requirement**, isolate it
|
||||
at the OS level, not the engine level: run untrusted scripts in a
|
||||
**separate process** (a stripped-down Godot instance or a dedicated
|
||||
interpreter) under OS sandboxing (a restricted user account, a
|
||||
container, or `firejail`/`bwrap`/seccomp), communicating with the main
|
||||
game over a narrow IPC/RPC boundary that only exposes intentionally-safe
|
||||
operations. This is the only approach that provides a real security
|
||||
boundary for arbitrary code, because it doesn't depend on the scripting
|
||||
language cooperating — the OS enforces it. Substantially more
|
||||
infrastructure than option 1; only worth it if option 1's DSL genuinely
|
||||
can't express what's needed.
|
||||
|
||||
**Do not** attempt in-process GDScript sandboxing via SubViewport,
|
||||
ScriptServer, or ad hoc blocklisting — all three were directly tested and
|
||||
none provide a real boundary (§2).
|
||||
|
||||
## 4. Open Questions
|
||||
|
||||
- What do actual planned UGC scripts need to *do*? (Trigger conditions,
|
||||
simple item/state effects, dialogue branching?) This determines whether
|
||||
the DSL in §3.1 is sufficient or whether §3.2's process isolation is
|
||||
needed. Needs a concrete feature list before implementation starts.
|
||||
- If §3.2 is needed later, does it run per-user-session or shared/pooled?
|
||||
Affects resource cost and isolation guarantees.
|
||||
- Interaction with the content moderation pipeline (`content_moderation.md`):
|
||||
scripts as a moderated content type still need a moderation flag path
|
||||
regardless of which execution model is chosen — a DSL makes static
|
||||
analysis of *what a script can possibly do* far easier than it would be
|
||||
for general code, which is an additional point in its favor.
|
||||
@@ -0,0 +1,737 @@
|
||||
# World Chunk Data Format & Persistence Architecture
|
||||
|
||||
> **Status:** Proposed
|
||||
> **Owner:** Platform team
|
||||
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document specifies the **world chunk data format** and **persistence
|
||||
schema** for **agp-game**, a Godot-based multiplayer game built by autonomous
|
||||
agents. It defines:
|
||||
|
||||
- The **chunk data format** — both a human-readable **JSON** form (for
|
||||
authoring, tooling, and debugging) and a compact **binary** form (for
|
||||
runtime loading and network transfer).
|
||||
- The **SQLite schema** that persists world state on the server, including
|
||||
chunk metadata, chunk payloads, and the world/room hierarchy.
|
||||
- The **read/write API** that the server and client use to load, save, and
|
||||
stream chunks.
|
||||
|
||||
The goal is a **single, versioned chunk contract** that is shared across the
|
||||
Godot client, the Python backend, and the persistence layer. A chunk is the
|
||||
atomic unit of world geometry, gameplay objects, and persistence — the server
|
||||
loads and saves whole chunks, and clients stream the chunks around them.
|
||||
|
||||
### 1.1 Design Goals
|
||||
|
||||
- **Versioned & forward-compatible:** Every chunk carries a format version so
|
||||
the loader can migrate old data and reject unknown future data gracefully.
|
||||
- **Dual representation:** JSON for authoring/debugging, binary for
|
||||
runtime/network. Both encode the *same* logical model so there is exactly one
|
||||
source of truth for the schema.
|
||||
- **Server-authoritative persistence:** The server is the only writer to the
|
||||
SQLite store. Clients submit *edits* (deltas) and the server applies and
|
||||
persists them, mirroring the server-authoritative networking model.
|
||||
- **Chunk-granular persistence:** Persistence happens at chunk granularity so
|
||||
the server can save a dirty chunk without rewriting the whole world, and can
|
||||
stream only the chunks a client needs.
|
||||
- **Deterministic & testable:** The binary format is byte-stable for a given
|
||||
logical payload, enabling checksums, caching, and reproducible tests.
|
||||
- **Moderation-aware:** Chunks reference content (assets, scripts) that carry
|
||||
content flags (see `content_moderation.md`); the persistence layer stores the
|
||||
moderation state alongside chunk data so enforcement can happen at load time.
|
||||
|
||||
---
|
||||
|
||||
## 2. World Model & Chunking
|
||||
|
||||
### 2.1 World Hierarchy
|
||||
|
||||
The world is organized as a hierarchy that matches the moderation content graph:
|
||||
|
||||
```
|
||||
World
|
||||
└── Room
|
||||
└── Chunk (the atomic persistence & streaming unit)
|
||||
├── Tiles / Terrain
|
||||
├── Static objects (props, geometry)
|
||||
├── Dynamic objects (spawnable entities, interactables)
|
||||
└── Metadata (flags, version, timestamps)
|
||||
```
|
||||
|
||||
- A **World** is the top-level container (a named, versioned space).
|
||||
- A **Room** is a bounded region within a world (a level, a building, a zone).
|
||||
- A **Chunk** is a fixed-size, axis-aligned region of a room. It is the unit of
|
||||
load, save, and stream.
|
||||
|
||||
### 2.2 Chunk Dimensions
|
||||
|
||||
Chunks are **fixed-size, axis-aligned boxes** in world space. The default
|
||||
dimensions are a configuration constant so they can be tuned per world:
|
||||
|
||||
| Constant | Default | Description |
|
||||
|---------------------|---------|----------------------------------------------------|
|
||||
| `CHUNK_SIZE_X` | 32 | Chunk width in world units (tiles). |
|
||||
| `CHUNK_SIZE_Y` | 32 | Chunk height in world units (tiles). |
|
||||
| `CHUNK_SIZE_Z` | 1 | Chunk depth (1 = flat 2D world; >1 = volumetric). |
|
||||
| `CHUNK_ORIGIN` | `(0,0)` | World-space origin of chunk coordinate `(0,0)`. |
|
||||
|
||||
A chunk is identified by its **integer chunk coordinate** `(cx, cy, cz)`,
|
||||
computed from a world-space position `p` as:
|
||||
|
||||
```
|
||||
cx = floor((p.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)
|
||||
cy = floor((p.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)
|
||||
cz = floor((p.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z)
|
||||
```
|
||||
|
||||
The chunk's world-space bounding box is therefore:
|
||||
|
||||
```
|
||||
min = (CHUNK_ORIGIN.x + cx*CHUNK_SIZE_X, ...)
|
||||
max = min + (CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z)
|
||||
```
|
||||
|
||||
### 2.3 Chunk Coordinate Key
|
||||
|
||||
For storage and lookup, a chunk is addressed by a **canonical string key**:
|
||||
|
||||
```
|
||||
"{world_id}:{room_id}:{cx}:{cy}:{cz}"
|
||||
```
|
||||
|
||||
Example: `"w_0001:r_0001:12:-4:0"`. This key is the primary lookup key in the
|
||||
SQLite `chunks` table and is used in the read/write API.
|
||||
|
||||
---
|
||||
|
||||
## 3. Chunk Data Format
|
||||
|
||||
### 3.1 Logical Model
|
||||
|
||||
Both the JSON and binary encodings serialize the same logical chunk model. The
|
||||
logical model is defined here once; the two encodings are projections of it.
|
||||
|
||||
```
|
||||
Chunk
|
||||
├── version : uint (format version, currently 1)
|
||||
├── key : string (canonical chunk key, see §2.3)
|
||||
├── world_id : string
|
||||
├── room_id : string
|
||||
├── coord : { cx, cy, cz } (int)
|
||||
├── bounds : { min_x, min_y, min_z, max_x, max_y, max_z } (float)
|
||||
├── tiles : TileLayer[] (terrain / tile data)
|
||||
├── objects : WorldObject[] (static + dynamic objects)
|
||||
├── flags : ContentFlags (moderation flags, see §3.5)
|
||||
├── meta : { created_at, updated_at, revision, author }
|
||||
└── checksum : string (SHA-256 of canonical payload)
|
||||
```
|
||||
|
||||
### 3.2 Tile Layer
|
||||
|
||||
A chunk's terrain is stored as one or more **tile layers**. Each layer is a
|
||||
dense grid of tile IDs of size `CHUNK_SIZE_X × CHUNK_SIZE_Y` (× `CHUNK_SIZE_Z`
|
||||
for volumetric worlds).
|
||||
|
||||
```
|
||||
TileLayer
|
||||
├── name : string (e.g. "ground", "walls", "decor")
|
||||
├── layer_id : uint
|
||||
├── width : uint (= CHUNK_SIZE_X)
|
||||
├── height : uint (= CHUNK_SIZE_Y)
|
||||
├── depth : uint (= CHUNK_SIZE_Z)
|
||||
├── tiles : uint[] (flattened tile IDs, row-major, length = w*h*d)
|
||||
└── palette : TileDef[] (tile ID → tile definition, shared per layer)
|
||||
```
|
||||
|
||||
**Tile encoding:** A tile ID is a `uint16` index into the layer's `palette`.
|
||||
`0` is reserved for "empty/air". The palette maps each tile ID to a `TileDef`:
|
||||
|
||||
```
|
||||
TileDef
|
||||
├── id : uint16
|
||||
├── asset_id : string (reference to a tile/terrain asset)
|
||||
├── solid : bool (collision)
|
||||
├── flags : uint32 (bitmask: walkable, climbable, liquid, ...)
|
||||
└── variants : uint8 (number of visual variants for this tile)
|
||||
```
|
||||
|
||||
**Rationale for palette + indices:** Storing dense `uint16` indices rather than
|
||||
full asset strings per cell keeps the binary payload small (2 bytes/cell) while
|
||||
the palette (shared across the layer) carries the rich per-tile metadata.
|
||||
|
||||
### 3.3 World Objects
|
||||
|
||||
Objects are the non-tile entities in a chunk: static props, interactables,
|
||||
spawn points, and dynamic-object templates.
|
||||
|
||||
```
|
||||
WorldObject
|
||||
├── id : string (stable object id, unique within the chunk)
|
||||
├── type : string (e.g. "prop", "interactable", "spawn_point", "door")
|
||||
├── asset_id : string (reference to the object's mesh/glTF asset)
|
||||
├── transform : { pos: [x,y,z], rot: [x,y,z,w], scale: [x,y,z] }
|
||||
├── properties : object (type-specific key/value map, JSON-typed)
|
||||
├── dynamic : bool (true if this is a spawnable dynamic entity)
|
||||
└── flags : ContentFlags (per-object moderation flags)
|
||||
```
|
||||
|
||||
**Dynamic objects** are stored as *templates* in the chunk. Their *runtime
|
||||
state* (position, health, inventory) is **not** stored in the chunk; it lives
|
||||
in the runtime entity store and is persisted separately (see §4.4). This keeps
|
||||
chunks stable and diffable — a chunk only changes when its *static* content
|
||||
changes.
|
||||
|
||||
### 3.4 Content Flags
|
||||
|
||||
Each chunk (and each object) carries a **content flag set** that is the
|
||||
aggregate of the moderation flags of the assets and scripts it references. This
|
||||
mirrors the aggregation rules in `content_moderation.md`:
|
||||
|
||||
```
|
||||
ContentFlags
|
||||
├── categories : { violence, sexual, language, substance, gambling, horror, hate, custom }
|
||||
│ (each → severity: none|mild|moderate|severe)
|
||||
├── mature : bool (true if any contributing item is mature)
|
||||
└── sources : string[] (flag ids that contributed, for audit)
|
||||
```
|
||||
|
||||
The server computes and persists these flags at write time so that enforcement
|
||||
(age gating, content filtering) can happen at load time without re-scanning
|
||||
assets.
|
||||
|
||||
### 3.5 JSON Encoding
|
||||
|
||||
The JSON encoding is the **authoring and debugging** representation. It is
|
||||
human-readable, diffable, and used by tools and tests. It is the canonical
|
||||
logical model serialized directly.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"key": "w_0001:r_0001:12:-4:0",
|
||||
"world_id": "w_0001",
|
||||
"room_id": "r_0001",
|
||||
"coord": { "cx": 12, "cy": -4, "cz": 0 },
|
||||
"bounds": { "min_x": 384.0, "min_y": -128.0, "min_z": 0.0,
|
||||
"max_x": 416.0, "max_y": -96.0, "max_z": 1.0 },
|
||||
"tiles": [
|
||||
{
|
||||
"name": "ground",
|
||||
"layer_id": 0,
|
||||
"width": 32,
|
||||
"height": 32,
|
||||
"depth": 1,
|
||||
"palette": [
|
||||
{ "id": 0, "asset_id": "", "solid": false, "flags": 0, "variants": 1 },
|
||||
{ "id": 1, "asset_id": "tiles/grass", "solid": false, "flags": 1, "variants": 4 }
|
||||
],
|
||||
"tiles": [0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1]
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"id": "obj_0001",
|
||||
"type": "interactable",
|
||||
"asset_id": "props/chest",
|
||||
"transform": { "pos": [400.0, -110.0, 0.0],
|
||||
"rot": [0.0, 0.0, 0.0, 1.0],
|
||||
"scale": [1.0, 1.0, 1.0] },
|
||||
"properties": { "loot_table": "chest_basic", "locked": false },
|
||||
"dynamic": false,
|
||||
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] }
|
||||
}
|
||||
],
|
||||
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] },
|
||||
"meta": { "created_at": "2026-08-17T00:00:00Z",
|
||||
"updated_at": "2026-08-17T00:00:00Z",
|
||||
"revision": 3,
|
||||
"author": "agent-bot" },
|
||||
"checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on the example checksum:** The value shown above is illustrative only.
|
||||
> In practice the `checksum` field is the SHA-256 of the canonical binary
|
||||
> payload body (see §3.6.3), not of the JSON text. The value here is a
|
||||
> placeholder for documentation purposes.
|
||||
|
||||
### 3.6 Binary Encoding
|
||||
|
||||
The binary encoding is the **runtime and network** representation. It is compact
|
||||
and byte-stable. It uses a simple, explicitly-specified layout (no external
|
||||
serialization library) so it can be produced and consumed identically by the
|
||||
Godot client (GDScript) and the Python backend.
|
||||
|
||||
#### 3.6.1 Header
|
||||
|
||||
| Offset | Size | Field | Type / Encoding |
|
||||
|--------|------|--------------|------------------------------------------|
|
||||
| 0 | 4 | magic | ASCII `"AGPC"` (0x41 0x47 0x50 0x43) |
|
||||
| 4 | 2 | version | `uint16` LE (currently `1`) |
|
||||
| 6 | 2 | header_len | `uint16` LE (bytes of header after this) |
|
||||
| 8 | 4 | flags | `uint32` LE (bitmask, reserved) |
|
||||
| 12 | 4 | payload_len | `uint32` LE (bytes of payload body) |
|
||||
| 16 | 32 | checksum | SHA-256 of payload body (hex, 32 bytes) |
|
||||
|
||||
Total header size: **48 bytes**.
|
||||
|
||||
#### 3.6.2 Payload Body
|
||||
|
||||
The payload body is a sequence of typed sections. Each section has a small
|
||||
header:
|
||||
|
||||
| Offset | Size | Field | Type / Encoding |
|
||||
|--------|------|------------|----------------------------|
|
||||
| 0 | 1 | section_id | `uint8` (see table below) |
|
||||
| 1 | 4 | section_len| `uint32` LE (bytes of section data) |
|
||||
| 5 | ... | data | section-specific |
|
||||
|
||||
Section IDs:
|
||||
|
||||
| section_id | Section | Data layout |
|
||||
|------------|----------------|-------------------------------------------------------------------|
|
||||
| `0x01` | metadata | world_id, room_id (length-prefixed strings), coord (3×int32), bounds (6×float32) |
|
||||
| `0x02` | tile layer | name, layer_id, dims (3×uint16), palette, dense tile indices |
|
||||
| `0x03` | object | one `WorldObject` (repeated; one section per object) |
|
||||
| `0x04` | content flags | category severities (8×uint8), mature (uint8), sources (string list) |
|
||||
| `0x05` | meta | created_at, updated_at (int64 unix), revision (uint32), author (string) |
|
||||
|
||||
**String encoding:** length-prefixed UTF-8 — `uint16` LE byte length followed by
|
||||
the UTF-8 bytes.
|
||||
|
||||
**Tile layer data layout (section `0x02`):**
|
||||
|
||||
```
|
||||
name : string
|
||||
layer_id : uint16 LE
|
||||
width : uint16 LE
|
||||
height : uint16 LE
|
||||
depth : uint16 LE
|
||||
palette_count : uint16 LE
|
||||
palette : palette_count × TileDef
|
||||
tile_count : uint32 LE
|
||||
tiles : tile_count × uint16 LE (dense, row-major)
|
||||
```
|
||||
|
||||
```
|
||||
TileDef:
|
||||
id : uint16 LE
|
||||
asset_id : string
|
||||
solid : uint8
|
||||
flags : uint32 LE
|
||||
variants : uint8
|
||||
```
|
||||
|
||||
**Object data layout (section `0x03`):**
|
||||
|
||||
```
|
||||
id : string
|
||||
type : string
|
||||
asset_id : string
|
||||
pos : 3 × float32
|
||||
rot : 4 × float32
|
||||
scale : 3 × float32
|
||||
dynamic : uint8
|
||||
properties : JSON string (length-prefixed)
|
||||
flags : ContentFlags (see 0x04 layout)
|
||||
```
|
||||
|
||||
**Content flags layout (section `0x04`):**
|
||||
|
||||
```
|
||||
violence, sexual, language, substance, gambling, horror, hate, custom
|
||||
: 8 × uint8 (severity enum: 0=none,1=mild,2=moderate,3=severe)
|
||||
mature : uint8
|
||||
source_count: uint16 LE
|
||||
sources : source_count × string
|
||||
```
|
||||
|
||||
#### 3.6.3 Byte-Stability
|
||||
|
||||
For a given logical payload, the binary encoding is **deterministic**:
|
||||
|
||||
- All integers are little-endian.
|
||||
- All strings are UTF-8, length-prefixed, and never null-terminated.
|
||||
- Maps (e.g. `properties`) are serialized as a **canonical JSON string** with
|
||||
keys sorted lexicographically.
|
||||
- The `checksum` is the SHA-256 of the payload body bytes.
|
||||
|
||||
This guarantees that two encoders produce identical bytes, enabling content
|
||||
addressing, caching, and reproducible tests.
|
||||
|
||||
---
|
||||
|
||||
## 4. SQLite Persistence Schema
|
||||
|
||||
The server persists world state in a **SQLite** database. SQLite is chosen for
|
||||
its zero-configuration, single-file, transactional, and embedded nature — ideal
|
||||
for a server that owns the world store and needs ACID guarantees without a
|
||||
separate database process.
|
||||
|
||||
### 4.1 Database Layout
|
||||
|
||||
The database file is `world.db` (path configurable). It contains the following
|
||||
tables:
|
||||
|
||||
```
|
||||
worlds
|
||||
rooms
|
||||
chunks
|
||||
chunk_objects
|
||||
content_flags
|
||||
revisions
|
||||
```
|
||||
|
||||
### 4.2 `worlds`
|
||||
|
||||
Top-level world containers.
|
||||
|
||||
```sql
|
||||
CREATE TABLE worlds (
|
||||
id TEXT PRIMARY KEY, -- e.g. 'w_0001'
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
chunk_size_x INTEGER NOT NULL DEFAULT 32,
|
||||
chunk_size_y INTEGER NOT NULL DEFAULT 32,
|
||||
chunk_size_z INTEGER NOT NULL DEFAULT 1,
|
||||
origin_x REAL NOT NULL DEFAULT 0,
|
||||
origin_y REAL NOT NULL DEFAULT 0,
|
||||
origin_z REAL NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL, -- ISO-8601 UTC
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 4.3 `rooms`
|
||||
|
||||
Rooms within a world.
|
||||
|
||||
```sql
|
||||
CREATE TABLE rooms (
|
||||
id TEXT PRIMARY KEY, -- e.g. 'r_0001'
|
||||
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
bounds_min_x REAL NOT NULL,
|
||||
bounds_min_y REAL NOT NULL,
|
||||
bounds_min_z REAL NOT NULL DEFAULT 0,
|
||||
bounds_max_x REAL NOT NULL,
|
||||
bounds_max_y REAL NOT NULL,
|
||||
bounds_max_z REAL NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (world_id, name)
|
||||
);
|
||||
CREATE INDEX idx_rooms_world ON rooms(world_id);
|
||||
```
|
||||
|
||||
### 4.4 `chunks`
|
||||
|
||||
The core persistence table. One row per chunk. The **payload** is stored as a
|
||||
BLOB in the **binary format** (§3.6); the JSON form is derived on demand for
|
||||
authoring/debugging and is not stored (to avoid dual-write drift).
|
||||
|
||||
```sql
|
||||
CREATE TABLE chunks (
|
||||
key TEXT PRIMARY KEY, -- canonical key 'world:room:cx:cy:cz'
|
||||
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
||||
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
cx INTEGER NOT NULL,
|
||||
cy INTEGER NOT NULL,
|
||||
cz INTEGER NOT NULL DEFAULT 0,
|
||||
format_version INTEGER NOT NULL DEFAULT 1,
|
||||
payload BLOB NOT NULL, -- binary chunk encoding (§3.6)
|
||||
payload_len INTEGER NOT NULL,
|
||||
checksum TEXT NOT NULL, -- SHA-256 of payload
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
dirty INTEGER NOT NULL DEFAULT 0, -- 1 = needs flush to disk
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (world_id, room_id, cx, cy, cz)
|
||||
);
|
||||
CREATE INDEX idx_chunks_world_room ON chunks(world_id, room_id);
|
||||
CREATE INDEX idx_chunks_coord ON chunks(cx, cy, cz);
|
||||
```
|
||||
|
||||
**Design notes:**
|
||||
|
||||
- The `payload` BLOB is the single source of truth for chunk content. The
|
||||
`checksum` guards against corruption and enables content addressing.
|
||||
- `revision` increments on every write; it is used for optimistic concurrency
|
||||
and change detection (see §5.4).
|
||||
- `dirty` marks chunks that have been modified in memory but not yet flushed,
|
||||
enabling a write-behind persistence strategy (see §5.5).
|
||||
|
||||
### 4.5 `chunk_objects`
|
||||
|
||||
A denormalized, queryable index of the objects inside each chunk. This is
|
||||
**derived** from the chunk payload and kept in sync by the write path. It exists
|
||||
so the server can answer queries like "which chunks contain a door?" without
|
||||
parsing every payload BLOB.
|
||||
|
||||
```sql
|
||||
CREATE TABLE chunk_objects (
|
||||
id TEXT NOT NULL, -- object id (unique within chunk)
|
||||
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL, -- 'prop', 'interactable', ...
|
||||
asset_id TEXT,
|
||||
dynamic INTEGER NOT NULL DEFAULT 0,
|
||||
pos_x REAL NOT NULL,
|
||||
pos_y REAL NOT NULL,
|
||||
pos_z REAL NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (chunk_key, id)
|
||||
);
|
||||
CREATE INDEX idx_chunk_objects_type ON chunk_objects(type);
|
||||
CREATE INDEX idx_chunk_objects_asset ON chunk_objects(asset_id);
|
||||
```
|
||||
|
||||
**Sync mechanism:** `chunk_objects` is maintained **explicitly by the write
|
||||
path** — it is not a SQL trigger. Whenever `put_chunk` (or `delete_chunk`)
|
||||
commits a chunk, the service layer performs a **delete-and-reinsert** of that
|
||||
chunk's object rows in the same transaction: it deletes all rows for
|
||||
`chunk_key`, then inserts fresh rows parsed from the new payload. This keeps the
|
||||
index transactionally consistent with the payload and avoids the complexity and
|
||||
fragility of SQL triggers over a binary BLOB. Because the index is derived, it
|
||||
can always be rebuilt from `chunks.payload` if it ever drifts (a `rebuild_index`
|
||||
utility is provided for this).
|
||||
|
||||
### 4.6 `content_flags`
|
||||
|
||||
Persists the aggregated moderation flags per chunk (and optionally per object).
|
||||
This lets the server enforce age/content gates at load time without parsing
|
||||
payloads.
|
||||
|
||||
```sql
|
||||
CREATE TABLE content_flags (
|
||||
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
|
||||
object_id TEXT NOT NULL DEFAULT '', -- '' = chunk-level flag (sentinel)
|
||||
violence INTEGER NOT NULL DEFAULT 0,
|
||||
sexual INTEGER NOT NULL DEFAULT 0,
|
||||
language INTEGER NOT NULL DEFAULT 0,
|
||||
substance INTEGER NOT NULL DEFAULT 0,
|
||||
gambling INTEGER NOT NULL DEFAULT 0,
|
||||
horror INTEGER NOT NULL DEFAULT 0,
|
||||
hate INTEGER NOT NULL DEFAULT 0,
|
||||
custom INTEGER NOT NULL DEFAULT 0,
|
||||
mature INTEGER NOT NULL DEFAULT 0,
|
||||
sources TEXT NOT NULL DEFAULT '[]', -- JSON array of flag ids
|
||||
PRIMARY KEY (chunk_key, object_id)
|
||||
);
|
||||
CREATE INDEX idx_content_flags_mature ON content_flags(mature);
|
||||
```
|
||||
|
||||
Severity is stored as `0=none, 1=mild, 2=moderate, 3=severe`.
|
||||
|
||||
**Chunk-level vs. object-level flags:** Chunk-level flags use the **sentinel
|
||||
value `''` (empty string)** in `object_id` rather than `NULL`. This is
|
||||
deliberate: in SQLite, `NULL` values in a composite primary key are **not**
|
||||
unique-constrained, so two rows with the same `chunk_key` and `object_id = NULL`
|
||||
could be inserted accidentally. Using `''` as the sentinel means the composite
|
||||
`PRIMARY KEY (chunk_key, object_id)` enforces that there is **exactly one**
|
||||
chunk-level flag row per chunk. Object-level flags use the object's actual `id`
|
||||
(which is never empty). The application layer treats `object_id = ''` as
|
||||
"chunk-level" and any non-empty value as "object-level".
|
||||
|
||||
### 4.7 `revisions`
|
||||
|
||||
An append-only audit log of chunk writes. Enables rollback, auditing, and
|
||||
replication of changes.
|
||||
|
||||
```sql
|
||||
CREATE TABLE revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chunk_key TEXT NOT NULL, -- NOT a FK: survives chunk deletion
|
||||
revision INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL, -- user / agent id
|
||||
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
|
||||
checksum TEXT NOT NULL, -- SHA-256 of payload
|
||||
payload BLOB, -- full binary payload at this revision
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_revisions_chunk ON revisions(chunk_key);
|
||||
```
|
||||
|
||||
**Rollback support:** The `payload` BLOB stores the **full binary chunk payload**
|
||||
at each revision. This is what makes rollback possible: to restore a chunk to a
|
||||
previous revision, the server reads the `payload` of the target revision and
|
||||
writes it back to `chunks.payload` (incrementing `revision` and appending a new
|
||||
`revisions` row). Without the payload, only the checksum would be available,
|
||||
which is insufficient to reconstruct the chunk.
|
||||
|
||||
**Audit trail preservation:** `chunk_key` is **not** a foreign key to `chunks`.
|
||||
This means deleting a chunk does **not** cascade-delete its revision history —
|
||||
the audit trail is preserved even after a chunk is removed. (The `chunk_key`
|
||||
string is still stored so the history can be correlated to a chunk that existed
|
||||
at that time.) If a chunk is later recreated with the same key, its history is
|
||||
still intact.
|
||||
|
||||
### 4.8 Schema Versioning & Migrations
|
||||
|
||||
The database carries a schema version in a `meta` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
-- seed: INSERT INTO meta(key, value) VALUES ('schema_version', '1');
|
||||
```
|
||||
|
||||
Migrations are applied in order at startup. Each migration is a numbered SQL
|
||||
script; the server tracks the applied version in `meta` and applies any pending
|
||||
migrations transactionally. This keeps the schema forward-compatible as the
|
||||
chunk format evolves.
|
||||
|
||||
---
|
||||
|
||||
## 5. Read/Write API
|
||||
|
||||
The API is exposed as a **Python service layer** on the backend (the same
|
||||
process that owns the SQLite store) and consumed by the Godot client over the
|
||||
network transport (see `networking.md`). The service layer is the **only**
|
||||
writer to the database; clients never touch SQLite directly.
|
||||
|
||||
### 5.1 Service Interface (Python)
|
||||
|
||||
```python
|
||||
class WorldStore:
|
||||
"""Owns the SQLite connection and chunk read/write operations."""
|
||||
|
||||
def get_chunk(self, key: str) -> Chunk | None: ...
|
||||
def get_chunk_binary(self, key: str) -> bytes | None: ...
|
||||
def put_chunk(self, chunk: Chunk, actor: str) -> Chunk: ...
|
||||
def delete_chunk(self, key: str, actor: str) -> None: ...
|
||||
def list_chunks(self, world_id: str, room_id: str | None = None,
|
||||
bounds: Bounds | None = None) -> list[str]: ...
|
||||
def get_chunk_flags(self, key: str) -> ContentFlags: ...
|
||||
def flush_dirty(self) -> int: ...
|
||||
def rollback_chunk(self, key: str, revision: int, actor: str) -> Chunk: ...
|
||||
def rebuild_index(self, chunk_key: str | None = None) -> int: ...
|
||||
def migrate(self) -> None: ...
|
||||
```
|
||||
|
||||
### 5.2 Encoding Helpers
|
||||
|
||||
The service layer provides symmetric encode/decode functions shared by the
|
||||
client and server:
|
||||
|
||||
```python
|
||||
def encode_binary(chunk: Chunk) -> bytes: ...
|
||||
def decode_binary(data: bytes) -> Chunk: ...
|
||||
def encode_json(chunk: Chunk) -> str: ...
|
||||
def decode_json(text: str) -> Chunk: ...
|
||||
def checksum(payload: bytes) -> str: ...
|
||||
```
|
||||
|
||||
These are the single implementation of the format spec (§3), so the Godot
|
||||
client and Python backend never drift.
|
||||
|
||||
### 5.3 Network RPCs
|
||||
|
||||
The chunk API is exposed to the client via RPCs (matching the networking
|
||||
architecture). All chunk traffic is **reliable** (chunks are discrete,
|
||||
must-arrive data).
|
||||
|
||||
| RPC | Direction | Payload | Notes |
|
||||
|----------------------------|-----------|-------------------------------------------|-------|
|
||||
| `request_chunk` | C → S | `{ key }` | Client asks for a chunk |
|
||||
| `chunk_data` | S → C | `{ key, binary_payload, checksum }` | Server streams a chunk |
|
||||
| `request_chunk_edit` | C → S | `{ key, base_revision, delta }` | Client submits an edit |
|
||||
| `chunk_edit_result` | S → C | `{ key, success, new_revision, error? }` | Acknowledges an edit |
|
||||
| `chunk_invalidated` | S → C | `{ key, new_revision }` | Server pushes a change |
|
||||
| `request_chunk_flags` | C → S | `{ key }` | Age/content gate check |
|
||||
|
||||
### 5.4 Edit & Concurrency Model
|
||||
|
||||
- **Read:** Client requests a chunk; server returns the binary payload and its
|
||||
`revision`.
|
||||
- **Edit:** Client submits a **delta** against a known `base_revision`. The
|
||||
server applies the delta, increments `revision`, persists, and returns the new
|
||||
revision.
|
||||
- **Optimistic concurrency:** If the chunk's current revision differs from the
|
||||
client's `base_revision`, the server rejects the edit with a conflict error
|
||||
and the client re-fetches and re-applies. This prevents lost updates when two
|
||||
clients edit the same chunk.
|
||||
- **Deltas:** A delta is a JSON patch (RFC 6902 style) applied to the chunk's
|
||||
logical model. The server validates the patched chunk (schema, bounds,
|
||||
moderation) before persisting.
|
||||
|
||||
### 5.5 Write-Behind Persistence
|
||||
|
||||
To amortize disk I/O, the server keeps a **dirty set** of chunks modified in
|
||||
memory and flushes them to SQLite in batches:
|
||||
|
||||
1. `put_chunk` updates the in-memory chunk and marks it `dirty`.
|
||||
2. A background flusher periodically calls `flush_dirty()`, which writes all
|
||||
dirty chunks in a single transaction and clears the `dirty` flag.
|
||||
3. On graceful shutdown, `flush_dirty()` is called to ensure durability.
|
||||
|
||||
This keeps the hot path (in-memory chunk access) fast while guaranteeing
|
||||
durability at shutdown and on a configurable interval.
|
||||
|
||||
**Recovery after unclean shutdown:** The `dirty` column in the `chunks` table
|
||||
exists specifically to support recovery if the process crashes before a flush
|
||||
completes. On startup, the server scans for rows where `dirty = 1` and treats
|
||||
them as **unflushed in-memory state that was lost** — it re-reads the last
|
||||
persisted payload from `chunks.payload` (the last durable state) and clears the
|
||||
`dirty` flag. In other words, `dirty = 1` at startup means "the last write was
|
||||
not durable; discard the in-memory delta and fall back to the persisted
|
||||
payload." This guarantees that the on-disk state is always a consistent,
|
||||
previously-committed snapshot, never a partially-written chunk. (The in-memory
|
||||
dirty set is authoritative during normal operation; the `dirty` column is only a
|
||||
recovery marker.)
|
||||
|
||||
### 5.6 Streaming & Caching
|
||||
|
||||
- The server streams only the chunks a client needs (those within the client's
|
||||
view radius), using `list_chunks` with a bounds query.
|
||||
- Chunks are cached in memory (LRU) keyed by `key`; the cache is invalidated on
|
||||
write.
|
||||
- The binary payload is content-addressed by `checksum`, so unchanged chunks can
|
||||
be served from cache without re-reading SQLite.
|
||||
|
||||
---
|
||||
|
||||
## 6. Format Versioning & Migration
|
||||
|
||||
- The chunk format `version` field (currently `1`) is checked on decode. A
|
||||
decoder that encounters a higher version than it supports **rejects** the
|
||||
chunk with a clear error rather than misreading it.
|
||||
- The SQLite `schema_version` in `meta` drives database migrations (§4.8).
|
||||
- Chunk payloads are migrated **lazily**: when a chunk is read and its
|
||||
`format_version` is older than the current one, the server migrates it to the
|
||||
current version in memory and marks it dirty so it is rewritten on the next
|
||||
flush. This avoids a costly full-world migration at startup.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security & Integrity
|
||||
|
||||
- **Server-authoritative writes:** Only the `WorldStore` writes to SQLite;
|
||||
clients submit deltas that are validated before application.
|
||||
- **Checksum verification:** Every chunk payload is checksummed (SHA-256) on
|
||||
write and verified on read. Corruption is detected and the chunk is re-fetched
|
||||
or flagged.
|
||||
- **Bounds validation:** Chunk coordinates and object positions are validated
|
||||
against the world's chunk dimensions and room bounds before persistence.
|
||||
- **Moderation enforcement:** `content_flags` are checked at load time; mature
|
||||
chunks are gated behind the age-verification flow (see `content_moderation.md`).
|
||||
- **Input validation:** All string fields are length-limited and validated
|
||||
against allowed character sets to prevent injection and oversized payloads.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open Questions / Future Work
|
||||
|
||||
- **Volumetric worlds:** The schema and format already support `CHUNK_SIZE_Z >
|
||||
1`, but the tooling and streaming for 3D chunks are not yet specified.
|
||||
- **Chunk compression:** For very large worlds, the binary payload could be
|
||||
compressed (e.g. zstd) before storage/transfer. The header reserves a `flags`
|
||||
bitmask for a compression flag.
|
||||
- **Multi-server sharding:** If a single SQLite file becomes a bottleneck, the
|
||||
`chunks` table could be sharded by `world_id` across multiple databases. The
|
||||
key design already supports this (world_id is a leading key component).
|
||||
- **Delta compression for streaming:** Instead of sending full chunks, the
|
||||
server could send only the delta between revisions to reduce bandwidth for
|
||||
frequently-updated chunks.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Package Checkpoint 1 (PC1)
|
||||
|
||||
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
|
||||
clone (not a reused agent workspace).
|
||||
|
||||
## Automated validation
|
||||
|
||||
Run with `scripts/run_checkpoint_tests.py` — see
|
||||
`docs/testing/test_inventory.md` for exactly what each suite covers.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Alembic migrations, fresh DB | PASS |
|
||||
| Backend pytest | 68/68 PASS |
|
||||
| Godot smoke tests (4 files) | ALL PASS |
|
||||
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
|
||||
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
|
||||
|
||||
## Problems found during this checkpoint (fixed, tracked as tasks)
|
||||
|
||||
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
|
||||
done — a historical data gap (a review-feedback follow-up task's
|
||||
supersession link was never recorded, predating when that field started
|
||||
being tracked). Backfilled in the PM database; confirmed green.
|
||||
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
|
||||
actually 68; said no integration tests existed, 4 real ones had landed).
|
||||
Fixed via task `398bf1a22a2d` / PR #40.
|
||||
|
||||
## What's actually testable right now
|
||||
|
||||
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
|
||||
the real scope of PC1; don't expect more when playtesting.
|
||||
|
||||
- Register a new account (18+ only, rejected otherwise)
|
||||
- Log out, log back in with the same credentials
|
||||
- Play → Browse Worlds — loads a (currently empty) world list with no errors
|
||||
|
||||
## Manual playtest: backend on cloudbison, client on pinkbison
|
||||
|
||||
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
|
||||
env vars, no editing files. Foolproof enough to hand to someone else.
|
||||
|
||||
### 1. On cloudbison — the AGP dashboard
|
||||
|
||||
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
|
||||
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
|
||||
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
|
||||
the API server, all in one step. The page shows the LAN address it's
|
||||
reachable at once it's up (under a minute).
|
||||
|
||||
When you're done playtesting, click **Stop & Clean Up** on the same page —
|
||||
it tears down everything that step started (API server, Postgres
|
||||
container, its volume and network). Nothing is left running or lying
|
||||
around afterward, even if the dashboard itself gets restarted in between.
|
||||
|
||||
### 2. On pinkbison — download and run the .exe
|
||||
|
||||
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
|
||||
pick up newer changes) — this pulls the latest `main` and exports a Windows
|
||||
binary with cloudbison's backend address already baked in. Click
|
||||
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
|
||||
environment variable, no config file, no build step on pinkbison at all.
|
||||
|
||||
### 3. Tear down when done
|
||||
|
||||
**Stop & Clean Up** on the dashboard page. That's it.
|
||||
|
||||
## Status
|
||||
|
||||
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
|
||||
restricts agent claiming to the 5 PC1-scope features. Agents will not move
|
||||
on to post-PC1 work until that's widened.
|
||||
@@ -0,0 +1,41 @@
|
||||
# MVP Vertical Slice: Client-Server Wiring — Plan
|
||||
|
||||
## Goal
|
||||
Prove the real Godot client and the real backend can talk: **a user can register/log in
|
||||
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
|
||||
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
|
||||
explicitly out of scope.
|
||||
|
||||
## Verified backend contract (as of this writing)
|
||||
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
|
||||
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
|
||||
Rejects under-18 with `400`.
|
||||
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
|
||||
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
|
||||
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
|
||||
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
|
||||
public-worlds path, so the worlds list screen can only be shown after login/register.
|
||||
- `GET /api/health` — unauthenticated, `{status, app}`.
|
||||
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
|
||||
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
|
||||
|
||||
## Task breakdown
|
||||
| # | Type | Task | Depends on |
|
||||
|---|------|------|-----------|
|
||||
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
|
||||
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
|
||||
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
|
||||
|
||||
## Design decisions captured (resolved in task 1)
|
||||
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||
open but not required yet (no re-login on restart in this slice).
|
||||
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
|
||||
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
||||
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
||||
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
||||
|
||||
## Notes
|
||||
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
|
||||
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
|
||||
- The world-list screen also needs to be **built** (a new scene), not just wired.
|
||||
@@ -0,0 +1,295 @@
|
||||
# Economy — Currency, Marketplace & Creator Payouts — Plan
|
||||
|
||||
> **Status:** Planned
|
||||
> **Owner:** Platform team
|
||||
> **Feature ID:** `economy`
|
||||
> **Godot version:** 4.6.2 stable
|
||||
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document breaks the **Economy** feature into user stories and right-sized
|
||||
implementation tasks. The feature delivers:
|
||||
|
||||
- **Virtual currency ("Credits")** — a single, server-authoritative currency
|
||||
with an immutable, append-only transaction ledger.
|
||||
- **Premium purchase** — buying Credits with real money through a pluggable
|
||||
payment-provider hook.
|
||||
- **Marketplace** — user-generated content (UGC) listings where creators sell
|
||||
items (assets, avatar items, experience passes) to players for Credits.
|
||||
- **Creator payouts** — accumulating creator earnings and paying them out to
|
||||
external accounts through a pluggable payout-provider hook.
|
||||
- **Godot client** — wallet/transaction history, marketplace browse/buy/sell,
|
||||
and creator payout screens.
|
||||
|
||||
The economy is the monetization backbone of the platform. Because it moves
|
||||
virtual value between real people, correctness (no double-spend, no lost funds,
|
||||
no balance drift) and safety (fraud prevention, moderation, audit) are treated
|
||||
as **blocking requirements**, not add-ons.
|
||||
|
||||
## 2. Why correctness is the differentiator
|
||||
|
||||
The platform is a creator economy (players buy UGC from other players). The
|
||||
economy is the highest-risk surface after chat: a single arithmetic or
|
||||
concurrency bug can mint or destroy value and irreversibly erode trust.
|
||||
Correctness is therefore designed in from the start:
|
||||
|
||||
- **One currency, integer units.** Credits are whole-number integers. There are
|
||||
no floats, no rounding, and no client-computed totals.
|
||||
- **Append-only ledger.** Every balance change is a row in a `transactions`
|
||||
table with a signed amount, a `balance_after` snapshot, a reference to the
|
||||
event that caused it, and an **idempotency key** so retries cannot double
|
||||
credit or double debit.
|
||||
- **Atomic, locked mutations.** Balance changes run in a single database
|
||||
transaction with row locking (`SELECT ... FOR UPDATE`) so concurrent
|
||||
purchases/payouts cannot race.
|
||||
- **Escrow hold.** Marketplace proceeds are held in `pending_escrow` for a
|
||||
configurable window before becoming available to the seller, so fraudulent
|
||||
or moderated sales can be reversed without clawing back paid-out funds.
|
||||
- **Server-authoritative fees.** The platform commission is computed
|
||||
server-side from a configured rate; the client never supplies the fee or the
|
||||
seller's net.
|
||||
|
||||
## 3. Existing constraints (do not re-build)
|
||||
|
||||
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
|
||||
`app/dependencies.py`. All economy endpoints reuse this.
|
||||
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
|
||||
Existing tables: `users`, `sessions`, `worlds`, `assets`.
|
||||
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
|
||||
idempotency short-caching and rate limiting.
|
||||
- **Models:** `User` (owner_id), `Asset` (owner_id, is_public, asset_type).
|
||||
Marketplace items reference existing `assets` rows and/or avatar items.
|
||||
- **Content moderation:** `docs/architecture/content_moderation.md` defines
|
||||
flag vocabulary, age gate, and moderation actions. Marketplace items and
|
||||
listings must inherit and enforce these rules (see §7).
|
||||
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no economy UI yet.
|
||||
|
||||
## 4. Key design decisions
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Currency model | Single virtual currency **"Credits" (CR)**, integer units. One wallet row per user; no fractional amounts. |
|
||||
| Ledger | Append-only `transactions` table: signed `amount`, `balance_after`, `reference_type`/`reference_id`, unique `idempotency_key`. |
|
||||
| Concurrency | `SELECT ... FOR UPDATE` on the wallet row inside the mutation transaction; never read-modify-write from stale data. |
|
||||
| Payments | Pluggable `PaymentProvider` interface with a **no-op default** that simulates a confirmed payment for dev/testing (clearly flagged). Stripe/PayPal later. |
|
||||
| Marketplace items | `catalog_items` (what is sold) + `marketplace_listings` (a seller's offer with a price/status). One item can have multiple listings over time. |
|
||||
| Purchase flow | Atomic transaction: debit buyer → hold funds in escrow (`pending_escrow`) → grant item. Platform fee computed server-side. |
|
||||
| Escrow | Configurable hold window (default 72h) before `pending_escrow` becomes available seller earnings. Refunds during the window reverse the sale. |
|
||||
| Fees | Single configurable `MARKETPLACE_FEE_RATE` (default `0.30`). `seller_net = floor(price * (1 - fee_rate))`. |
|
||||
| Creator balance | `creator_balances` table: `available` (earned, payable), `pending` (inside an open payout request), `lifetime_earned`. Updated in the same transaction as the sale completion. |
|
||||
| Payouts | Pluggable `PayoutProvider.pay()` with a no-op default (simulated external ref). Minimum payout threshold, admin review for large payouts. |
|
||||
| Idempotency | Every money-moving request carries a client-generated `Idempotency-Key`; duplicate keys return the original result. |
|
||||
| Rate limiting | Redis token-bucket dependency reused from the social feature for purchase/listing/payout endpoints. |
|
||||
| Moderation | Listings enforce content flags + age gate; moderators can `delist`/`remove`; fraud/chargeback reversal is an audited admin action. |
|
||||
|
||||
## 5. User stories
|
||||
|
||||
### US-1 Currency & wallet
|
||||
As a player, I can see my Credit balance and transaction history, and the system
|
||||
credits/debits my wallet only through server-authoritative events.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Exactly one wallet per user, created lazily on first use.
|
||||
- Balance is always a non-negative integer; no operation can make it negative.
|
||||
- Every mutation appends an immutable ledger row with amount, resulting balance,
|
||||
reference, and idempotency key.
|
||||
- Retrying a request with the same idempotency key does not double-apply.
|
||||
- `GET` endpoints expose current balance and paged transaction history.
|
||||
|
||||
### US-2 Premium purchase (buy Credits)
|
||||
As a player, I can purchase Credits with real money and have them credited only
|
||||
after the payment is confirmed.
|
||||
|
||||
**Acceptance criteria**
|
||||
- A purchase session is created through the payment provider (or the no-op
|
||||
default) and is idempotent.
|
||||
- Credits are granted exactly once, only after a verified provider confirmation
|
||||
(webhook/confirm endpoint), never on client claim.
|
||||
- Purchase amounts are bounded and server-defined; clients cannot pick arbitrary
|
||||
prices.
|
||||
- Confirmation is idempotent and audited (provider reference recorded).
|
||||
|
||||
### US-3 Marketplace listing (sell)
|
||||
As a creator, I can list a catalog item for sale at a Credit price and
|
||||
manage/remove my listings.
|
||||
|
||||
**Acceptance criteria**
|
||||
- A listing references a valid `catalog_item` owned by the seller (or an
|
||||
authorized seller).
|
||||
- Price is bounded (min/max) and set server-side; status transitions are
|
||||
explicit (`active`, `sold`, `delisted`, `removed_by_moderation`).
|
||||
- Only the seller (or a moderator) can update/remove a listing.
|
||||
- Listings are searchable/filterable (item type, price range, owner).
|
||||
|
||||
### US-4 Marketplace purchase (buy)
|
||||
As a player, I can buy a listed item. The purchase is atomic, holds funds in
|
||||
escrow, grants the item, and splits the payment between platform and seller.
|
||||
|
||||
**Acceptance criteria**
|
||||
- A single transaction debits the buyer, holds funds in escrow, and grants the
|
||||
item — no partial states on failure.
|
||||
- Platform fee and seller net are computed server-side from the configured rate.
|
||||
- After the escrow hold window, funds move from escrow to the seller's
|
||||
available balance.
|
||||
- Refunds during the hold window reverse the sale and return funds to the buyer.
|
||||
- Concurrent purchases of a one-of-a-kind listing cannot both succeed.
|
||||
|
||||
### US-5 Creator payouts
|
||||
As a creator, I can view my earned balance, request a payout, and track its
|
||||
status; admins can review and approve/reject payouts.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Available balance is derived only from completed (escrow-released) sales and
|
||||
completed payouts; it never goes negative.
|
||||
- Payout requests are bounded (min threshold, max per period) and rate-limited.
|
||||
- Large payouts require admin review; all payout requests carry an audit trail
|
||||
(requested by, reviewed by, provider reference, status).
|
||||
- The payout provider is called exactly once; idempotent on retry.
|
||||
|
||||
### US-6 Godot client
|
||||
As a player, I can manage Credits, browse/buy/sell marketplace items, and manage
|
||||
payouts from the game UI.
|
||||
|
||||
**Acceptance criteria**
|
||||
- An API client handles JWT auth, HTTP, and idempotency keys for all economy
|
||||
calls.
|
||||
- Dedicated screens: wallet + transaction history, marketplace browse/buy,
|
||||
sell-listing, and creator payout.
|
||||
- The client never computes balances, fees, or seller nets — it only displays
|
||||
server responses.
|
||||
|
||||
## 6. Task breakdown
|
||||
|
||||
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
|
||||
are sequential for dependency chaining.
|
||||
|
||||
| # | Task | Story | Depends on |
|
||||
|---|------|-------|-----------|
|
||||
| 1 | Wallet & ledger models + Alembic migration (`wallets`, `transactions`) | US-1 | — |
|
||||
| 2 | Wallet service (atomic credit/debit, idempotency, locking) + wallet/transactions API | US-1 | 1 |
|
||||
| 3 | Premium purchase: `PaymentProvider` interface + purchase/confirm endpoints | US-2 | 1 |
|
||||
| 4 | Catalog item & listing models + migration (`catalog_items`, `marketplace_listings`, `creator_balances`) | US-3 | 1 |
|
||||
| 5 | Marketplace listing API (create/list/search/update/remove) | US-3 | 4 |
|
||||
| 6 | Marketplace purchase service + API (atomic escrow, fee split, item grant) | US-4 | 2, 4, 5 |
|
||||
| 7 | Creator payout request models + API (`payout_requests`) | US-5 | 2, 6 |
|
||||
| 8 | Payout admin review + `PayoutProvider` + audit | US-5 | 7 |
|
||||
| 9 | Marketplace moderation + content-flag/age-gate enforcement | cross-cutting | 5, 6 |
|
||||
| 10 | Godot economy API client (auth + HTTP + idempotency) | US-6 | 2 |
|
||||
| 11 | Godot wallet & transaction history UI | US-6 | 10 |
|
||||
| 12 | Godot marketplace browse/buy UI | US-6 | 6, 10 |
|
||||
| 13 | Godot selling & payout UI | US-6 | 5, 7, 10 |
|
||||
|
||||
## 7. Phasing
|
||||
|
||||
- **Phase 1 (currency + payments):** tasks 1–3. The foundation — ledger and
|
||||
money movement must be rock-solid before anything spends Credits.
|
||||
- **Phase 2 (marketplace):** tasks 4–6. Listing and atomic purchase with escrow.
|
||||
- **Phase 3 (payouts):** tasks 7–8. Turn released escrow into creator payouts.
|
||||
- **Phase 4 (moderation):** task 9. Lock down the marketplace against mature
|
||||
content and abuse.
|
||||
- **Phase 5 (client):** tasks 10–13, each landing after its backend dependency.
|
||||
|
||||
## 8. Data model sketch
|
||||
|
||||
```text
|
||||
wallets
|
||||
id bigint PK
|
||||
user_id int UNIQUE FK -> users.id
|
||||
balance bigint NOT NULL DEFAULT 0 CHECK (balance >= 0)
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
|
||||
transactions (append-only ledger)
|
||||
id bigint PK
|
||||
wallet_id bigint FK -> wallets.id
|
||||
type enum(credit_purchase, marketplace_purchase,
|
||||
marketplace_sale, escrow_release, payout,
|
||||
refund, admin_adjustment)
|
||||
amount bigint NOT NULL (signed)
|
||||
balance_after bigint NOT NULL
|
||||
reference_type text (e.g. "purchase", "sale", "payout_request")
|
||||
reference_id text
|
||||
idempotency_key text UNIQUE
|
||||
metadata_json jsonb
|
||||
created_at timestamptz
|
||||
|
||||
catalog_items
|
||||
id bigint PK
|
||||
owner_id int FK -> users.id
|
||||
asset_id int FK -> assets.id (nullable; null for non-asset virtual items)
|
||||
item_type enum(asset, avatar_item, experience_pass)
|
||||
name text
|
||||
description text
|
||||
metadata_json jsonb
|
||||
created_at timestamptz
|
||||
|
||||
marketplace_listings
|
||||
id bigint PK
|
||||
catalog_item_id bigint FK -> catalog_items.id
|
||||
seller_id int FK -> users.id
|
||||
price bigint NOT NULL CHECK (price > 0)
|
||||
status enum(active, sold, delisted, removed_by_moderation)
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
|
||||
purchases
|
||||
id bigint PK
|
||||
listing_id bigint FK -> marketplace_listings.id
|
||||
buyer_id int FK -> users.id
|
||||
seller_id int FK -> users.id
|
||||
price bigint NOT NULL
|
||||
platform_fee bigint NOT NULL
|
||||
seller_net bigint NOT NULL
|
||||
status enum(pending_escrow, completed, refunded, reversed)
|
||||
escrow_hold_until timestamptz
|
||||
created_at timestamptz
|
||||
|
||||
creator_balances
|
||||
id bigint PK
|
||||
user_id int UNIQUE FK -> users.id
|
||||
available bigint NOT NULL DEFAULT 0
|
||||
pending bigint NOT NULL DEFAULT 0
|
||||
lifetime_earned bigint NOT NULL DEFAULT 0
|
||||
updated_at timestamptz
|
||||
|
||||
payout_requests
|
||||
id bigint PK
|
||||
creator_id int FK -> users.id
|
||||
amount bigint NOT NULL CHECK (amount > 0)
|
||||
status enum(pending, approved, paid, rejected)
|
||||
external_ref text
|
||||
provider_ref text
|
||||
requested_by int FK -> users.id
|
||||
reviewed_by int FK -> users.id (nullable)
|
||||
reviewed_at timestamptz
|
||||
created_at timestamptz
|
||||
```
|
||||
|
||||
## 9. Security, anti-fraud & audit
|
||||
|
||||
- **Server-authoritative balances/fees:** clients never compute totals; amounts
|
||||
are validated server-side against bounds and the current listing.
|
||||
- **Row locking:** wallet and creator-balance mutations use `SELECT ... FOR
|
||||
UPDATE` within one transaction.
|
||||
- **Idempotency:** every money-moving endpoint requires an `Idempotency-Key`;
|
||||
duplicate keys return the stored original outcome.
|
||||
- **Rate limiting:** purchase, listing, and payout endpoints are token-bucket
|
||||
rate-limited per user.
|
||||
- **Escrow & reversibility:** funds are not payable until the hold window
|
||||
elapses; chargebacks/refunds reverse before release.
|
||||
- **Moderation:** listings carry the item's effective content flags; mature
|
||||
items require age verification (per `content_moderation.md`); moderators can
|
||||
`delist`/`remove` and trigger audited reversals.
|
||||
- **Audit:** the ledger plus `payout_requests` review fields form an append-only
|
||||
audit trail for every movement of value.
|
||||
|
||||
## 10. Out of scope for this feature
|
||||
|
||||
- Real Stripe/PayPal/KYC integrations (provider interfaces leave the hooks; the
|
||||
defaults are deterministic no-ops).
|
||||
- Subscriptions, recurring billing, or premium memberships.
|
||||
- Gift cards, promotional codes, or user-to-user Credit transfers.
|
||||
- Partial refunds, chargeback disputes, and seller tax/VAT handling.
|
||||
- In-world vending machines / scripted economy triggers (future, built on this
|
||||
ledger via the scripting sandbox).
|
||||
@@ -0,0 +1,170 @@
|
||||
# Social — Friends, Chat & Groups — Plan
|
||||
|
||||
> **Status:** Planned
|
||||
> **Owner:** Platform team
|
||||
> **Feature ID:** `social`
|
||||
> **Godot version:** 4.6.2 stable
|
||||
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document breaks the **Social** feature into user stories and right-sized
|
||||
implementation tasks. The feature delivers:
|
||||
|
||||
- **Friends & following** — a friend request/accept model plus a one-way follow
|
||||
relationship, with block lists.
|
||||
- **Presence & status** — online/offline, custom status message, last-seen.
|
||||
- **Chat** — direct messages (DM) and in-experience (world/room) chat with
|
||||
**first-class safety**: server-side text filtering, report-message, and rate
|
||||
limiting.
|
||||
- **Groups** — named groups with roles (owner/admin/member) and a simple group
|
||||
page.
|
||||
- **Public profiles** — avatar, bio, favorite experiences, and a friends list
|
||||
that respects privacy settings.
|
||||
|
||||
## 2. Why chat safety is the differentiator
|
||||
|
||||
The platform is explicitly meant to pull adults away from a youth-oriented
|
||||
competitor. Chat is the highest-risk surface: it is real-time, user-generated,
|
||||
and where abuse happens fastest. Safety is therefore treated as a **blocking
|
||||
requirement**, not an add-on:
|
||||
|
||||
- Every message passes a **server-side text filter** before it is stored or
|
||||
delivered. The client filter is cosmetic only; the server is authoritative.
|
||||
- Every message can be **reported**; reports flow into a moderation queue with
|
||||
actor + reason + audit trail (mirroring `content_moderation.md`).
|
||||
- Sending is **rate limited** per user (Redis token bucket) to stop flooding and
|
||||
harassment-by-volume.
|
||||
- Report and block are always reachable from a message context in the UI.
|
||||
|
||||
## 3. Existing constraints (do not re-build)
|
||||
|
||||
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
|
||||
`app/dependencies.py`. All social endpoints reuse this.
|
||||
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
|
||||
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
|
||||
presence and rate limiting.
|
||||
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). Avatars
|
||||
and favorite-experience references point at existing rows.
|
||||
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no social UI yet.
|
||||
|
||||
## 4. Key design decisions
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Message transport | REST for send/history; **WebSocket** gateway for real-time delivery + presence (matches `networking.md`'s "WebSocket as secondary transport" for chat) |
|
||||
| Presence store | Redis (TTL heartbeat) + persisted `last_seen_at` on the user record |
|
||||
| Text filtering | Pluggable `TextFilter` interface with a deterministic default (profanity list + normalization) so it runs without external services; third-party hook later |
|
||||
| Rate limiting | Redis token-bucket dependency, reusable across all social endpoints |
|
||||
| Reports | `message_reports` table + moderation endpoints (list/review/resolve) with audit fields |
|
||||
| Friends vs. follow | Friends = mutual (request→accept). Follow = one-way (no approval). Block = hard deny that also hides content and prevents DMs |
|
||||
| Privacy | Per-user settings JSON (`profile_visibility`, `friends_list_visibility`, `favorites_visibility`) enforced server-side on every read |
|
||||
| Groups | `groups` + `group_members` (role: owner/admin/member) + group page aggregate endpoint |
|
||||
| Real-time | One WebSocket endpoint multiplexing presence, DM, and world-chat messages via Redis pub/sub |
|
||||
|
||||
## 5. User stories
|
||||
|
||||
### US-1 Friends & following
|
||||
As a player, I can send a friend request, accept/decline incoming requests,
|
||||
unfriend, follow/unfollow other players, and block users so they can't interact
|
||||
with me.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Friend requests are one-directional until accepted; accepted friendships are mutual.
|
||||
- Follow is one-way and requires no approval.
|
||||
- Block prevents DMs, hides the blocked user's profile/friends interactions, and
|
||||
removes any existing friendship/follow between the two.
|
||||
- Duplicate/self requests are rejected idempotently.
|
||||
|
||||
### US-2 Presence & status
|
||||
As a player, I can see whether my friends are online, their custom status, and
|
||||
when they were last seen.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Online/offline is derived from a server-side heartbeat (Redis TTL), not client claims.
|
||||
- `last_seen_at` persists and is visible (subject to privacy settings).
|
||||
- Custom status text is optional and length-limited.
|
||||
|
||||
### US-3 Public profiles
|
||||
As a player, I can view a public profile page showing avatar, bio, favorite
|
||||
experiences, and a friends list that respects the owner's privacy settings.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Avatar and bio are editable by the owner only.
|
||||
- Favorite experiences reference existing `World` rows.
|
||||
- A viewer sees only the sections the owner's privacy settings allow; blocked
|
||||
users see a restricted view.
|
||||
|
||||
### US-4 Chat (DM + in-experience) with safety
|
||||
As a player, I can send direct messages and chat inside a world/room. Messages
|
||||
are filtered, rate limited, and reportable.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Server filters message text before storing/delivering; filtered content is either
|
||||
rejected (blocking) or redacted (configurable per filter policy).
|
||||
- Send rate is limited per user (token bucket); excess sends get `429`.
|
||||
- Every message has a stable id, author, timestamp, and conversation/world scope.
|
||||
- Any message can be reported with a reason; reports are stored and reviewable.
|
||||
- Real-time delivery over WebSocket with REST fallback for history.
|
||||
|
||||
### US-5 Groups
|
||||
As a player, I can create a group, join/leave groups, and (as owner/admin)
|
||||
manage roles and view a simple group page.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Each group has exactly one owner and any number of admins/members.
|
||||
- Role transitions are authorized (only owner can transfer ownership or promote/demote admins).
|
||||
- Group page lists name, description, member list with roles, and member count.
|
||||
|
||||
### US-6 Godot client
|
||||
As a player, I can access friends, presence, chat, profiles, and groups from the
|
||||
game UI.
|
||||
|
||||
**Acceptance criteria**
|
||||
- An API client handles JWT auth, HTTP, and WebSocket for all social calls.
|
||||
- Dedicated screens/panels: friends+presence, DM & world chat (with report),
|
||||
profile page, group page.
|
||||
- Client-side filtering is cosmetic; server responses remain authoritative.
|
||||
|
||||
## 6. Task breakdown
|
||||
|
||||
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
|
||||
are sequential for dependency chaining.
|
||||
|
||||
| # | Task | Story | Depends on |
|
||||
|---|------|-------|-----------|
|
||||
| 1 | Friends/follow/block models + Alembic migration | US-1 | — |
|
||||
| 2 | Friends & follow API router | US-1 | 1 |
|
||||
| 3 | Presence & status model + endpoints (Redis heartbeat) | US-2 | — |
|
||||
| 4 | Profile model + migration + own-profile endpoints | US-3 | 1 |
|
||||
| 5 | Public profile view + favorites + privacy enforcement | US-3 | 2, 4 |
|
||||
| 6 | Chat data models + migration (conversations, messages, reports) | US-4 | — |
|
||||
| 7 | Text filtering service + profanity config | US-4 | — |
|
||||
| 8 | Redis rate-limiting dependency | US-4 | — |
|
||||
| 9 | DM chat send/list endpoints (filter + rate limit) | US-4 | 6, 7, 8 |
|
||||
| 10 | Report-message endpoint + moderation queue | US-4 | 6, 9 |
|
||||
| 11 | In-experience (world/room) chat endpoints | US-4 | 9 |
|
||||
| 12 | Real-time WebSocket gateway (presence + DM + world chat) | US-4 | 3, 9, 11 |
|
||||
| 13 | Group + membership models + migration | US-5 | — |
|
||||
| 14 | Groups API (CRUD, join/leave, roles, group page) | US-5 | 13 |
|
||||
| 15 | Godot API client (auth + HTTP + WebSocket) | US-6 | 2 |
|
||||
| 16 | Godot friends & presence UI | US-6 | 3, 15 |
|
||||
| 17 | Godot chat UI (DM + world chat + report) | US-6 | 12, 15 |
|
||||
| 18 | Godot profile page UI | US-6 | 5, 15 |
|
||||
| 19 | Godot groups UI + group page | US-6 | 14, 15 |
|
||||
|
||||
## 7. Phasing
|
||||
|
||||
- **Phase 1 (data + core APIs):** tasks 1–5 (friends, presence, profiles).
|
||||
- **Phase 2 (chat safety — the differentiator):** tasks 6–12 (chat + safety +
|
||||
realtime). This is the critical path and highest priority.
|
||||
- **Phase 3 (groups):** tasks 13–14.
|
||||
- **Phase 4 (client):** tasks 15–19, each landing after its backend dependency.
|
||||
|
||||
## 8. Out of scope for this feature
|
||||
|
||||
- Age verification / maturity gating (covered by `content_moderation.md`).
|
||||
- Voice chat, media/image messages, message editing/deletion history.
|
||||
- Third-party profanity/toxicity vendors (the `TextFilter` interface leaves a
|
||||
hook, but the default is deterministic and dependency-free).
|
||||
- Mobile push notifications.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Testing at Package Checkpoints
|
||||
|
||||
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
|
||||
real milestones, the client and server builds get packaged together and
|
||||
independently reviewed before anyone calls anything actually done. See
|
||||
`project_agp` in Claude's memory, or ask, for the full definition; this
|
||||
doc is just the mechanical "how do I run the tests" part of that review.
|
||||
|
||||
## Running the suite
|
||||
|
||||
```
|
||||
python scripts/run_checkpoint_tests.py
|
||||
```
|
||||
|
||||
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
|
||||
backend pytest suite, starts the backend server, and runs every Godot
|
||||
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
|
||||
Postgres container down afterward (`--keep-postgres` to skip that if you
|
||||
want to poke at the DB after a failure).
|
||||
|
||||
If port 5432 is already taken on the machine, pass `--postgres-port` with
|
||||
something else.
|
||||
|
||||
Exit code is 0 only if everything that actually ran passed — suites with
|
||||
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
|
||||
reported SKIPPED, not treated as a failure.
|
||||
|
||||
## What this does and doesn't prove
|
||||
|
||||
Passing this script means: migrations apply cleanly to a genuinely empty
|
||||
database, the backend's unit-level logic is correct, and whatever Godot
|
||||
smoke tests exist actually round-trip against a live backend. It does
|
||||
**not** mean full endpoint/integration coverage exists — see
|
||||
`test_inventory.md` for the current, honest list of what's tested and what
|
||||
isn't. A checkpoint review should still involve reading the actual diffs
|
||||
for anything this script's current coverage doesn't reach, not just running
|
||||
green and calling it done.
|
||||
|
||||
## Growing this over time
|
||||
|
||||
As real integration tests get added (endpoint tests hitting the FastAPI app,
|
||||
more Godot smoke coverage beyond `health_check`), they should land under
|
||||
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
|
||||
picks up new files automatically, nothing to register by hand. Update
|
||||
`test_inventory.md`'s tables when you do, so the inventory stays a source
|
||||
of truth instead of quietly going stale.
|
||||
|
||||
Eventual goal (not implemented yet): gate merges to `main` on this script
|
||||
passing, the same way a real CI pipeline would. For now it's a manual step
|
||||
run at checkpoint review time.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Test Inventory
|
||||
|
||||
What automated coverage actually exists, what it actually verifies, and what
|
||||
doesn't have coverage yet. Keep this current as tests are added — it's the
|
||||
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
|
||||
to know what "run the tests" actually checked.
|
||||
|
||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||
|
||||
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
|
||||
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||
playable world + character + movement) landed 2026-08-20/21 — see
|
||||
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||
`smoke_test_enter_world.gd`) for what's actually verified. The avatar
|
||||
loader/customization system (`smoke_test_avatar_system.gd`) also landed
|
||||
2026-08-21 — real glTF-based loading with graceful failure on missing
|
||||
assets, verified since `assets/avatars/*.glb` model files don't exist yet.
|
||||
|
||||
## Backend — `backend/tests/` (pytest)
|
||||
|
||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||
reachable Postgres — see the runner script).
|
||||
|
||||
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
||||
|
||||
| File | Tests | What it covers |
|
||||
|---|---|---|
|
||||
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
|
||||
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
||||
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||
|
||||
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
|
||||
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
|
||||
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
|
||||
that hit the FastAPI app against a real Postgres database via `TestClient`.
|
||||
The integration tests exercise the full stack: Pydantic validation → DB →
|
||||
JWT → response. The suite is also re-runnable without manual cleanup —
|
||||
`backend/tests/conftest.py` has a session-scoped autouse fixture that
|
||||
truncates all app tables once before the suite runs.
|
||||
|
||||
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||
these yet):
|
||||
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
|
||||
world/asset CRUD routes — only auth endpoints have integration coverage
|
||||
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
|
||||
- No tests for the moderation schema/migration beyond "the migration runs."
|
||||
- No tests for the `GET /api/auth/me` endpoint's response body (only its
|
||||
auth-header validation is covered via expired-token tests).
|
||||
- No tests for session management (create/validate/revoke sessions).
|
||||
|
||||
## Godot client — `scripts/smoke_test_*.gd`
|
||||
|
||||
**12 smoke tests merged into `main`.** Ten need a live backend, one
|
||||
(`smoke_test_network_client.gd`) needs a live dedicated server instead,
|
||||
and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises
|
||||
local catalog/loadout/loader logic. Backend-dependent tests read
|
||||
`AGP_API_BASE_URL` env var, falling back to the
|
||||
`application/agp/api_base_url` project setting (default
|
||||
`http://127.0.0.1:8000`) if unset.
|
||||
|
||||
| File | What it covers |
|
||||
|---|---|
|
||||
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
|
||||
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
|
||||
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
|
||||
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
|
||||
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
|
||||
|
||||
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||
then for each smoke test: `godot --headless --path . --script
|
||||
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||
their awaited HTTP calls finish.
|
||||
|
||||
**Known gaps:**
|
||||
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||
- No smoke tests for dynamic/static object nodes.
|
||||
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
|
||||
ones that exercise a real `.tscn` scene instantiation — others only test
|
||||
the `ApiClient` autoload directly.
|
||||
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
|
||||
has no smoke coverage yet.
|
||||
- The Login path (as opposed to Register) through `main_menu.gd` isn't
|
||||
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
|
||||
`_on_auth_complete()` code path already exercised for Register, but that's
|
||||
inference, not a direct check.
|
||||
|
||||
## Client export
|
||||
|
||||
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
|
||||
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
|
||||
exports complete clean from a fresh checkout and produce valid binaries
|
||||
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
|
||||
export is exercised by an automated GUI test (headless Godot can't drive a
|
||||
window) — actually launching and clicking through the exported binary is a
|
||||
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
|
||||
|
||||
## What "full coverage" should eventually mean here
|
||||
|
||||
Not defined precisely yet — the honest target as of 2026-08-20 is
|
||||
"integration tests for every endpoint + smoke tests for every major scene."
|
||||
As real integration/endpoint tests get added, update the tables above
|
||||
rather than letting this doc drift stale; a wrong inventory is worse than
|
||||
no inventory.
|
||||
@@ -0,0 +1,74 @@
|
||||
[preset.0]
|
||||
|
||||
name="Linux"
|
||||
platform="Linux/X11"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="export/linux/agp-game.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.0.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
ssh_remote_deploy/enabled=false
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Windows Desktop"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="export/windows/agp-game.exe"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
codesign/enable=false
|
||||
application/icon=""
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version=""
|
||||
application/product_version=""
|
||||
application/company_name=""
|
||||
application/product_name=""
|
||||
application/file_description=""
|
||||
application/copyright=""
|
||||
application/trademarks=""
|
||||
application/export_angle=0
|
||||
application/export_d3d12=0
|
||||
d3d12/agility_sdk_multizip=0
|
||||
@@ -0,0 +1,51 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; but it can also be edited by hand.
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="AGP-Game"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6.2")
|
||||
agp/api_base_url="http://127.0.0.1:8000"
|
||||
|
||||
[autoload]
|
||||
|
||||
ApiClient="*res://scripts/api_client.gd"
|
||||
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/stretch/mode="canvas_items"
|
||||
|
||||
[input]
|
||||
|
||||
move_forward={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_left={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_right={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_down={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
extends Node
|
||||
|
||||
func _ready():
|
||||
print("=== SANDBOX PROBE 6: Corrected Evidence Gathering (Godot 4.6.2) ===")
|
||||
probe_script_loading()
|
||||
probe_os_apis()
|
||||
probe_file_access()
|
||||
probe_network()
|
||||
probe_classdb()
|
||||
probe_expression_engine()
|
||||
probe_engine_singletons()
|
||||
probe_worker_threads()
|
||||
probe_project_settings()
|
||||
probe_allowlist()
|
||||
print("=== SANDBOX PROBE 6 END ===")
|
||||
get_tree().quit()
|
||||
|
||||
func probe_script_loading():
|
||||
print("--- Script loading ---")
|
||||
var gd = GDScript.new()
|
||||
gd.source_code = "extends RefCounted\nfunc hello():\n\treturn 42\n"
|
||||
var err = gd.reload()
|
||||
print("GDScript.reload() from source_code err=", err)
|
||||
if err == OK:
|
||||
var inst = gd.new()
|
||||
print(" instantiated, hello()=", inst.call("hello"))
|
||||
var res = load("res://test_script.gd")
|
||||
print("load('res://test_script.gd') = ", res)
|
||||
if res:
|
||||
print(" get_source_code() available: ", res.has_method("get_source_code"))
|
||||
print(" GDScript instance has 'get_ast': ", gd.has_method("get_ast"))
|
||||
print(" GDScript instance has 'get_parser': ", gd.has_method("get_parser"))
|
||||
print(" GDScript instance has 'get_script_method_list': ", gd.has_method("get_script_method_list"))
|
||||
print(" GDScript instance has 'get_script_property_list': ", gd.has_method("get_script_property_list"))
|
||||
|
||||
func probe_os_apis():
|
||||
print("--- OS APIs ---")
|
||||
var out = []
|
||||
var exit_code = OS.execute("echo", ["hello"], out)
|
||||
print("OS.execute('echo') exit=", exit_code, " out=", out)
|
||||
print("OS.get_environment('HOME') = ", OS.get_environment("HOME"))
|
||||
print("OS has 'create_process': ", OS.has_method("create_process"))
|
||||
print("OS.get_cmdline_args() = ", OS.get_cmdline_args())
|
||||
print("OS.get_user_data_dir() = ", OS.get_user_data_dir())
|
||||
print("OS.get_executable_path() = ", OS.get_executable_path())
|
||||
|
||||
func probe_file_access():
|
||||
print("--- File access ---")
|
||||
var f = FileAccess.open("/etc/hostname", FileAccess.READ)
|
||||
print("FileAccess.open('/etc/hostname') = ", f)
|
||||
if f:
|
||||
print(" content: ", f.get_as_text())
|
||||
f.close()
|
||||
var w = FileAccess.open("/tmp/godot_probe_write.txt", FileAccess.WRITE)
|
||||
print("FileAccess.open('/tmp/...', WRITE) = ", w)
|
||||
if w:
|
||||
w.store_string("probe")
|
||||
w.close()
|
||||
print(" wrote to /tmp OK")
|
||||
var dir = DirAccess.open("/")
|
||||
print("DirAccess.open('/') = ", dir)
|
||||
if dir:
|
||||
var names = dir.get_files()
|
||||
print(" root files count: ", names.size())
|
||||
var res_dir = DirAccess.open("res://")
|
||||
print("DirAccess.open('res://') = ", res_dir)
|
||||
var user_dir = DirAccess.open("user://")
|
||||
print("DirAccess.open('user://') = ", user_dir)
|
||||
|
||||
func probe_network():
|
||||
print("--- Network ---")
|
||||
var tcp = TCPServer.new()
|
||||
var bind_err = tcp.listen(0)
|
||||
print("TCPServer.listen(0) err=", bind_err)
|
||||
var http = HTTPClient.new()
|
||||
print("HTTPClient instantiated: ", http != null)
|
||||
var http_req = HTTPRequest.new()
|
||||
print("HTTPRequest instantiated: ", http_req != null)
|
||||
var udp = PacketPeerUDP.new()
|
||||
print("PacketPeerUDP instantiated: ", udp != null)
|
||||
var ws = WebSocketPeer.new()
|
||||
print("WebSocketPeer instantiated: ", ws != null)
|
||||
|
||||
func probe_classdb():
|
||||
print("--- ClassDB ---")
|
||||
print("ClassDB.class_exists('OS'): ", ClassDB.class_exists("OS"))
|
||||
print("ClassDB.class_exists('FileAccess'): ", ClassDB.class_exists("FileAccess"))
|
||||
print("ClassDB.class_exists('TCPServer'): ", ClassDB.class_exists("TCPServer"))
|
||||
print("ClassDB.class_exists('HTTPClient'): ", ClassDB.class_exists("HTTPClient"))
|
||||
var class_list = ClassDB.get_class_list()
|
||||
print("ClassDB.get_class_list() count: ", class_list.size())
|
||||
var os_methods = ClassDB.class_get_method_list("OS")
|
||||
print("OS method count: ", os_methods.size())
|
||||
var inst = ClassDB.instantiate("Node")
|
||||
print("ClassDB.instantiate('Node') = ", inst)
|
||||
print("ClassDB has 'class_get_singleton': ", ClassDB.has_method("class_get_singleton"))
|
||||
|
||||
func probe_expression_engine():
|
||||
print("--- Expression engine ---")
|
||||
var expr = Expression.new()
|
||||
var err = expr.parse("1 + 2")
|
||||
print("Expression.parse('1 + 2') err=", err)
|
||||
if err == OK:
|
||||
var result = expr.execute()
|
||||
print(" Expression.execute() = ", result)
|
||||
var expr2 = Expression.new()
|
||||
err = expr2.parse("OS.get_environment('HOME')")
|
||||
print("Expression.parse('OS.get_environment') err=", err)
|
||||
if err == OK:
|
||||
var result2 = expr2.execute()
|
||||
print(" Expression.execute() = ", result2)
|
||||
var expr3 = Expression.new()
|
||||
err = expr3.parse("get_environment('HOME')", ["OS"])
|
||||
print("Expression.parse with base_instance err=", err)
|
||||
if err == OK:
|
||||
var result3 = expr3.execute([], OS)
|
||||
print(" Expression.execute with OS base = ", result3)
|
||||
var expr4 = Expression.new()
|
||||
err = expr4.parse("FileAccess.open('/etc/hostname', 1)")
|
||||
print("Expression.parse('FileAccess.open') err=", err)
|
||||
if err == OK:
|
||||
var result4 = expr4.execute()
|
||||
print(" Expression.execute() = ", result4)
|
||||
var expr5 = Expression.new()
|
||||
err = expr5.parse("get_tree().quit()")
|
||||
print("Expression.parse('get_tree().quit()') err=", err)
|
||||
if err == OK:
|
||||
var result5 = expr5.execute()
|
||||
print(" Expression.execute() = ", result5)
|
||||
|
||||
func probe_engine_singletons():
|
||||
print("--- Engine singletons ---")
|
||||
print("Engine has 'remove_singleton': ", Engine.has_method("remove_singleton"))
|
||||
print("Engine has 'add_singleton': ", Engine.has_method("add_singleton"))
|
||||
print("Engine.get_main_loop() = ", Engine.get_main_loop())
|
||||
|
||||
func probe_worker_threads():
|
||||
print("--- Worker threads ---")
|
||||
var thread = Thread.new()
|
||||
print(" Thread.new() = ", thread)
|
||||
print(" WorkerThreadPool has 'add_task': ", WorkerThreadPool.has_method("add_task"))
|
||||
|
||||
func probe_project_settings():
|
||||
print("--- Project settings ---")
|
||||
var settings = ProjectSettings.get_property_list()
|
||||
var relevant = []
|
||||
for s in settings:
|
||||
var name = String(s.name)
|
||||
if "sandbox" in name.to_lower() or "restrict" in name.to_lower() or "security" in name.to_lower():
|
||||
relevant.append(name)
|
||||
print(" Sandbox/security project settings: ", relevant)
|
||||
print(" ProjectSettings has 'application/run/disable_os': ", ProjectSettings.has_setting("application/run/disable_os"))
|
||||
print(" ProjectSettings has 'application/run/disable_file_access': ", ProjectSettings.has_setting("application/run/disable_file_access"))
|
||||
print(" ProjectSettings has 'application/run/disable_network': ", ProjectSettings.has_setting("application/run/disable_network"))
|
||||
print(" ProjectSettings has 'set_setting': ", ProjectSettings.has_method("set_setting"))
|
||||
print(" ProjectSettings has 'save': ", ProjectSettings.has_method("save"))
|
||||
|
||||
func probe_allowlist():
|
||||
print("--- Allowlist / static analysis ---")
|
||||
var dangerous_patterns = [
|
||||
"OS.execute", "OS.create_process", "OS.get_environment",
|
||||
"FileAccess.open", "DirAccess.open", "TCPServer", "HTTPClient",
|
||||
"load(", "preload(", "ClassDB", "Engine.get_main_loop",
|
||||
"get_tree().quit", "ProjectSettings"
|
||||
]
|
||||
var test_script = """extends Node
|
||||
func _ready():
|
||||
var f = FileAccess.open("/etc/passwd", FileAccess.READ)
|
||||
OS.execute("rm", ["-rf", "/"])
|
||||
"""
|
||||
print(" Test script contains dangerous patterns:")
|
||||
for p in dangerous_patterns:
|
||||
if p in test_script:
|
||||
print(" DETECTED: ", p)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://probe6.gd" id="1"]
|
||||
|
||||
[node name="Probe6" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,8 @@
|
||||
; Engine configuration file.
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="sandbox_probe"
|
||||
run/main_scene="res://probe6.tscn"
|
||||
config/features=PackedStringArray("4.6")
|
||||
@@ -0,0 +1,4 @@
|
||||
extends RefCounted
|
||||
|
||||
func hello():
|
||||
return "from test_script.gd"
|
||||
@@ -0,0 +1,149 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://avatar_customization"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/avatar/avatar_customization_ui.gd" id="1"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_main_panel"]
|
||||
bg_color = Color(0.12, 0.12, 0.15, 1)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_slot_panel"]
|
||||
bg_color = Color(0.18, 0.18, 0.22, 1)
|
||||
border_width_left = 2
|
||||
border_width_right = 2
|
||||
border_width_top = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.3, 0.3, 0.35, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_style"]
|
||||
bg_color = Color(0.25, 0.25, 0.3, 1)
|
||||
border_width_left = 1
|
||||
border_width_right = 1
|
||||
border_width_top = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.4, 0.4, 0.45, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
|
||||
bg_color = Color(0.35, 0.35, 0.4, 1)
|
||||
border_width_left = 1
|
||||
border_width_right = 1
|
||||
border_width_top = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.5, 0.5, 0.55, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[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")
|
||||
|
||||
[node name="MainPanel" type="Panel" parent="."]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_main_panel")
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="MainPanel"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="MainPanel/VBox"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 3
|
||||
text = "Avatar Customization"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 24
|
||||
|
||||
[node name="HBox" type="HBoxContainer" parent="MainPanel/VBox"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="AvatarViewport" type="ViewportContainer" parent="MainPanel/VBox/HBox"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
expand_texture = true
|
||||
stretch = true
|
||||
|
||||
[node name="AvatarViewport" type="Viewport" parent="MainPanel/VBox/HBox/AvatarViewport"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
width = 512
|
||||
height = 512
|
||||
transparent_bg = true
|
||||
|
||||
[node name="Avatar3D" type="Node3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
||||
layout_mode = 3
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
||||
layout_mode = 3
|
||||
position = Vector3(0, 1.0, 3.0)
|
||||
rotation = Vector3(-0.174533, 0, 0)
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
||||
layout_mode = 3
|
||||
position = Vector3(2, 4, 2)
|
||||
rotation = Vector3(0.785398, 0, 0)
|
||||
light_color = Color(0.8, 0.8, 0.8, 1)
|
||||
light_energy = 1.0
|
||||
|
||||
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
|
||||
layout_mode = 3
|
||||
position = Vector3(-2, 2, -2)
|
||||
rotation = Vector3(0.785398, 0, 0)
|
||||
light_color = Color(0.4, 0.4, 0.5, 1)
|
||||
light_energy = 0.5
|
||||
|
||||
[node name="SlotList" type="VBoxContainer" parent="MainPanel/VBox/HBox"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 0
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 4
|
||||
custom_minimum_size = Vector2(280, 0)
|
||||
|
||||
[node name="SlotHeader" type="Label" parent="MainPanel/VBox/HBox/SlotList"]
|
||||
layout_mode = 1
|
||||
text = "SLOTS"
|
||||
theme_override_font_sizes/font_size = 16
|
||||
font_color = Color(0.7, 0.7, 0.75, 1)
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MainPanel/VBox/HBox/SlotList"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
horizontal_mode = 2
|
||||
horizontal_scroll_mode = 2
|
||||
|
||||
[node name="SlotsVBox" type="VBoxContainer" parent="MainPanel/VBox/HBox/SlotList/ScrollContainer"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="BackButton" type="Button" parent="MainPanel/VBox"]
|
||||
layout_mode = 1
|
||||
size_flags_horizontal = 3
|
||||
text = "Back to Menu"
|
||||
button_pressed = false
|
||||
action_mode = 0
|
||||
custom_minimum_size = Vector2(0, 36)
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
|
||||
|
||||
[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"]
|
||||
|
||||
[node name="Level" parent="." instance=ExtResource("1_level")]
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("2_player")]
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_scene"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/chunk_stream_manager.gd" id="1_chunk_stream"]
|
||||
|
||||
[node name="Main" type="Node3D"]
|
||||
script = ExtResource("1_chunk_stream")
|
||||
@@ -0,0 +1,126 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_menu_scene"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_main")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
layout_mode = 1
|
||||
theme_override_constants/separation = 16
|
||||
size_flags_horizontal = 4
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
text = "AGP Game"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 72
|
||||
|
||||
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||
layout_mode = 1
|
||||
text = "Login"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||
layout_mode = 1
|
||||
text = "Register"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/VBoxContainer/AuthPanel"]
|
||||
layout_mode = 1
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="AuthVBox" type="VBoxContainer" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer"]
|
||||
layout_mode = 1
|
||||
theme_override_constants/separation = 8
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AuthModeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
text = "Login"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 24
|
||||
|
||||
[node name="UsernameEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
placeholder_text = "Username"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="PasswordEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
placeholder_text = "Password"
|
||||
size_flags_horizontal = 3
|
||||
echo_password = true
|
||||
|
||||
[node name="EmailEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
placeholder_text = "Email"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
text = "I am 18 or older"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
text = "Login"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AuthStatusLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
text = ""
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
visible = false
|
||||
|
||||
[node name="WelcomeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
text = ""
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
text = "Play"
|
||||
size_flags_horizontal = 4
|
||||
|
||||
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
text = "Quit"
|
||||
size_flags_horizontal = 4
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
||||
@@ -0,0 +1,24 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
|
||||
radius = 0.35
|
||||
height = 1.6
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape1"]
|
||||
radius = 0.35
|
||||
height = 1.6
|
||||
|
||||
[node name="Player" type="CharacterBody3D"]
|
||||
script = ExtResource("1_player")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("CapsuleShape1")
|
||||
|
||||
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("CapsuleMesh1")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||
current = true
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/server.gd" id="1_server"]
|
||||
|
||||
[node name="Server" type="Node"]
|
||||
script = ExtResource("1_server")
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
|
||||
|
||||
[node name="Player" type="CharacterBody3D"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,78 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/world_list.gd" id="1_wl"]
|
||||
|
||||
[node name="WorldList" type="Control"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_wl")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 3
|
||||
text = "< Back"
|
||||
size_flags_horizontal = 0
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 3
|
||||
text = "Browse Worlds"
|
||||
horizontal_alignment = 1
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 28
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 3
|
||||
text = "Refresh"
|
||||
size_flags_horizontal = 0
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
horizontal_mode = 2
|
||||
vertical_mode = 2
|
||||
|
||||
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
text = "Loading worlds..."
|
||||
visible = false
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 18
|
||||
|
||||
[node name="ErrorLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
text = ""
|
||||
visible = false
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 18
|
||||
|
||||
[node name="EmptyLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
text = ""
|
||||
visible = false
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 18
|
||||
|
||||
[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"]
|
||||
[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]
|
||||
@@ -0,0 +1,120 @@
|
||||
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||
size = Vector3(40, 1, 40)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
|
||||
size = Vector3(40, 1, 40)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_prop1"]
|
||||
size = Vector3(2, 3, 2)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_prop1"]
|
||||
size = Vector3(2, 3, 2)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_prop2"]
|
||||
size = Vector3(3, 1, 3)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_prop2"]
|
||||
size = Vector3(3, 1, 3)
|
||||
|
||||
[sub_resource type="CylinderShape3D" id="CylinderShape3D_prop3"]
|
||||
top_radius = 1.0
|
||||
height = 4.0
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_prop3"]
|
||||
top_radius = 1.0
|
||||
bottom_radius = 1.0
|
||||
height = 4.0
|
||||
subdivisions_top_radius = 8
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_prop4"]
|
||||
radius = 1.0
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_prop4"]
|
||||
radius = 1.0
|
||||
subdivisions_ring = 12
|
||||
subdivisions_height = 12
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_prop5"]
|
||||
radius = 0.5
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_prop5"]
|
||||
radius = 0.5
|
||||
subdivisions_ring = 8
|
||||
subdivisions_height = 8
|
||||
|
||||
[sub_resource type="Environment" id="WorldEnvironment_env"]
|
||||
background_mode = 1
|
||||
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"]
|
||||
|
||||
[node name="Ground" type="StaticBody3D" parent="."]
|
||||
position = Vector3(0, -0.5, 0)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||
shape = SubResource("BoxShape3D_ground")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||
mesh = SubResource("BoxMesh_ground")
|
||||
|
||||
[node name="Props" type="Node3D" parent="."]
|
||||
|
||||
[node name="Prop1_Box" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(8, 1.5, 8)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop1_Box"]
|
||||
shape = SubResource("BoxShape3D_prop1")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop1_Box"]
|
||||
mesh = SubResource("BoxMesh_prop1")
|
||||
|
||||
[node name="Prop2_Pedestal" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(-8, 0.5, 6)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop2_Pedestal"]
|
||||
shape = SubResource("BoxShape3D_prop2")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop2_Pedestal"]
|
||||
mesh = SubResource("BoxMesh_prop2")
|
||||
|
||||
[node name="Prop3_Column" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(6, 2.0, -8)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop3_Column"]
|
||||
shape = SubResource("CylinderShape3D_prop3")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop3_Column"]
|
||||
mesh = SubResource("CylinderMesh_prop3")
|
||||
|
||||
[node name="Prop4_Sphere" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(-6, 1.0, -6)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop4_Sphere"]
|
||||
shape = SubResource("SphereShape3D_prop4")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop4_Sphere"]
|
||||
mesh = SubResource("SphereMesh_prop4")
|
||||
|
||||
[node name="Prop5_SmallSphere" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(0, 0.5, 12)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop5_SmallSphere"]
|
||||
shape = SubResource("SphereShape3D_prop5")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
|
||||
mesh = SubResource("SphereMesh_prop5")
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||
rotation = Vector3(-0.785398, 0, 0)
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("WorldEnvironment_env")
|
||||
|
||||
[node name="PlayerSpawn" type="Marker3D" parent="."]
|
||||
position = Vector3(0, 1.5, 0)
|
||||
@@ -0,0 +1,292 @@
|
||||
extends Node
|
||||
## Central HTTP client singleton (autoload name: "ApiClient").
|
||||
##
|
||||
## Every network call in the game flows through this class. Callers use the
|
||||
## high-level helpers ([method login], [method register], [method get_worlds],
|
||||
## [method health_check], [method get_me]) or the generic [method request]
|
||||
## primitive. All results conform to the result-dictionary contract below.
|
||||
##
|
||||
## [b]Result Dictionary Contract[/b]
|
||||
## Every public method returns (after `await`) a Dictionary with this shape:
|
||||
##
|
||||
## {
|
||||
## "ok": bool, # true for HTTP 2xx, false otherwise
|
||||
## "status": int, # HTTP status code (0 on network failure)
|
||||
## "data": Variant, # parsed JSON body on success (null otherwise)
|
||||
## "detail": String, # human-readable message
|
||||
## }
|
||||
##
|
||||
## Network failures (timeout / DNS / refused connection) return `status == 0`
|
||||
## with a `detail` describing the transport failure. Non-2xx responses return
|
||||
## the real HTTP status and the backend's `detail` field from the JSON error
|
||||
## body when present. No caller can crash on a failed request.
|
||||
##
|
||||
## [b]Token storage[/b]
|
||||
## JWTs are kept in memory. [method set_token] is the single seam for future
|
||||
## disk persistence (override it to save to `user://`).
|
||||
##
|
||||
## [b]Usage[/b]
|
||||
## var result: Dictionary = await ApiClient.login("alice", "secret")
|
||||
## if result.ok:
|
||||
## print("Logged in!")
|
||||
## else:
|
||||
## print("Error: ", result.detail)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _access_token: String = ""
|
||||
var _token_type: String = "Bearer"
|
||||
|
||||
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
|
||||
var bearer_token: String:
|
||||
get:
|
||||
return _token_type + " " + _access_token if _access_token != "" else ""
|
||||
|
||||
|
||||
## Whether an access token is currently stored.
|
||||
func has_token() -> bool:
|
||||
return _access_token != ""
|
||||
|
||||
|
||||
## Set the access token (called after login / register / token refresh).
|
||||
## This is the persistence seam: override to also write to disk.
|
||||
func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
||||
_access_token = access_token
|
||||
_token_type = token_type
|
||||
|
||||
|
||||
## Clear stored tokens.
|
||||
func clear_token() -> void:
|
||||
_access_token = ""
|
||||
_token_type = "Bearer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _base_url: String = "http://127.0.0.1:8000"
|
||||
|
||||
## Request timeout in seconds. 0 disables the timeout.
|
||||
var timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Environment variable override (e.g. for headless smoke tests against a
|
||||
# backend on a non-default port). Falls back to the ProjectSetting, then
|
||||
# to the documented default.
|
||||
var env_url := OS.get_environment("AGP_API_BASE_URL")
|
||||
if env_url != "":
|
||||
_base_url = env_url.rstrip("/")
|
||||
else:
|
||||
_base_url = str(ProjectSettings.get_setting(
|
||||
"application/agp/api_base_url",
|
||||
"http://127.0.0.1:8000"
|
||||
)).rstrip("/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core request primitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Generic request primitive. Awaitable via `await`.
|
||||
##
|
||||
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
|
||||
## cross-talk, and so the result maps 1:1 to the awaiting caller.
|
||||
##
|
||||
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
|
||||
## @param path URL path, e.g. "/api/health".
|
||||
## @param body Request body string (JSON or form-encoded).
|
||||
## @param content_type "application/json" or "application/x-www-form-urlencoded".
|
||||
## @param require_auth If true, attach the stored bearer token when present.
|
||||
## @return Result dictionary conforming to the contract.
|
||||
func request(
|
||||
method: HTTPClient.Method,
|
||||
path: String,
|
||||
body: String = "",
|
||||
content_type: String = "application/json",
|
||||
require_auth: bool = false
|
||||
) -> Dictionary:
|
||||
var http := HTTPRequest.new()
|
||||
http.timeout = timeout_seconds
|
||||
http.max_redirects = 8
|
||||
http.accept_gzip = true
|
||||
add_child(http)
|
||||
|
||||
var headers := PackedStringArray()
|
||||
headers.append("Accept: application/json")
|
||||
if content_type != "":
|
||||
headers.append("Content-Type: " + content_type)
|
||||
if require_auth and has_token():
|
||||
headers.append("Authorization: " + bearer_token)
|
||||
|
||||
var url := _base_url + path
|
||||
var err := http.request(url, headers, method, body)
|
||||
if err != OK:
|
||||
http.queue_free()
|
||||
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
|
||||
|
||||
# request_completed emits (result, response_code, headers, body).
|
||||
var args: Array = await http.request_completed
|
||||
var result_code: int = args[0]
|
||||
var response_code: int = args[1]
|
||||
var response_body: PackedByteArray = args[3]
|
||||
http.queue_free()
|
||||
|
||||
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
|
||||
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||
return _make_result(false, 0, null, _transport_error_detail(result_code))
|
||||
|
||||
# Server accepted the connection but returned no HTTP status line.
|
||||
if response_code == 0:
|
||||
return _make_result(false, 0, null, "No response received from server.")
|
||||
|
||||
var json_string := response_body.get_string_from_utf8()
|
||||
var ok := response_code >= 200 and response_code < 300
|
||||
|
||||
if ok:
|
||||
var data: Variant = _parse_json(json_string)
|
||||
return _make_result(true, response_code, data, "OK")
|
||||
|
||||
# Non-2xx: surface the backend's "detail" message when available.
|
||||
var detail := "HTTP error %d" % response_code
|
||||
var parsed: Variant = _parse_json(json_string)
|
||||
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
||||
detail = str((parsed as Dictionary)["detail"])
|
||||
return _make_result(false, response_code, null, detail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Log in with username/password (OAuth2PasswordRequestForm).
|
||||
## On success stores the returned access token.
|
||||
func login(username: String, password: String) -> Dictionary:
|
||||
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/login",
|
||||
form,
|
||||
"application/x-www-form-urlencoded",
|
||||
false
|
||||
)
|
||||
_store_token_from_result(result)
|
||||
return result
|
||||
|
||||
|
||||
## Register a new user. On success stores the returned access token.
|
||||
## @param over_18 Must be true; the backend rejects registration otherwise.
|
||||
func register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
over_18: bool
|
||||
) -> Dictionary:
|
||||
var payload := {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": over_18,
|
||||
}
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/register",
|
||||
JSON.stringify(payload),
|
||||
"application/json",
|
||||
false
|
||||
)
|
||||
_store_token_from_result(result)
|
||||
return result
|
||||
|
||||
|
||||
## List worlds visible to the current user (requires auth).
|
||||
## @param visibility Optional filter: "public", "private", "friends_only".
|
||||
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
||||
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
|
||||
if visibility != "":
|
||||
path += "&visibility=" + visibility.uri_encode()
|
||||
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
|
||||
## Ping the backend health endpoint (no auth).
|
||||
func health_check() -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
||||
|
||||
|
||||
## Fetch the current user's profile from the stored JWT (requires auth).
|
||||
func get_me() -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _store_token_from_result(result: Dictionary) -> void:
|
||||
if result.ok and result.data is Dictionary:
|
||||
var token: String = str((result.data as Dictionary).get("access_token", ""))
|
||||
if token != "":
|
||||
set_token(token)
|
||||
|
||||
|
||||
func _parse_json(text: String) -> Variant:
|
||||
if text == "":
|
||||
return null
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed == null:
|
||||
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
|
||||
# the raw string so callers never lose the body entirely.
|
||||
return text if text != "null" else null
|
||||
return parsed
|
||||
|
||||
|
||||
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
|
||||
return {
|
||||
"ok": ok,
|
||||
"status": status,
|
||||
"data": data,
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
func _transport_error_detail(result_code: int) -> String:
|
||||
match result_code:
|
||||
HTTPRequest.RESULT_CANT_CONNECT:
|
||||
return "Could not connect to server. Is it running?"
|
||||
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||
return "Could not resolve server hostname."
|
||||
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||
return "Connection error."
|
||||
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||
return "TLS handshake failed."
|
||||
HTTPRequest.RESULT_NO_RESPONSE:
|
||||
return "No response from server."
|
||||
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
||||
return "Response body too large."
|
||||
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
||||
return "Failed to decompress response body."
|
||||
HTTPRequest.RESULT_REQUEST_FAILED:
|
||||
return "Request failed."
|
||||
HTTPRequest.RESULT_TIMEOUT:
|
||||
return "Request timed out."
|
||||
_:
|
||||
return "Network error (code %d)." % result_code
|
||||
|
||||
|
||||
func _error_string(err_code: int) -> String:
|
||||
match err_code:
|
||||
OK:
|
||||
return "OK"
|
||||
ERR_UNCONFIGURED:
|
||||
return "HTTPRequest not configured (not in tree)."
|
||||
ERR_BUSY:
|
||||
return "HTTPRequest is busy with a previous request."
|
||||
ERR_INVALID_PARAMETER:
|
||||
return "Invalid URL format."
|
||||
ERR_CANT_CONNECT:
|
||||
return "Cannot connect to host."
|
||||
_:
|
||||
return "Unknown error (%d)." % err_code
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjd0uhll7iylj
|
||||
@@ -0,0 +1,181 @@
|
||||
## 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
|
||||
|
||||
|
||||
## Emitted when the catalog is loaded successfully.
|
||||
signal catalog_loaded
|
||||
|
||||
## Emitted when the catalog fails to load.
|
||||
signal catalog_load_failed(error_message: String)
|
||||
|
||||
## Catalog manifest structure:
|
||||
## { "assets": { "id": { "type", "slot", "path", "version", ... } } }
|
||||
var _catalog: Dictionary = {}
|
||||
var _loaded: bool = false
|
||||
var _catalog_path: String = "res://assets/avatars/catalog.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Load the catalog from disk. Call this before any lookups.
|
||||
func load_catalog(path: String = _catalog_path) -> Error:
|
||||
_catalog_path = path
|
||||
_catalog = {}
|
||||
|
||||
var file = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
var err = FileAccess.get_open_error()
|
||||
catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err])
|
||||
return err
|
||||
|
||||
var json_text: String = file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var result: Variant = JSON.parse_string(json_text)
|
||||
if result == null or not result is Dictionary:
|
||||
catalog_load_failed.emit("Invalid JSON in catalog: %s" % path)
|
||||
return ERR_PARSE_ERROR
|
||||
|
||||
var json: Dictionary = result as Dictionary
|
||||
|
||||
if not json.has("assets"):
|
||||
catalog_load_failed.emit("Catalog missing 'assets' key")
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
_catalog = json["assets"]
|
||||
_loaded = true
|
||||
catalog_loaded.emit()
|
||||
return OK
|
||||
|
||||
|
||||
## Check if the catalog has been loaded.
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Resolve an asset by its ID.
|
||||
## Returns a Dictionary with keys: type, slot, path, version, etc.
|
||||
## Returns null if the asset ID is not found.
|
||||
func resolve(id: String) -> Dictionary:
|
||||
if not _loaded:
|
||||
return {}
|
||||
return _catalog.get(id, {})
|
||||
|
||||
|
||||
## Check if an asset ID exists in the catalog.
|
||||
func has_asset(id: String) -> bool:
|
||||
if not _loaded:
|
||||
return false
|
||||
return _catalog.has(id)
|
||||
|
||||
|
||||
## Get all assets that belong to a given slot.
|
||||
## Returns an array of asset ID strings.
|
||||
func get_assets_by_slot(slot: String) -> Array[String]:
|
||||
if not _loaded:
|
||||
return []
|
||||
var result: Array[String] = []
|
||||
for id in _catalog:
|
||||
var asset: Dictionary = _catalog[id]
|
||||
if asset.get("slot") == slot:
|
||||
result.append(id)
|
||||
return result
|
||||
|
||||
|
||||
## Get all asset IDs in the catalog.
|
||||
func get_all_ids() -> Array[String]:
|
||||
if not _loaded:
|
||||
return []
|
||||
return _catalog.keys()
|
||||
|
||||
|
||||
## Validate a loadout against the catalog.
|
||||
## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]).
|
||||
func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
|
||||
# Check base_body
|
||||
if loadout.base_body.is_empty():
|
||||
errors.append("Loadout missing base_body")
|
||||
elif not has_asset(loadout.base_body):
|
||||
errors.append("Unknown base_body asset: %s" % loadout.base_body)
|
||||
else:
|
||||
var base: Dictionary = resolve(loadout.base_body)
|
||||
if base.get("type") != "base_body":
|
||||
errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body)
|
||||
|
||||
# Check each slot
|
||||
for slot_id in loadout.slots:
|
||||
var slot_value = loadout.slots[slot_id]
|
||||
|
||||
if slot_value == null:
|
||||
continue
|
||||
|
||||
if slot_id == "accessory":
|
||||
# Accessory is an array
|
||||
if not slot_value is Array:
|
||||
errors.append("Slot '%s' must be an array" % slot_id)
|
||||
continue
|
||||
for item_id in slot_value:
|
||||
if not has_asset(item_id):
|
||||
errors.append("Unknown accessory asset: %s" % item_id)
|
||||
else:
|
||||
var asset: Dictionary = resolve(item_id)
|
||||
if asset.get("slot") != slot_id:
|
||||
errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [
|
||||
item_id, asset.get("slot"), slot_id
|
||||
])
|
||||
else:
|
||||
# Exclusive slot - single value
|
||||
if not slot_value is String:
|
||||
errors.append("Slot '%s' must be a string or null" % slot_id)
|
||||
continue
|
||||
if not has_asset(slot_value):
|
||||
errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value])
|
||||
else:
|
||||
var asset: Dictionary = resolve(slot_value)
|
||||
if asset.get("slot") != slot_id:
|
||||
errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [
|
||||
slot_value, asset.get("slot"), slot_id
|
||||
])
|
||||
|
||||
return {
|
||||
"valid": errors.is_empty(),
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
|
||||
## Compute a hash of the loadout for change detection.
|
||||
## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int
|
||||
## is 64-bit signed, the value is just constrained to 32 bits of range).
|
||||
static func compute_loadout_hash(loadout: AvatarLoadout) -> int:
|
||||
var hasher = HashingContext.new()
|
||||
hasher.start(HashingContext.HASH_SHA256)
|
||||
|
||||
hasher.update(loadout.base_body.to_utf8_buffer())
|
||||
for slot_id in loadout.slots:
|
||||
var slot_value = loadout.slots[slot_id]
|
||||
hasher.update(slot_id.to_utf8_buffer())
|
||||
if slot_value is Array:
|
||||
for item in slot_value:
|
||||
hasher.update(item.to_utf8_buffer())
|
||||
elif slot_value is String:
|
||||
hasher.update(slot_value.to_utf8_buffer())
|
||||
|
||||
var hash_bytes = hasher.finish()
|
||||
return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24)
|
||||
|
||||
|
||||
## Get the catalog path.
|
||||
func get_catalog_path() -> String:
|
||||
return _catalog_path
|
||||
@@ -0,0 +1 @@
|
||||
uid://ceyugve7rjxqs
|
||||
@@ -0,0 +1,465 @@
|
||||
## 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 = ""
|
||||
|
||||
## 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()
|
||||
|
||||
# Create a fresh loadout.
|
||||
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 ---
|
||||
var preview_panel := PanelContainer.new()
|
||||
preview_panel.name = "PreviewPanel"
|
||||
preview_panel.custom_minimum_size = Vector2(480, 0)
|
||||
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)
|
||||
|
||||
_preview_container = SubViewportContainer.new()
|
||||
_preview_container.name = "PreviewViewportContainer"
|
||||
_preview_container.stretch = true
|
||||
_preview_container.custom_minimum_size = Vector2(464, 560)
|
||||
preview_margin.add_child(_preview_container)
|
||||
|
||||
_preview_viewport = SubViewport.new()
|
||||
_preview_viewport.name = "PreviewViewport"
|
||||
_preview_viewport.size = Vector2i(464, 560)
|
||||
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
_preview_container.add_child(_preview_viewport)
|
||||
|
||||
# Camera for the preview.
|
||||
_preview_camera = Camera3D.new()
|
||||
_preview_camera.name = "PreviewCamera"
|
||||
_preview_camera.position = Vector3(0, 1.0, 3.0)
|
||||
_preview_camera.look_at(Vector3(0, 1.0, 0))
|
||||
_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)
|
||||
|
||||
# A light so the avatar is visible.
|
||||
var light := DirectionalLight3D.new()
|
||||
light.name = "PreviewLight"
|
||||
light.rotation_degrees = Vector3(-45, 30, 0)
|
||||
_preview_viewport.add_child(light)
|
||||
|
||||
# --- 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 tone control.
|
||||
var skin_header := Label.new()
|
||||
skin_header.text = "Skin Tone"
|
||||
skin_header.add_theme_font_size_override("font_size", 16)
|
||||
controls_vbox.add_child(skin_header)
|
||||
|
||||
_skin_tone_slider = HSlider.new()
|
||||
_skin_tone_slider.name = "SkinToneSlider"
|
||||
_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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_skin_tone_changed(value: float) -> void:
|
||||
if loadout == null:
|
||||
return
|
||||
# Map slider 0..1 to a skin tone color range.
|
||||
var t := value
|
||||
var skin := Color(
|
||||
0.6 + 0.3 * t, # R
|
||||
0.4 + 0.2 * t, # G
|
||||
0.3 + 0.15 * t, # B
|
||||
1.0
|
||||
)
|
||||
loadout.skin_tone = skin
|
||||
_refresh_preview()
|
||||
loadout_changed.emit(loadout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Save / load
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_save_pressed() -> void:
|
||||
if loadout == null:
|
||||
return
|
||||
var json := loadout.to_json()
|
||||
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
_status_label.text = "Loadout saved."
|
||||
else:
|
||||
_status_label.text = "Failed to save loadout."
|
||||
|
||||
|
||||
func _on_load_pressed() -> void:
|
||||
if not FileAccess.file_exists("user://loadout.json"):
|
||||
_status_label.text = "No saved loadout found."
|
||||
return
|
||||
|
||||
var file := FileAccess.open("user://loadout.json", FileAccess.READ)
|
||||
if file == null:
|
||||
_status_label.text = "Failed to open saved loadout."
|
||||
return
|
||||
|
||||
var json := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var loaded := AvatarLoadout.from_json(json)
|
||||
if not loaded.validation_errors.is_empty():
|
||||
_status_label.text = "Saved loadout invalid: %s" % "\n".join(loaded.validation_errors)
|
||||
return
|
||||
|
||||
loadout = loaded
|
||||
_refresh_preview()
|
||||
loadout_changed.emit(loadout)
|
||||
_status_label.text = "Loadout loaded."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_close_pressed() -> void:
|
||||
closed.emit()
|
||||
queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://crqtef5rifgrv
|
||||
@@ -0,0 +1,563 @@
|
||||
## 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Maximum number of attachments allowed in the accessory slot.
|
||||
const MAX_ACCESSORY_COUNT := 8
|
||||
|
||||
## 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
|
||||
var glb_data: PackedByteArray = _load_file(path)
|
||||
if glb_data.is_empty():
|
||||
load_error.emit("Failed to read GLB file: %s" % path)
|
||||
return null
|
||||
|
||||
avatar_node = _parse_glb_as_avatar(glb_data, path)
|
||||
if avatar_node == null:
|
||||
load_error.emit("Failed to parse GLB: %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[String] = _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 glb_data: PackedByteArray = _load_file(path)
|
||||
if glb_data.is_empty():
|
||||
load_error.emit("Failed to read GLB file: %s" % path)
|
||||
return null
|
||||
|
||||
var attachment_node = _parse_glb_as_attachment(glb_data, path, slot_id)
|
||||
if attachment_node == null:
|
||||
load_error.emit("Failed to parse attachment GLB: %s" % path)
|
||||
return null
|
||||
|
||||
# Retarget skin to the canonical skeleton.
|
||||
_retarget_skin(attachment_node)
|
||||
|
||||
# Parent static accessories to their mount bone.
|
||||
_parent_attachment(attachment_node, slot_id, asset_meta)
|
||||
|
||||
# Store reference.
|
||||
if slot_id == "accessory":
|
||||
var list: Array[String] = _attachments.get("accessory", [])
|
||||
list.append(asset_id)
|
||||
_attachments["accessory"] = list
|
||||
else:
|
||||
_attachments[slot_id] = attachment_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":
|
||||
var list: Array[String] = _attachments.get("accessory", [])
|
||||
for asset_id in list:
|
||||
var node = _attachments.get(asset_id)
|
||||
if node and node is Node:
|
||||
node.queue_free()
|
||||
_attachments.erase("accessory")
|
||||
else:
|
||||
var node = _attachments.get(slot_id)
|
||||
if node and node is 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
|
||||
|
||||
|
||||
## 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Parse a GLB byte array into an avatar Node3D structure.
|
||||
## Creates: Avatar (Node3D) -> Skeleton3D + BodyMeshes + AttachmentPoints
|
||||
func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D:
|
||||
var avatar := Node3D.new()
|
||||
avatar.name = "Avatar"
|
||||
|
||||
# Create the skeleton container.
|
||||
var skeleton_node := Skeleton3D.new()
|
||||
skeleton_node.name = "Skeleton3D"
|
||||
skeleton_node.position = Vector3(0, 0, 0)
|
||||
avatar.add_child(skeleton_node)
|
||||
|
||||
# Create attachment points container.
|
||||
var attach_points := Node3D.new()
|
||||
attach_points.name = "AttachmentPoints"
|
||||
avatar.add_child(attach_points)
|
||||
|
||||
# Create body meshes container.
|
||||
var body_meshes := Node3D.new()
|
||||
body_meshes.name = "BodyMeshes"
|
||||
avatar.add_child(body_meshes)
|
||||
|
||||
# Create attachments container.
|
||||
var attachments_container := Node3D.new()
|
||||
attachments_container.name = "Attachments"
|
||||
avatar.add_child(attachments_container)
|
||||
|
||||
# Parse the GLB and extract skeleton + meshes.
|
||||
var gltf_document := GLTFDocument.new()
|
||||
var gltf_state := GLTFState.new()
|
||||
|
||||
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
|
||||
if parse_result != OK:
|
||||
push_error("GLB parse error: %s" % error_string(parse_result))
|
||||
avatar.queue_free()
|
||||
return null
|
||||
|
||||
# Generate the actual scene tree from the parsed GLTF state.
|
||||
var scene_root := gltf_document.generate_scene(gltf_state)
|
||||
if scene_root == null:
|
||||
push_error("Failed to generate scene from GLB: %s" % path)
|
||||
avatar.queue_free()
|
||||
return null
|
||||
|
||||
# Merge GLB nodes into our avatar structure (recurses into children).
|
||||
if scene_root is Node3D:
|
||||
_merge_node_into_avatar(scene_root, avatar, body_meshes,
|
||||
attach_points, attachments_container)
|
||||
|
||||
return avatar
|
||||
|
||||
|
||||
## Parse a GLB byte array into an attachment Node3D.
|
||||
func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String,
|
||||
slot_id: String) -> Node3D:
|
||||
|
||||
var gltf_document := GLTFDocument.new()
|
||||
var gltf_state := GLTFState.new()
|
||||
|
||||
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
|
||||
if parse_result != OK:
|
||||
push_error("GLB parse error: %s" % error_string(parse_result))
|
||||
return null
|
||||
|
||||
var scene_root := gltf_document.generate_scene(gltf_state)
|
||||
if scene_root == null:
|
||||
push_error("Failed to generate scene from attachment GLB: %s" % path)
|
||||
return null
|
||||
|
||||
var attachment := Node3D.new()
|
||||
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
|
||||
|
||||
if scene_root is Node3D:
|
||||
attachment.add_child(scene_root.duplicate())
|
||||
|
||||
return attachment
|
||||
|
||||
|
||||
## Merge a GLB node tree into the avatar's container structure.
|
||||
func _merge_node_into_avatar(node: Node3D, avatar: Node3D,
|
||||
body_meshes: Node3D, attach_points: Node3D,
|
||||
attachments_container: Node3D) -> void:
|
||||
|
||||
var node_name := node.name
|
||||
|
||||
# Identify skeleton nodes.
|
||||
if node is Skeleton3D:
|
||||
var src_skel := node as Skeleton3D
|
||||
var dst_skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||
if dst_skel:
|
||||
# Copy bones from source skeleton to destination.
|
||||
for i in src_skel.get_bone_count():
|
||||
var bone_name := src_skel.get_bone_name(i)
|
||||
var bone_pose := src_skel.get_bone_pose(i)
|
||||
var parent_idx := src_skel.get_bone_parent(i)
|
||||
if dst_skel.has_bone(bone_name):
|
||||
# Bone already exists (canonical).
|
||||
dst_skel.set_bone_pose(i, bone_pose)
|
||||
if parent_idx >= 0:
|
||||
var parent_name := src_skel.get_bone_name(parent_idx)
|
||||
var dst_parent_idx := dst_skel.find_bone(parent_name)
|
||||
if dst_parent_idx >= 0:
|
||||
dst_skel.set_bone_parent(i, dst_parent_idx)
|
||||
else:
|
||||
# Add new bone.
|
||||
var new_idx := dst_skel.add_bone(bone_name)
|
||||
dst_skel.set_bone_pose(new_idx, bone_pose)
|
||||
if parent_idx >= 0:
|
||||
var parent_name := src_skel.get_bone_name(parent_idx)
|
||||
var dst_parent_idx := dst_skel.find_bone(parent_name)
|
||||
if dst_parent_idx >= 0:
|
||||
dst_skel.set_bone_parent(new_idx, dst_parent_idx)
|
||||
# Skinned meshes are commonly children of the skeleton node in a GLB
|
||||
# export, not siblings — fall through to the recursion below instead
|
||||
# of returning, so they still get merged in.
|
||||
for child in src_skel.get_children():
|
||||
if child is Node3D:
|
||||
_merge_node_into_avatar(child, avatar, body_meshes,
|
||||
attach_points, attachments_container)
|
||||
return
|
||||
|
||||
# Identify attachment point nodes (empty nodes named attach_*).
|
||||
if node_name.begins_with("attach_"):
|
||||
var cloned := node.duplicate()
|
||||
cloned.name = node_name
|
||||
attach_points.add_child(cloned)
|
||||
return
|
||||
|
||||
# Identify mesh nodes.
|
||||
if node is MeshInstance3D:
|
||||
var mesh_inst := node as MeshInstance3D
|
||||
var cloned := mesh_inst.duplicate() as MeshInstance3D
|
||||
# Determine which container to place it in.
|
||||
if node_name == "Body" or node_name.ends_with("_Mesh"):
|
||||
body_meshes.add_child(cloned)
|
||||
else:
|
||||
attachments_container.add_child(cloned)
|
||||
return
|
||||
|
||||
# For other nodes, recurse into children.
|
||||
for child in node.get_children():
|
||||
if child is Node3D:
|
||||
_merge_node_into_avatar(child, avatar, body_meshes,
|
||||
attach_points, attachments_container)
|
||||
|
||||
|
||||
## Extract base body meshes from the avatar and store them by slot.
|
||||
func _extract_base_meshes(avatar: Node3D) -> void:
|
||||
var body_meshes := avatar.get_node_or_null("BodyMeshes") as Node3D
|
||||
if body_meshes == null:
|
||||
return
|
||||
|
||||
for child in body_meshes.get_children():
|
||||
if child is MeshInstance3D:
|
||||
var mesh_name := child.name
|
||||
var slot_id := _mesh_name_to_slot(mesh_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)
|
||||
var joints_array := arrays[Mesh.ARRAY_BONES] as PackedInt32Array
|
||||
var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://br6x7f83dkrw2
|
||||
@@ -0,0 +1,306 @@
|
||||
## AvatarLoadout - Serializable loadout model + validation.
|
||||
##
|
||||
## Represents a complete avatar composition: a base body plus equipped items
|
||||
## per slot. Validates slot exclusivity and references against a catalog.
|
||||
##
|
||||
## See docs/architecture/avatar.md §5 for the loadout JSON format.
|
||||
|
||||
class_name AvatarLoadout
|
||||
extends RefCounted
|
||||
|
||||
|
||||
## Maximum number of accessories allowed in the accessory slot.
|
||||
const MAX_ACCESSORIES: int = 8
|
||||
|
||||
## The base body asset ID.
|
||||
var base_body: String = ""
|
||||
|
||||
## Equipped items per slot. Keys are slot IDs from AvatarSlotRegistry.
|
||||
## Exclusive slots hold a single asset ID (or null).
|
||||
## The "accessory" slot holds an array of asset IDs.
|
||||
var slots: Dictionary = {}
|
||||
|
||||
## Validation errors found during the last validate() call.
|
||||
var validation_errors: Array[String] = []
|
||||
|
||||
## Skin tone color override (default: transparent = use asset colors).
|
||||
var skin_tone: Color = Color.TRANSPARENT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inner classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Represents a single equipped item in a slot.
|
||||
## Used by consumers to iterate over equipped items.
|
||||
class SlotItem:
|
||||
## The slot this item belongs to.
|
||||
var slot_id: String
|
||||
## The catalog asset ID of the equipped item.
|
||||
var asset_id: String
|
||||
|
||||
func _init(p_slot_id: String, p_asset_id: String) -> void:
|
||||
slot_id = p_slot_id
|
||||
asset_id = p_asset_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _init() -> void:
|
||||
validation_errors = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Create a loadout from a JSON string.
|
||||
static func from_json(json_string: String) -> AvatarLoadout:
|
||||
var loadout := AvatarLoadout.new()
|
||||
var data: Variant = JSON.parse_string(json_string)
|
||||
if not (data is Dictionary):
|
||||
loadout.validation_errors.append("Loadout JSON root is not an object")
|
||||
return loadout
|
||||
|
||||
var root: Dictionary = data as Dictionary
|
||||
|
||||
# Parse base_body.
|
||||
if root.has("base_body"):
|
||||
var bb = root["base_body"]
|
||||
if typeof(bb) == TYPE_STRING:
|
||||
loadout.base_body = bb as String
|
||||
else:
|
||||
loadout.validation_errors.append("base_body must be a string")
|
||||
|
||||
# Parse slots.
|
||||
if root.has("slots"):
|
||||
var slots_data = root["slots"]
|
||||
if typeof(slots_data) == TYPE_DICTIONARY:
|
||||
var parsed_slots: Dictionary = slots_data as Dictionary
|
||||
for slot_id in parsed_slots:
|
||||
if slot_id is String:
|
||||
loadout.slots[slot_id] = parsed_slots[slot_id]
|
||||
else:
|
||||
loadout.validation_errors.append("slots must be an object")
|
||||
else:
|
||||
loadout.validation_errors.append("Missing 'slots' in loadout JSON")
|
||||
|
||||
# Parse skin_tone.
|
||||
if root.has("skin_tone"):
|
||||
var st = root["skin_tone"]
|
||||
if typeof(st) == TYPE_DICTIONARY:
|
||||
var tone: Dictionary = st as Dictionary
|
||||
loadout.skin_tone = Color(
|
||||
float(tone.get("r", 0.0)),
|
||||
float(tone.get("g", 0.0)),
|
||||
float(tone.get("b", 0.0)),
|
||||
float(tone.get("a", 1.0))
|
||||
)
|
||||
|
||||
return loadout
|
||||
|
||||
|
||||
## Serialize this loadout to a JSON string.
|
||||
func to_json() -> String:
|
||||
var data := {
|
||||
"base_body": base_body,
|
||||
"slots": {},
|
||||
}
|
||||
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
if slot_id == "accessory":
|
||||
data["slots"][slot_id] = slots.get(slot_id, [])
|
||||
else:
|
||||
data["slots"][slot_id] = slots.get(slot_id, null)
|
||||
|
||||
# Include skin_tone if set.
|
||||
if skin_tone != Color.TRANSPARENT:
|
||||
data["skin_tone"] = {
|
||||
"r": skin_tone.r,
|
||||
"g": skin_tone.g,
|
||||
"b": skin_tone.b,
|
||||
"a": skin_tone.a,
|
||||
}
|
||||
|
||||
return JSON.stringify(data, " ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Validate this loadout against the slot registry.
|
||||
## Returns true if valid; populates validation_errors if not.
|
||||
func validate() -> bool:
|
||||
validation_errors.clear()
|
||||
|
||||
# Check base_body.
|
||||
if base_body.is_empty():
|
||||
validation_errors.append("base_body is required")
|
||||
|
||||
# Check each known slot.
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
|
||||
if slot_def == null:
|
||||
validation_errors.append("Unknown slot: %s" % slot_id)
|
||||
continue
|
||||
|
||||
var value = slots.get(slot_id)
|
||||
|
||||
if slot_def.exclusive:
|
||||
# Exclusive slots: must be null or a string.
|
||||
if value != null and typeof(value) != TYPE_STRING:
|
||||
validation_errors.append(
|
||||
"Slot '%s' must be null or a string, got %s" % [
|
||||
slot_id, typeof(value)
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Non-exclusive (accessory): must be null or an array.
|
||||
if value != null and typeof(value) != TYPE_ARRAY:
|
||||
validation_errors.append(
|
||||
"Slot '%s' must be null or an array, got %s" % [
|
||||
slot_id, typeof(value)
|
||||
]
|
||||
)
|
||||
elif value is Array:
|
||||
if slot_id == "accessory" and (value as Array).size() > MAX_ACCESSORIES:
|
||||
validation_errors.append(
|
||||
"Accessory count (%d) exceeds maximum (%d)" % [
|
||||
(value as Array).size(), MAX_ACCESSORIES
|
||||
]
|
||||
)
|
||||
|
||||
# Check for duplicate accessories.
|
||||
if slots.has("accessory") and slots["accessory"] is Array:
|
||||
var accessories := slots["accessory"] as Array
|
||||
var seen := {}
|
||||
for item in accessories:
|
||||
if item is String:
|
||||
if seen.has(item):
|
||||
validation_errors.append("Duplicate accessory: %s" % item)
|
||||
seen[item] = true
|
||||
|
||||
return validation_errors.is_empty()
|
||||
|
||||
|
||||
## Convenience alias for validate().
|
||||
func is_valid() -> bool:
|
||||
return validate()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Check if a specific slot is equipped with a non-null item.
|
||||
func is_equipped(slot_id: String) -> bool:
|
||||
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||
return false
|
||||
|
||||
var value = slots.get(slot_id)
|
||||
if value == null:
|
||||
return false
|
||||
|
||||
if typeof(value) == TYPE_STRING:
|
||||
return not (value as String).is_empty()
|
||||
elif typeof(value) == TYPE_ARRAY:
|
||||
return (value as Array).size() > 0
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Get the equipped asset ID for an exclusive slot.
|
||||
## Returns empty string if not equipped or slot is not exclusive.
|
||||
func get_equipment(slot_id: String) -> String:
|
||||
var value = slots.get(slot_id)
|
||||
if value is String:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
## Set equipment for an exclusive slot.
|
||||
func set_equipment(slot_id: String, asset_id: String) -> void:
|
||||
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||
push_error("Unknown slot: %s" % slot_id)
|
||||
return
|
||||
|
||||
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
|
||||
if slot_def and not slot_def.exclusive:
|
||||
push_error("Slot '%s' is not exclusive" % slot_id)
|
||||
return
|
||||
|
||||
slots[slot_id] = null if asset_id.is_empty() else asset_id
|
||||
|
||||
|
||||
## Add an accessory to the accessory slot.
|
||||
func add_accessory(asset_id: String) -> bool:
|
||||
var accessories := slots.get("accessory", []) as Array
|
||||
if accessories.size() >= MAX_ACCESSORIES:
|
||||
return false
|
||||
if asset_id.is_empty():
|
||||
return false
|
||||
if accessories.has(asset_id):
|
||||
return false
|
||||
accessories.append(asset_id)
|
||||
slots["accessory"] = accessories
|
||||
return true
|
||||
|
||||
|
||||
## Remove an accessory from the accessory slot.
|
||||
func remove_accessory(asset_id: String) -> bool:
|
||||
var accessories := slots.get("accessory", []) as Array
|
||||
if accessories.has(asset_id):
|
||||
accessories.erase(asset_id)
|
||||
slots["accessory"] = accessories
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Iterate over equipped items as SlotItem objects.
|
||||
## Returns an array of SlotItem for each equipped slot.
|
||||
func get_equipped_items() -> Array[SlotItem]:
|
||||
var items: Array[SlotItem] = []
|
||||
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
var value = slots.get(slot_id)
|
||||
if value == null:
|
||||
continue
|
||||
|
||||
if typeof(value) == TYPE_STRING:
|
||||
var asset_id := value as String
|
||||
if not asset_id.is_empty():
|
||||
items.append(SlotItem.new(slot_id, asset_id))
|
||||
elif typeof(value) == TYPE_ARRAY:
|
||||
for item_id in value:
|
||||
if item_id is String and not (item_id as String).is_empty():
|
||||
items.append(SlotItem.new(slot_id, item_id))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Compute a hash of this loadout for change detection.
|
||||
## Mirrors the server's loadout_hash concept.
|
||||
func compute_hash() -> int:
|
||||
var hasher := HashingContext.new()
|
||||
hasher.start(HashingContext.HASH_SHA256)
|
||||
hasher.update(base_body.to_utf8_buffer())
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
hasher.update(slot_id.to_utf8_buffer())
|
||||
var value = slots.get(slot_id)
|
||||
if value is String:
|
||||
hasher.update((value as String).to_utf8_buffer())
|
||||
elif value is Array:
|
||||
var arr_str = JSON.stringify(value)
|
||||
hasher.update(arr_str.to_utf8_buffer())
|
||||
else:
|
||||
hasher.update("null".to_utf8_buffer())
|
||||
var digest = hasher.finish()
|
||||
# Return first 4 bytes as int for compact storage.
|
||||
return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxdtebl4upeny
|
||||
@@ -0,0 +1,275 @@
|
||||
## AvatarSlotRegistry - Static registry of slot definitions.
|
||||
##
|
||||
## Each slot maps to one or more bones and defines what can be equipped there.
|
||||
## This is the single source of truth for slot IDs, categories, mount bones,
|
||||
## exclusivity, and body-part vs attachment classification.
|
||||
##
|
||||
## See docs/architecture/avatar.md §3.2 for the full slot specification.
|
||||
class_name AvatarSlotRegistry extends RefCounted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot definitions (mirrors §3.2 of the avatar spec)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## All known slot IDs, in canonical order.
|
||||
const SLOT_IDS: PackedStringArray = [
|
||||
"head",
|
||||
"hair",
|
||||
"face",
|
||||
"torso",
|
||||
"chest",
|
||||
"back",
|
||||
"left_arm",
|
||||
"right_arm",
|
||||
"left_hand",
|
||||
"right_hand",
|
||||
"legs",
|
||||
"pants",
|
||||
"left_foot",
|
||||
"right_foot",
|
||||
"accessory",
|
||||
]
|
||||
|
||||
## Body-part slots (exclusive, replace base mesh).
|
||||
const BODY_PART_SLOTS: PackedStringArray = [
|
||||
"head", "torso", "legs", "left_arm", "right_arm",
|
||||
]
|
||||
|
||||
## Attachment slots (layered on top of base mesh).
|
||||
const ATTACHMENT_SLOTS: PackedStringArray = [
|
||||
"hair", "face", "chest", "back",
|
||||
"left_hand", "right_hand", "pants",
|
||||
"left_foot", "right_foot", "accessory",
|
||||
]
|
||||
|
||||
## Slots that are multi-slot (multiple accessories can coexist).
|
||||
const MULTI_SLOT_IDS: PackedStringArray = ["accessory"]
|
||||
|
||||
|
||||
## Metadata for a single slot.
|
||||
class SlotDefinition:
|
||||
## The slot ID string (e.g. "head", "hair").
|
||||
var slot_key: String
|
||||
## "body_part" or "attachment".
|
||||
var category: String
|
||||
## Primary mount bone name(s).
|
||||
var mount_bones: PackedStringArray
|
||||
## Whether at most one item can occupy this slot.
|
||||
var exclusive: bool
|
||||
## Whether this slot replaces a base mesh.
|
||||
var is_body_part: bool
|
||||
|
||||
func _init(p_slot_key: String, p_category: String, p_mount_bones: PackedStringArray,
|
||||
p_exclusive: bool, p_is_body_part: bool) -> void:
|
||||
slot_key = p_slot_key
|
||||
category = p_category
|
||||
mount_bones = p_mount_bones
|
||||
exclusive = p_exclusive
|
||||
is_body_part = p_is_body_part
|
||||
|
||||
|
||||
# Slot metadata lookup tables (indexed by SLOT_IDS position). Static: every
|
||||
# caller uses AvatarSlotRegistry.method(...) directly on the class, never on
|
||||
# an instance, so this data has to be built once at class-load time via
|
||||
# _static_init() rather than an instance _init() that's never invoked.
|
||||
# Each entry: { category, mount_bones, exclusive, is_body_part }
|
||||
static var _slot_data: Dictionary = {}
|
||||
static var _slot_definitions: Array[SlotDefinition] = []
|
||||
|
||||
|
||||
static func _static_init() -> void:
|
||||
_build_registry()
|
||||
|
||||
|
||||
static func _build_registry() -> void:
|
||||
_slot_data = {
|
||||
"head": {
|
||||
"category": "body_part",
|
||||
"mount_bones": ["Head"],
|
||||
"exclusive": true,
|
||||
"is_body_part": true,
|
||||
},
|
||||
"hair": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["HeadTop"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"face": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["Jaw"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"torso": {
|
||||
"category": "body_part",
|
||||
"mount_bones": ["Chest"],
|
||||
"exclusive": true,
|
||||
"is_body_part": true,
|
||||
},
|
||||
"chest": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["Chest"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"back": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["Chest"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"left_arm": {
|
||||
"category": "body_part",
|
||||
"mount_bones": ["LeftUpperArm"],
|
||||
"exclusive": true,
|
||||
"is_body_part": true,
|
||||
},
|
||||
"right_arm": {
|
||||
"category": "body_part",
|
||||
"mount_bones": ["RightUpperArm"],
|
||||
"exclusive": true,
|
||||
"is_body_part": true,
|
||||
},
|
||||
"left_hand": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["LeftHand"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"right_hand": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["RightHand"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"legs": {
|
||||
"category": "body_part",
|
||||
"mount_bones": ["Hips"],
|
||||
"exclusive": true,
|
||||
"is_body_part": true,
|
||||
},
|
||||
"pants": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["Hips"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"left_foot": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["LeftFoot"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"right_foot": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["RightFoot"],
|
||||
"exclusive": true,
|
||||
"is_body_part": false,
|
||||
},
|
||||
"accessory": {
|
||||
"category": "attachment",
|
||||
"mount_bones": ["Root"],
|
||||
"exclusive": false,
|
||||
"is_body_part": false,
|
||||
},
|
||||
}
|
||||
|
||||
# Build SlotDefinition objects.
|
||||
for slot_id in SLOT_IDS:
|
||||
var data = _slot_data[slot_id]
|
||||
var def := SlotDefinition.new(
|
||||
slot_id,
|
||||
data["category"],
|
||||
PackedStringArray(data["mount_bones"]),
|
||||
data["exclusive"],
|
||||
data["is_body_part"],
|
||||
)
|
||||
_slot_definitions.append(def)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Returns the list of all known slot IDs.
|
||||
static func get_slot_ids() -> PackedStringArray:
|
||||
return SLOT_IDS
|
||||
|
||||
|
||||
## Returns true if *slot_id* is a known slot.
|
||||
static func has_slot(slot_id: String) -> bool:
|
||||
return slot_id in SLOT_IDS
|
||||
|
||||
|
||||
## Returns the SlotDefinition for a slot, or null if unknown.
|
||||
static func get_slot(slot_id: String) -> SlotDefinition:
|
||||
if not has_slot(slot_id):
|
||||
return null
|
||||
for def in _slot_definitions:
|
||||
if def.slot_key == slot_id:
|
||||
return def
|
||||
return null
|
||||
|
||||
|
||||
## Returns all SlotDefinition objects in canonical order.
|
||||
static func get_slot_definitions() -> Array[SlotDefinition]:
|
||||
return _slot_definitions
|
||||
|
||||
|
||||
## Returns the category for a slot ("body_part" or "attachment").
|
||||
static func get_category(slot_id: String) -> String:
|
||||
if not has_slot(slot_id):
|
||||
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||
return ""
|
||||
return _slot_data[slot_id]["category"]
|
||||
|
||||
|
||||
## Returns the primary mount bone(s) for a slot.
|
||||
static func get_mount_bones(slot_id: String) -> PackedStringArray:
|
||||
if not has_slot(slot_id):
|
||||
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||
return PackedStringArray()
|
||||
return PackedStringArray(_slot_data[slot_id]["mount_bones"])
|
||||
|
||||
|
||||
## Returns whether the slot is exclusive (at most one item).
|
||||
static func is_exclusive(slot_id: String) -> bool:
|
||||
if not has_slot(slot_id):
|
||||
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||
return true
|
||||
return _slot_data[slot_id]["exclusive"]
|
||||
|
||||
|
||||
## Returns whether the slot is a body-part slot (replaces base mesh).
|
||||
static func is_body_part_slot(slot_id: String) -> bool:
|
||||
if not has_slot(slot_id):
|
||||
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||
return false
|
||||
return _slot_data[slot_id]["is_body_part"]
|
||||
|
||||
|
||||
## Returns whether the slot is a multi-slot (e.g. accessory).
|
||||
static func is_multi_slot(slot_id: String) -> bool:
|
||||
return slot_id in MULTI_SLOT_IDS
|
||||
|
||||
|
||||
## Returns the list of body-part slot IDs.
|
||||
static func get_body_part_slots() -> PackedStringArray:
|
||||
return BODY_PART_SLOTS
|
||||
|
||||
|
||||
## Returns the list of attachment slot IDs.
|
||||
static func get_attachment_slots() -> PackedStringArray:
|
||||
return ATTACHMENT_SLOTS
|
||||
|
||||
|
||||
## Validates that an asset's declared slot matches the slot it is placed in.
|
||||
static func validate_slot_assignment(asset_slot: String, slot_id: String) -> bool:
|
||||
return asset_slot == slot_id
|
||||
|
||||
|
||||
## Returns the canonical order index for a slot (for serialization order).
|
||||
static func get_slot_index(slot_id: String) -> int:
|
||||
return SLOT_IDS.find(slot_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://do873d38pqhwa
|
||||
@@ -0,0 +1,222 @@
|
||||
## AvatarSystem - Global avatar orchestrator (autoload singleton).
|
||||
##
|
||||
## Owns the AvatarCatalog, AvatarLoader, and the current player's AvatarLoadout.
|
||||
## Provides a single entry point for the rest of the game to:
|
||||
## - Load the asset catalog.
|
||||
## - Load a player's avatar from a loadout.
|
||||
## - Equip / unequip items and persist the resulting loadout.
|
||||
##
|
||||
## Registered as an autoload singleton named "AvatarSystem" in project.godot.
|
||||
|
||||
extends Node
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Emitted when the catalog has finished loading.
|
||||
signal catalog_loaded
|
||||
|
||||
## Emitted when the catalog fails to load.
|
||||
signal catalog_load_failed(error_message: String)
|
||||
|
||||
## Emitted when the avatar has been fully assembled and is ready to render.
|
||||
signal avatar_ready(avatar_node: Node3D)
|
||||
|
||||
## Emitted when the loadout changes (after equip/unequip).
|
||||
signal loadout_changed(loadout: AvatarLoadout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## The asset catalog (id -> metadata + path).
|
||||
var catalog: AvatarCatalog = null
|
||||
|
||||
## The loader used to import GLB assets and assemble the avatar.
|
||||
var loader: AvatarLoader = null
|
||||
|
||||
## The current player's loadout.
|
||||
var current_loadout: AvatarLoadout = null
|
||||
|
||||
## The assembled avatar node (null until avatar_ready).
|
||||
var avatar_node: Node3D = null
|
||||
|
||||
## Whether the catalog has been loaded.
|
||||
var is_catalog_loaded: bool = false
|
||||
|
||||
## Whether an avatar is currently assembled.
|
||||
var is_avatar_ready: bool = false
|
||||
|
||||
## Default catalog path.
|
||||
const DEFAULT_CATALOG_PATH := "res://assets/avatars/catalog.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
catalog = AvatarCatalog.new()
|
||||
loader = AvatarLoader.new()
|
||||
loader.init_loader(catalog)
|
||||
add_child(loader)
|
||||
|
||||
|
||||
## Load the asset catalog. Call this once at startup (or on demand).
|
||||
func load_catalog(path: String = DEFAULT_CATALOG_PATH) -> Error:
|
||||
var err := catalog.load_catalog(path)
|
||||
if err == OK:
|
||||
is_catalog_loaded = true
|
||||
catalog_loaded.emit()
|
||||
else:
|
||||
catalog_load_failed.emit("Failed to load catalog: %s" % err)
|
||||
return err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Avatar assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Load and assemble an avatar from a loadout.
|
||||
## Returns the assembled avatar Node3D on success, or null on failure.
|
||||
func load_avatar(loadout: AvatarLoadout) -> Node3D:
|
||||
if not is_catalog_loaded:
|
||||
push_error("AvatarSystem: catalog not loaded; call load_catalog() first")
|
||||
return null
|
||||
|
||||
if loadout == null:
|
||||
push_error("AvatarSystem: loadout is null")
|
||||
return null
|
||||
|
||||
# Validate the loadout against the catalog.
|
||||
var validation := catalog.validate_loadout(loadout)
|
||||
if not validation.valid:
|
||||
push_error("AvatarSystem: invalid loadout: %s" % str(validation.errors))
|
||||
return null
|
||||
|
||||
current_loadout = loadout
|
||||
|
||||
# Reset any previous avatar.
|
||||
loader.reset()
|
||||
|
||||
# Load the base body.
|
||||
var base_body_id := loadout.base_body
|
||||
var base_node := loader.load_base_body(base_body_id)
|
||||
if base_node == null:
|
||||
push_error("AvatarSystem: failed to load base body '%s'" % base_body_id)
|
||||
return null
|
||||
|
||||
# Load each equipped attachment.
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
var slot_value = loadout.slots.get(slot_id)
|
||||
|
||||
if slot_id == "accessory":
|
||||
# Accessory is an array of asset ids.
|
||||
if slot_value is Array:
|
||||
for asset_id in slot_value:
|
||||
if asset_id is String and not (asset_id as String).is_empty():
|
||||
loader.load_attachment(slot_id, asset_id)
|
||||
else:
|
||||
# Exclusive slot: single asset id or null.
|
||||
if slot_value is String and not (slot_value as String).is_empty():
|
||||
loader.load_attachment(slot_id, slot_value)
|
||||
|
||||
# Hide base meshes for equipped body-part slots.
|
||||
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
||||
var slot_value = loadout.slots.get(slot_id)
|
||||
if slot_value is String and not (slot_value as String).is_empty():
|
||||
loader.set_base_mesh_visible(slot_id, false)
|
||||
|
||||
avatar_node = loader.avatar_node
|
||||
is_avatar_ready = avatar_node != null
|
||||
if is_avatar_ready:
|
||||
avatar_ready.emit(avatar_node)
|
||||
|
||||
return avatar_node
|
||||
|
||||
|
||||
## Equip an item into a slot and rebuild the avatar.
|
||||
## Returns true on success.
|
||||
func equip(slot_id: String, asset_id: String) -> bool:
|
||||
if current_loadout == null:
|
||||
push_error("AvatarSystem: no loadout loaded")
|
||||
return false
|
||||
|
||||
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
|
||||
return false
|
||||
|
||||
if not catalog.has_asset(asset_id):
|
||||
push_error("AvatarSystem: unknown asset '%s'" % asset_id)
|
||||
return false
|
||||
|
||||
var asset := catalog.resolve(asset_id)
|
||||
if asset.get("slot") != slot_id:
|
||||
push_error("AvatarSystem: asset '%s' is for slot '%s', not '%s'" % [
|
||||
asset_id, asset.get("slot"), slot_id])
|
||||
return false
|
||||
|
||||
if slot_id == "accessory":
|
||||
if not current_loadout.add_accessory(asset_id):
|
||||
push_error("AvatarSystem: could not add accessory '%s'" % asset_id)
|
||||
return false
|
||||
else:
|
||||
current_loadout.set_equipment(slot_id, asset_id)
|
||||
|
||||
loadout_changed.emit(current_loadout)
|
||||
return true
|
||||
|
||||
|
||||
## Unequip an item from a slot and rebuild the avatar.
|
||||
## Returns true on success.
|
||||
func unequip(slot_id: String, asset_id: String = "") -> bool:
|
||||
if current_loadout == null:
|
||||
push_error("AvatarSystem: no loadout loaded")
|
||||
return false
|
||||
|
||||
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
|
||||
return false
|
||||
|
||||
if slot_id == "accessory":
|
||||
if asset_id.is_empty():
|
||||
push_error("AvatarSystem: accessory unequip requires an asset id")
|
||||
return false
|
||||
if not current_loadout.remove_accessory(asset_id):
|
||||
push_error("AvatarSystem: accessory '%s' not equipped" % asset_id)
|
||||
return false
|
||||
else:
|
||||
current_loadout.set_equipment(slot_id, "")
|
||||
|
||||
loadout_changed.emit(current_loadout)
|
||||
return true
|
||||
|
||||
|
||||
## Serialize the current loadout to JSON.
|
||||
func save_loadout() -> String:
|
||||
if current_loadout == null:
|
||||
return ""
|
||||
return current_loadout.to_json()
|
||||
|
||||
|
||||
## Load a loadout from a JSON string.
|
||||
func load_loadout_from_json(json_string: String) -> AvatarLoadout:
|
||||
var loadout := AvatarLoadout.from_json(json_string)
|
||||
if not loadout.validate():
|
||||
push_error("AvatarSystem: invalid loadout JSON: %s" % str(loadout.validation_errors))
|
||||
return null
|
||||
return loadout
|
||||
|
||||
|
||||
## Get the list of available assets for a given slot.
|
||||
func get_assets_for_slot(slot_id: String) -> Array[String]:
|
||||
return catalog.get_assets_by_slot(slot_id)
|
||||
|
||||
|
||||
## Get the display name for an asset id.
|
||||
func get_asset_display_name(asset_id: String) -> String:
|
||||
var asset := catalog.resolve(asset_id)
|
||||
return asset.get("display_name", asset_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfbvgfisbxbk
|
||||
@@ -0,0 +1,285 @@
|
||||
## AvatarVisualizer
|
||||
##
|
||||
## Builds the render tree from an AvatarLoadout.
|
||||
##
|
||||
## Responsibilities:
|
||||
## - Create a root Node3D (or Node2D for 2D mode) as the avatar container.
|
||||
## - For each slot in the loadout, call AvatarLoader.load_slot() and attach
|
||||
## the resulting Skeleton3D / MeshInstance3D tree to the root.
|
||||
## - Apply skin-tone overrides to all MeshInstance3D nodes.
|
||||
## - Apply a single Skeleton3D root (the "body" skeleton) and retarget all
|
||||
## limb skeletons to it using the bone-map from AvatarSlotRegistry.
|
||||
## - Expose `get_root()` for the caller to attach to the scene tree.
|
||||
## - Expose `clear()` to destroy the tree and free resources.
|
||||
##
|
||||
## Usage:
|
||||
## var viz = AvatarVisualizer.new()
|
||||
## viz.load_loadout(loadout)
|
||||
## avatar_node.add_child(viz.get_root())
|
||||
##
|
||||
## The visualizer owns the Skeleton3D and MeshInstance3D trees it creates.
|
||||
## The caller owns the root node's placement in the scene tree.
|
||||
|
||||
class_name AvatarVisualizer
|
||||
extends Object
|
||||
|
||||
|
||||
## Emitted when the visualizer has finished building the render tree.
|
||||
signal build_complete
|
||||
## Emitted when the visualizer has been cleared.
|
||||
signal cleared
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## The root node that holds the avatar render tree.
|
||||
## Set this to the desired parent before calling load_loadout().
|
||||
var root: Node3D = null
|
||||
|
||||
## The Skeleton3D that serves as the body skeleton.
|
||||
## All limb skeletons are retargeted to this skeleton's bone hierarchy.
|
||||
var body_skeleton: Skeleton3D = null
|
||||
|
||||
## The AvatarLoadout currently loaded (null if none).
|
||||
var current_loadout: AvatarLoadout = null
|
||||
|
||||
## Whether the visualizer is currently building (true during load_loadout).
|
||||
var is_building: bool = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Load an AvatarLoadout and build the render tree.
|
||||
##
|
||||
## If [param root] is null, a new Node3D is created and assigned.
|
||||
## If [param loadout] is null or invalid, the current tree is cleared.
|
||||
func load_loadout(loadout: AvatarLoadout, root_node: Node3D = null) -> void:
|
||||
# Clear any previous tree.
|
||||
clear()
|
||||
|
||||
if loadout == null or not loadout.is_valid():
|
||||
build_complete.emit()
|
||||
return
|
||||
|
||||
current_loadout = loadout
|
||||
is_building = true
|
||||
|
||||
# Create root if not provided.
|
||||
if root_node != null:
|
||||
root = root_node
|
||||
else:
|
||||
root = Node3D.new()
|
||||
root.name = "AvatarVisualizer"
|
||||
|
||||
# Create the body skeleton.
|
||||
body_skeleton = Skeleton3D.new()
|
||||
body_skeleton.name = "AvatarBody"
|
||||
root.add_child(body_skeleton)
|
||||
|
||||
# Build each slot's contribution.
|
||||
for slot_def in AvatarSlotRegistry.get_slot_definitions():
|
||||
var slot_key: String = slot_def.slot_key
|
||||
var slot_item: AvatarLoadout.SlotItem = loadout.slots.get(slot_key)
|
||||
|
||||
if slot_item == null or slot_item.asset_id == "":
|
||||
# Slot is empty — create a placeholder.
|
||||
_add_placeholder_slot(slot_def)
|
||||
continue
|
||||
|
||||
# Load the slot's asset.
|
||||
var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot(
|
||||
slot_item,
|
||||
slot_def,
|
||||
loadout.skin_tone,
|
||||
)
|
||||
|
||||
if slot_result == null:
|
||||
push_warning("AvatarVisualizer: failed to load slot '%s' (%s)" % [slot_key, slot_item.asset_id])
|
||||
_add_placeholder_slot(slot_def)
|
||||
continue
|
||||
|
||||
# Attach the loaded tree to the root.
|
||||
_attach_slot_tree(slot_result, slot_def)
|
||||
|
||||
# Retarget limb skeletons to the body skeleton.
|
||||
_retarget_limbs()
|
||||
|
||||
# Apply skin-tone override to all meshes.
|
||||
_apply_skin_tone(loadout.skin_tone)
|
||||
|
||||
is_building = false
|
||||
build_complete.emit()
|
||||
|
||||
|
||||
## Clear the render tree and free resources.
|
||||
func clear() -> void:
|
||||
if root != null:
|
||||
root.queue_free()
|
||||
root = null
|
||||
body_skeleton = null
|
||||
current_loadout = null
|
||||
cleared.emit()
|
||||
|
||||
|
||||
## Get the root node.
|
||||
func get_root() -> Node3D:
|
||||
return root
|
||||
|
||||
|
||||
## Get a specific slot's Skeleton3D (if it has one).
|
||||
func get_slot_skeleton(slot_key: String) -> Skeleton3D:
|
||||
if body_skeleton == null:
|
||||
return null
|
||||
var bones := body_skeleton.get_bone_count()
|
||||
for i in range(bones):
|
||||
var bone_name: String = body_skeleton.get_bone_name(i)
|
||||
if bone_name == slot_key:
|
||||
return body_skeleton
|
||||
return null
|
||||
|
||||
|
||||
## Get all MeshInstance3D nodes in the render tree.
|
||||
func get_all_mesh_instances() -> Array[MeshInstance3D]:
|
||||
var meshes: Array[MeshInstance3D] = []
|
||||
if root == null:
|
||||
return meshes
|
||||
_collect_meshes(root, meshes)
|
||||
return meshes
|
||||
|
||||
|
||||
## Get all Skeleton3D nodes in the render tree.
|
||||
func get_all_skeletons() -> Array[Skeleton3D]:
|
||||
var skeletons: Array[Skeleton3D] = []
|
||||
if root == null:
|
||||
return skeletons
|
||||
_collect_skeletons(root, skeletons)
|
||||
return skeletons
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Add a placeholder (empty) skeleton for a slot that has no asset.
|
||||
func _add_placeholder_slot(slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
|
||||
if body_skeleton == null:
|
||||
return
|
||||
|
||||
var bone_name: String = slot_def.slot_key
|
||||
var bone_id: int = body_skeleton.add_bone(bone_name)
|
||||
|
||||
# Set the bone's rest pose to identity (placeholder).
|
||||
var rest := Transform3D()
|
||||
rest.origin = slot_def.default_offset
|
||||
body_skeleton.set_bone_rest(bone_id, rest)
|
||||
|
||||
# Set parent to root (bone 0 is the root bone).
|
||||
body_skeleton.set_bone_parent(bone_id, 0)
|
||||
|
||||
|
||||
## Attach a loaded slot's tree to the root.
|
||||
func _attach_slot_tree(result: AvatarLoader.SlotResult, slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
|
||||
if result.skeleton == null:
|
||||
return
|
||||
|
||||
# Add the skeleton as a child of the body skeleton.
|
||||
# The skeleton's bones will be merged into the body skeleton during retargeting.
|
||||
result.skeleton.name = "%s_skeleton" % slot_def.slot_key
|
||||
result.skeleton.visible = true
|
||||
root.add_child(result.skeleton)
|
||||
|
||||
|
||||
## Retarget all limb skeletons to the body skeleton.
|
||||
func _retarget_limbs() -> void:
|
||||
if body_skeleton == null:
|
||||
return
|
||||
|
||||
# Collect all child Skeleton3D nodes (limb skeletons).
|
||||
var limb_skeletons: Array[Skeleton3D] = []
|
||||
for child in root.get_children():
|
||||
if child is Skeleton3D and child != body_skeleton:
|
||||
limb_skeletons.append(child as Skeleton3D)
|
||||
|
||||
# For each limb skeleton, merge its bones into the body skeleton.
|
||||
for limb in limb_skeletons:
|
||||
_merge_skeleton_into_body(limb)
|
||||
limb.queue_free()
|
||||
|
||||
|
||||
## Merge a limb skeleton's bones into the body skeleton.
|
||||
func _merge_skeleton_into_body(limb: Skeleton3D) -> void:
|
||||
if limb == null or body_skeleton == null:
|
||||
return
|
||||
|
||||
var limb_bone_count: int = limb.get_bone_count()
|
||||
var start_id: int = body_skeleton.get_bone_count()
|
||||
|
||||
for i in range(limb_bone_count):
|
||||
var bone_name: String = limb.get_bone_name(i)
|
||||
var bone_id: int = body_skeleton.add_bone(bone_name)
|
||||
|
||||
# Copy the rest transform.
|
||||
var rest: Transform3D = limb.get_bone_rest(i)
|
||||
body_skeleton.set_bone_rest(bone_id, rest)
|
||||
|
||||
# Copy the parent.
|
||||
var parent_id: int = limb.get_bone_parent(i)
|
||||
if parent_id >= 0:
|
||||
# Map parent ID to body skeleton.
|
||||
if parent_id == 0:
|
||||
# Root of limb skeleton -> parent is the body root.
|
||||
body_skeleton.set_bone_parent(bone_id, 0)
|
||||
else:
|
||||
# Offset by start_id to map to body skeleton bones.
|
||||
body_skeleton.set_bone_parent(bone_id, start_id + parent_id)
|
||||
else:
|
||||
body_skeleton.set_bone_parent(bone_id, 0)
|
||||
|
||||
# Copy custom bone data (if any).
|
||||
var custom_data_count: int = limb.get_bone_custom_data_count(i)
|
||||
for j in range(custom_data_count):
|
||||
var data: Variant = limb.get_bone_custom_data(i, j)
|
||||
body_skeleton.set_bone_custom_data(bone_id, j, data)
|
||||
|
||||
# Copy mesh connections.
|
||||
var mesh_count: int = limb.get_bone_mesh_count(i)
|
||||
for m in range(mesh_count):
|
||||
var mesh: MeshInstance3D = limb.get_bone_mesh(i, m)
|
||||
if mesh != null:
|
||||
# Re-parent mesh to body skeleton.
|
||||
mesh.skeleton = body_skeleton
|
||||
mesh.skeleton_path = body_skeleton.get_path()
|
||||
body_skeleton.add_bone_mesh(bone_id, mesh)
|
||||
|
||||
|
||||
## Apply skin-tone override to all MeshInstance3D nodes.
|
||||
func _apply_skin_tone(skin_tone: Color) -> void:
|
||||
if skin_tone == Color.TRANSPARENT:
|
||||
return
|
||||
|
||||
var meshes: Array[MeshInstance3D] = get_all_mesh_instances()
|
||||
for mesh_instance in meshes:
|
||||
if mesh_instance.material_override == null:
|
||||
mesh_instance.material_override = StandardMaterial3D.new()
|
||||
var mat: StandardMaterial3D = mesh_instance.material_override
|
||||
mat.albedo_color = skin_tone
|
||||
|
||||
|
||||
## Recursively collect all MeshInstance3D nodes.
|
||||
func _collect_meshes(node: Node, result: Array[MeshInstance3D]) -> void:
|
||||
if node is MeshInstance3D:
|
||||
result.append(node as MeshInstance3D)
|
||||
for child in node.get_children():
|
||||
_collect_meshes(child, result)
|
||||
|
||||
|
||||
## Recursively collect all Skeleton3D nodes.
|
||||
func _collect_skeletons(node: Node, result: Array[Skeleton3D]) -> void:
|
||||
if node is Skeleton3D:
|
||||
result.append(node as Skeleton3D)
|
||||
for child in node.get_children():
|
||||
_collect_skeletons(child, result)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvr5mmbv2worn
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Headless CI export for Linux and Windows.
|
||||
#
|
||||
# Requires:
|
||||
# - Godot 4.6.2 (the exact version this project + export_presets.cfg were
|
||||
# built against; a different Godot minor version's export templates are
|
||||
# not guaranteed compatible)
|
||||
# - The matching export templates installed at
|
||||
# ~/.local/share/godot/export_templates/4.6.2.stable/
|
||||
# (download: https://godotengine.org/download/archive/4.6.2-stable/,
|
||||
# "Export templates" -- NOT the same as the editor download)
|
||||
# - godot on PATH (or set GODOT_BIN below)
|
||||
#
|
||||
# Usage: scripts/build.sh
|
||||
# Output: export/linux/agp-game.x86_64, export/windows/agp-game.exe
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
GODOT_BIN="${GODOT_BIN:-godot}"
|
||||
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
|
||||
if [ -x "$HOME/godot/godot" ]; then
|
||||
GODOT_BIN="$HOME/godot/godot"
|
||||
else
|
||||
echo "godot binary not found (checked PATH and ~/godot/godot)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Godot version: $("$GODOT_BIN" --version)"
|
||||
|
||||
mkdir -p export/linux export/windows
|
||||
|
||||
echo "=== Exporting Linux/X11 ==="
|
||||
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/agp-game.x86_64
|
||||
|
||||
echo "=== Exporting Windows Desktop ==="
|
||||
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/agp-game.exe
|
||||
|
||||
echo ""
|
||||
echo "Build artifacts:"
|
||||
ls -la export/linux export/windows
|
||||
@@ -0,0 +1,105 @@
|
||||
class_name Chunk
|
||||
extends Node3D
|
||||
## A single world chunk. Holds the chunk coordinate, bounds, and the
|
||||
## visual/gameplay content for that region of the world.
|
||||
##
|
||||
## Chunks are the atomic unit of world geometry, gameplay objects, and
|
||||
## persistence (see docs/architecture/world.md). The server loads and saves
|
||||
## whole chunks; clients stream the chunks around them.
|
||||
|
||||
## Default chunk dimensions in world units (tiles). Matches the constants in
|
||||
## docs/architecture/world.md section 2.2.
|
||||
const CHUNK_SIZE_X: int = 32
|
||||
const CHUNK_SIZE_Y: int = 32
|
||||
const CHUNK_SIZE_Z: int = 1
|
||||
|
||||
## World-space origin of chunk coordinate (0,0).
|
||||
const CHUNK_ORIGIN := Vector3(0.0, 0.0, 0.0)
|
||||
|
||||
## Chunk coordinate (cx, cy, cz) in chunk space.
|
||||
var coord := Vector3i.ZERO
|
||||
|
||||
## The canonical chunk key: "{world_id}:{room_id}:{cx}:{cy}:{cz}".
|
||||
var key: String = ""
|
||||
|
||||
## World and room identifiers this chunk belongs to.
|
||||
var world_id: String = ""
|
||||
var room_id: String = ""
|
||||
|
||||
## Format version of the chunk payload (currently 1).
|
||||
var format_version: int = 1
|
||||
|
||||
## Revision counter, incremented on every write. Used for optimistic
|
||||
## concurrency and change detection.
|
||||
var revision: int = 0
|
||||
|
||||
## Whether this chunk has been modified in memory but not yet flushed.
|
||||
var dirty: bool = false
|
||||
|
||||
## Container for static objects (props, geometry) in this chunk.
|
||||
var objects: Array[Node3D] = []
|
||||
|
||||
## Container for dynamic object templates (spawnable entities).
|
||||
var dynamic_templates: Array[Node3D] = []
|
||||
|
||||
## The world-space bounding box of this chunk.
|
||||
var bounds: AABB
|
||||
|
||||
|
||||
func _init(p_coord: Vector3i = Vector3i.ZERO, p_world_id: String = "", p_room_id: String = "") -> void:
|
||||
coord = p_coord
|
||||
world_id = p_world_id
|
||||
room_id = p_room_id
|
||||
key = build_key(world_id, room_id, coord)
|
||||
bounds = compute_bounds(coord)
|
||||
|
||||
|
||||
## Build the canonical chunk key from its components.
|
||||
static func build_key(p_world_id: String, p_room_id: String, p_coord: Vector3i) -> String:
|
||||
return "%s:%s:%d:%d:%d" % [p_world_id, p_room_id, p_coord.x, p_coord.y, p_coord.z]
|
||||
|
||||
|
||||
## Compute the world-space bounding box for a chunk coordinate.
|
||||
static func compute_bounds(p_coord: Vector3i) -> AABB:
|
||||
var min_pos := Vector3(
|
||||
CHUNK_ORIGIN.x + p_coord.x * CHUNK_SIZE_X,
|
||||
CHUNK_ORIGIN.y + p_coord.y * CHUNK_SIZE_Y,
|
||||
CHUNK_ORIGIN.z + p_coord.z * CHUNK_SIZE_Z
|
||||
)
|
||||
return AABB(min_pos, Vector3(CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z))
|
||||
|
||||
|
||||
## Compute the chunk coordinate that contains a world-space position.
|
||||
static func coord_for_position(p_pos: Vector3) -> Vector3i:
|
||||
return Vector3i(
|
||||
int(floor((p_pos.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)),
|
||||
int(floor((p_pos.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)),
|
||||
int(floor((p_pos.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z))
|
||||
)
|
||||
|
||||
|
||||
## Add a static object to this chunk.
|
||||
func add_object(p_object: Node3D) -> void:
|
||||
objects.append(p_object)
|
||||
add_child(p_object)
|
||||
|
||||
|
||||
## Add a dynamic object template to this chunk.
|
||||
func add_dynamic_template(p_template: Node3D) -> void:
|
||||
dynamic_templates.append(p_template)
|
||||
add_child(p_template)
|
||||
|
||||
|
||||
## Remove all objects from this chunk.
|
||||
func clear_objects() -> void:
|
||||
for obj in objects:
|
||||
if is_instance_valid(obj):
|
||||
obj.queue_free()
|
||||
objects.clear()
|
||||
dynamic_templates.clear()
|
||||
|
||||
|
||||
## Mark this chunk as dirty (modified but not yet persisted).
|
||||
func mark_dirty() -> void:
|
||||
dirty = true
|
||||
revision += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc6j7kqjitc6g
|
||||
@@ -0,0 +1,667 @@
|
||||
class_name ChunkDataDecoder
|
||||
extends RefCounted
|
||||
## Decodes chunk binary payloads (and JSON) into logical chunk data.
|
||||
##
|
||||
## Implements the binary format spec from docs/architecture/world.md §3.6.
|
||||
## Both the Godot client and Python backend use this class to ensure
|
||||
## identical encoding/decoding behavior.
|
||||
##
|
||||
## ## Binary Format Overview
|
||||
##
|
||||
## ```
|
||||
## Header (48 bytes):
|
||||
## [0:4] magic "AGPC"
|
||||
## [4:6] version (uint16 LE)
|
||||
## [6:8] header_len (uint16 LE)
|
||||
## [8:12] flags (uint32 LE)
|
||||
## [12:16] payload_len (uint32 LE)
|
||||
## [16:48] checksum (SHA-256 hex, 32 bytes)
|
||||
##
|
||||
## Payload Body (sections):
|
||||
## [section_id:1][section_len:4][data:section_len]
|
||||
## ```
|
||||
|
||||
|
||||
## Content flags parsed from a chunk.
|
||||
class ContentFlags:
|
||||
## Severity levels: 0=none, 1=mild, 2=moderate, 3=severe
|
||||
var violence: int = 0
|
||||
var sexual: int = 0
|
||||
var language: int = 0
|
||||
var substance: int = 0
|
||||
var gambling: int = 0
|
||||
var horror: int = 0
|
||||
var hate: int = 0
|
||||
var custom: int = 0
|
||||
var mature: bool = false
|
||||
var sources: Array[String] = []
|
||||
|
||||
func is_mature() -> bool:
|
||||
return mature
|
||||
|
||||
func has_severe_content() -> bool:
|
||||
return (violence >= 3 or sexual >= 3 or language >= 3 or
|
||||
substance >= 3 or gambling >= 3 or horror >= 3 or
|
||||
hate >= 3 or custom >= 3)
|
||||
|
||||
func get_max_severity() -> int:
|
||||
return maxi(
|
||||
violence, maxi(sexual, maxi(language, maxi(substance,
|
||||
maxi(gambling, maxi(horror, maxi(hate, custom)))))))
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"categories": {
|
||||
"violence": violence,
|
||||
"sexual": sexual,
|
||||
"language": language,
|
||||
"substance": substance,
|
||||
"gambling": gambling,
|
||||
"horror": horror,
|
||||
"hate": hate,
|
||||
"custom": custom,
|
||||
},
|
||||
"mature": mature,
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
func from_dict(d: Dictionary) -> void:
|
||||
if d.has("categories"):
|
||||
violence = d["categories"].get("violence", 0)
|
||||
sexual = d["categories"].get("sexual", 0)
|
||||
language = d["categories"].get("language", 0)
|
||||
substance = d["categories"].get("substance", 0)
|
||||
gambling = d["categories"].get("gambling", 0)
|
||||
horror = d["categories"].get("horror", 0)
|
||||
hate = d["categories"].get("hate", 0)
|
||||
custom = d["categories"].get("custom", 0)
|
||||
mature = d.get("mature", false)
|
||||
sources = d.get("sources", [])
|
||||
|
||||
|
||||
## A tile layer definition (palette + dense tile indices).
|
||||
class TileLayerData:
|
||||
var name: String = ""
|
||||
var layer_id: int = 0
|
||||
var width: int = 0
|
||||
var height: int = 0
|
||||
var depth: int = 1
|
||||
var palette: Array[TileDef] = []
|
||||
var tiles: PackedInt32Array = PackedInt32Array()
|
||||
|
||||
func tile_count() -> int:
|
||||
return width * height * depth
|
||||
|
||||
func get_tile(x: int, y: int, z: int = 0) -> int:
|
||||
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
|
||||
return 0 # Out of bounds returns empty tile
|
||||
var idx := (z * height + y) * width + x
|
||||
if idx < tiles.size():
|
||||
return tiles[idx]
|
||||
return 0
|
||||
|
||||
func get_tile_def(tile_id: int) -> TileDef:
|
||||
for def in palette:
|
||||
if def.id == tile_id:
|
||||
return def
|
||||
return null
|
||||
|
||||
|
||||
## A world object parsed from chunk data.
|
||||
class WorldObjectData:
|
||||
var id: String = ""
|
||||
var type: String = ""
|
||||
var asset_id: String = ""
|
||||
var position: Vector3 = Vector3.ZERO
|
||||
var rotation: Vector4 = Vector4(0, 0, 0, 1)
|
||||
var scale: Vector3 = Vector3.ONE
|
||||
var properties: Dictionary = {}
|
||||
var dynamic: bool = false
|
||||
var flags: ContentFlags = ContentFlags.new()
|
||||
|
||||
|
||||
## The complete decoded chunk payload.
|
||||
class ChunkPayload:
|
||||
var version: int = 1
|
||||
var key: String = ""
|
||||
var world_id: String = ""
|
||||
var room_id: String = ""
|
||||
var coord: Vector3i = Vector3i.ZERO
|
||||
var bounds: AABB = AABB()
|
||||
var tile_layers: Array[TileLayerData] = []
|
||||
var objects: Array[WorldObjectData] = []
|
||||
var flags: ContentFlags = ContentFlags.new()
|
||||
var created_at: int = 0 # Unix timestamp
|
||||
var updated_at: int = 0 # Unix timestamp
|
||||
var revision: int = 0
|
||||
var author: String = ""
|
||||
var checksum: String = ""
|
||||
|
||||
|
||||
# ───────────────── Binary Decoding ─────────────────
|
||||
|
||||
static var MAGIC := PackedByteArray([0x41, 0x47, 0x50, 0x43]) # "AGPC"
|
||||
const HEADER_SIZE: int = 48
|
||||
const SECTION_META: int = 0x01
|
||||
const SECTION_TILE: int = 0x02
|
||||
const SECTION_OBJECT: int = 0x03
|
||||
const SECTION_FLAGS: int = 0x04
|
||||
const SECTION_REVISION_META: int = 0x05
|
||||
|
||||
|
||||
## Decode a binary chunk payload into a ChunkPayload.
|
||||
## Returns null if the data is invalid or version is unsupported.
|
||||
static func decode_binary(data: PackedByteArray) -> ChunkPayload:
|
||||
if data.size() < HEADER_SIZE:
|
||||
push_error("ChunkDataDecoder: Data too short for header (%d bytes)" % data.size())
|
||||
return null
|
||||
|
||||
# Verify magic
|
||||
var magic := PackedByteArray(data.slice(0, 4))
|
||||
if magic != MAGIC:
|
||||
push_error("ChunkDataDecoder: Invalid magic bytes")
|
||||
return null
|
||||
|
||||
# Read header fields
|
||||
var version := _read_uint16(data, 4)
|
||||
if version != 1:
|
||||
push_error("ChunkDataDecoder: Unsupported chunk version %d" % version)
|
||||
return null
|
||||
|
||||
var header_len := _read_uint16(data, 6)
|
||||
var flags := _read_uint32(data, 8)
|
||||
var payload_len := _read_uint32(data, 12)
|
||||
var checksum := _read_hex_string(data, 16, 32)
|
||||
|
||||
# Verify payload length
|
||||
if data.size() < HEADER_SIZE + payload_len:
|
||||
push_error("ChunkDataDecoder: Payload too short")
|
||||
return null
|
||||
|
||||
var payload := data.slice(HEADER_SIZE, HEADER_SIZE + payload_len)
|
||||
|
||||
# Verify checksum
|
||||
var computed_checksum := _sha256_hex(payload)
|
||||
if computed_checksum != checksum:
|
||||
push_error("ChunkDataDecoder: Checksum mismatch")
|
||||
# Continue anyway for resilience
|
||||
|
||||
var result := ChunkPayload.new()
|
||||
result.version = version
|
||||
result.checksum = checksum
|
||||
|
||||
# Parse sections
|
||||
var offset := 0
|
||||
while offset < payload.size():
|
||||
if offset + 5 > payload.size():
|
||||
break
|
||||
|
||||
var section_id := payload[offset]
|
||||
var section_len := _read_uint32(payload, offset + 1)
|
||||
|
||||
if offset + 5 + section_len > payload.size():
|
||||
break
|
||||
|
||||
var section_data := payload.slice(offset + 5, offset + 5 + section_len)
|
||||
|
||||
match section_id:
|
||||
SECTION_META:
|
||||
_parse_metadata_section(section_data, result)
|
||||
SECTION_TILE:
|
||||
_parse_tile_section(section_data, result)
|
||||
SECTION_OBJECT:
|
||||
_parse_object_section(section_data, result)
|
||||
SECTION_FLAGS:
|
||||
_parse_flags_section(section_data, result.flags)
|
||||
SECTION_REVISION_META:
|
||||
_parse_revision_meta_section(section_data, result)
|
||||
|
||||
offset += 5 + section_len
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Encode a ChunkPayload into binary format.
|
||||
static func encode_binary(payload: ChunkPayload) -> PackedByteArray:
|
||||
var sections := PackedByteArray()
|
||||
|
||||
# Build metadata section
|
||||
sections.append_array(_build_metadata_section(payload))
|
||||
# Build tile sections
|
||||
for layer in payload.tile_layers:
|
||||
sections.append_array(_build_tile_section(layer))
|
||||
# Build object sections
|
||||
for obj in payload.objects:
|
||||
sections.append_array(_build_object_section(obj))
|
||||
# Build flags section
|
||||
sections.append_array(_build_flags_section(payload.flags))
|
||||
# Build revision meta section
|
||||
sections.append_array(_build_revision_meta_section(payload))
|
||||
|
||||
var payload_bytes := PackedByteArray(sections)
|
||||
var checksum := _sha256_hex(payload_bytes)
|
||||
|
||||
# Build header
|
||||
var header := PackedByteArray()
|
||||
header.append_array(MAGIC)
|
||||
header.append_array(_write_uint16(1)) # version
|
||||
header.append_array(_write_uint16(0)) # header_len (after this field)
|
||||
header.append_array(_write_uint32(0)) # flags
|
||||
header.append_array(_write_uint32(payload_bytes.size()))
|
||||
header.append_array(_hex_to_bytes(checksum))
|
||||
|
||||
return header + payload_bytes
|
||||
|
||||
|
||||
# ───────────────── Section Parsers ─────────────────
|
||||
|
||||
static func _parse_metadata_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var offset := 0
|
||||
|
||||
# world_id (length-prefixed string)
|
||||
var world_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
result.world_id = data.slice(offset, offset + world_id_len).get_string_from_utf8()
|
||||
offset += world_id_len
|
||||
|
||||
# room_id (length-prefixed string)
|
||||
var room_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
result.room_id = data.slice(offset, offset + room_id_len).get_string_from_utf8()
|
||||
offset += room_id_len
|
||||
|
||||
# coord (3 × int32)
|
||||
result.coord = Vector3i(
|
||||
_read_int32(data, offset),
|
||||
_read_int32(data, offset + 4),
|
||||
_read_int32(data, offset + 8)
|
||||
)
|
||||
offset += 12
|
||||
|
||||
# bounds (6 × float32)
|
||||
var min_pos := Vector3(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8)
|
||||
)
|
||||
var max_pos := Vector3(
|
||||
_read_float32(data, offset + 12),
|
||||
_read_float32(data, offset + 16),
|
||||
_read_float32(data, offset + 20)
|
||||
)
|
||||
result.bounds = AABB(min_pos, max_pos - min_pos)
|
||||
|
||||
|
||||
static func _parse_tile_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var layer := TileLayerData.new()
|
||||
var offset := 0
|
||||
|
||||
# name (length-prefixed string)
|
||||
var name_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
layer.name = data.slice(offset, offset + name_len).get_string_from_utf8()
|
||||
offset += name_len
|
||||
|
||||
# layer_id (uint16)
|
||||
layer.layer_id = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
|
||||
# dimensions (3 × uint16)
|
||||
layer.width = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
layer.height = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
layer.depth = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
|
||||
# palette
|
||||
var palette_count := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
for _i in range(palette_count):
|
||||
var tile_def := TileDef.new()
|
||||
tile_def.id = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
|
||||
var asset_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
tile_def.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
|
||||
offset += asset_id_len
|
||||
|
||||
tile_def.solid = data[offset] != 0
|
||||
offset += 1
|
||||
|
||||
tile_def.flags = _read_uint32(data, offset)
|
||||
offset += 4
|
||||
|
||||
tile_def.variants = data[offset]
|
||||
offset += 1
|
||||
|
||||
layer.palette.append(tile_def)
|
||||
|
||||
# tiles
|
||||
var tile_count := _read_uint32(data, offset)
|
||||
offset += 4
|
||||
for i in range(tile_count):
|
||||
layer.tiles.append(_read_uint16(data, offset))
|
||||
offset += 2
|
||||
|
||||
result.tile_layers.append(layer)
|
||||
|
||||
|
||||
static func _parse_object_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var obj := WorldObjectData.new()
|
||||
var offset := 0
|
||||
|
||||
# id (length-prefixed string)
|
||||
var id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.id = data.slice(offset, offset + id_len).get_string_from_utf8()
|
||||
offset += id_len
|
||||
|
||||
# type (length-prefixed string)
|
||||
var type_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.type = data.slice(offset, offset + type_len).get_string_from_utf8()
|
||||
offset += type_len
|
||||
|
||||
# asset_id (length-prefixed string)
|
||||
var asset_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
|
||||
offset += asset_id_len
|
||||
|
||||
# position (3 × float32)
|
||||
obj.position = Vector3(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8)
|
||||
)
|
||||
offset += 12
|
||||
|
||||
# rotation (4 × float32)
|
||||
obj.rotation = Vector4(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8),
|
||||
_read_float32(data, offset + 12)
|
||||
)
|
||||
offset += 16
|
||||
|
||||
# scale (3 × float32)
|
||||
obj.scale = Vector3(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8)
|
||||
)
|
||||
offset += 12
|
||||
|
||||
# dynamic (uint8)
|
||||
obj.dynamic = data[offset] != 0
|
||||
offset += 1
|
||||
|
||||
# properties (length-prefixed JSON string)
|
||||
var props_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
var props_str := data.slice(offset, offset + props_len).get_string_from_utf8()
|
||||
offset += props_len
|
||||
obj.properties = JSON.parse_string(props_str) or {}
|
||||
|
||||
# flags (ContentFlags)
|
||||
obj.flags.violence = data[offset]
|
||||
offset += 1
|
||||
obj.flags.sexual = data[offset]
|
||||
offset += 1
|
||||
obj.flags.language = data[offset]
|
||||
offset += 1
|
||||
obj.flags.substance = data[offset]
|
||||
offset += 1
|
||||
obj.flags.gambling = data[offset]
|
||||
offset += 1
|
||||
obj.flags.horror = data[offset]
|
||||
offset += 1
|
||||
obj.flags.hate = data[offset]
|
||||
offset += 1
|
||||
obj.flags.custom = data[offset]
|
||||
offset += 1
|
||||
obj.flags.mature = data[offset] != 0
|
||||
offset += 1
|
||||
|
||||
var source_count := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
for _i in range(source_count):
|
||||
var src_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
|
||||
offset += src_len
|
||||
|
||||
result.objects.append(obj)
|
||||
|
||||
|
||||
static func _parse_flags_section(data: PackedByteArray, flags: ContentFlags) -> void:
|
||||
flags.violence = data[0]
|
||||
flags.sexual = data[1]
|
||||
flags.language = data[2]
|
||||
flags.substance = data[3]
|
||||
flags.gambling = data[4]
|
||||
flags.horror = data[5]
|
||||
flags.hate = data[6]
|
||||
flags.custom = data[7]
|
||||
flags.mature = data[8] != 0
|
||||
|
||||
var offset := 9
|
||||
var source_count := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
for _i in range(source_count):
|
||||
var src_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
|
||||
offset += src_len
|
||||
|
||||
|
||||
static func _parse_revision_meta_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var offset := 0
|
||||
# created_at (int64 unix)
|
||||
result.created_at = _read_int64(data, offset)
|
||||
offset += 8
|
||||
# updated_at (int64 unix)
|
||||
result.updated_at = _read_int64(data, offset)
|
||||
offset += 8
|
||||
# revision (uint32)
|
||||
result.revision = _read_uint32(data, offset)
|
||||
offset += 4
|
||||
# author (length-prefixed string)
|
||||
var author_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
result.author = data.slice(offset, offset + author_len).get_string_from_utf8()
|
||||
|
||||
|
||||
# ───────────────── Section Builders ─────────────────
|
||||
|
||||
static func _build_metadata_section(payload: ChunkPayload) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_string(payload.world_id))
|
||||
data.append_array(_write_string(payload.room_id))
|
||||
data.append_array(_write_int32(payload.coord.x))
|
||||
data.append_array(_write_int32(payload.coord.y))
|
||||
data.append_array(_write_int32(payload.coord.z))
|
||||
data.append_array(_write_float32(payload.bounds.position.x))
|
||||
data.append_array(_write_float32(payload.bounds.position.y))
|
||||
data.append_array(_write_float32(payload.bounds.position.z))
|
||||
data.append_array(_write_float32(payload.bounds.size.x))
|
||||
data.append_array(_write_float32(payload.bounds.size.y))
|
||||
data.append_array(_write_float32(payload.bounds.size.z))
|
||||
return data
|
||||
|
||||
|
||||
static func _build_tile_section(layer: TileLayerData) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_string(layer.name))
|
||||
data.append_array(_write_uint16(layer.layer_id))
|
||||
data.append_array(_write_uint16(layer.width))
|
||||
data.append_array(_write_uint16(layer.height))
|
||||
data.append_array(_write_uint16(layer.depth))
|
||||
data.append_array(_write_uint16(layer.palette.size()))
|
||||
|
||||
for tile_def in layer.palette:
|
||||
data.append_array(_write_uint16(tile_def.id))
|
||||
data.append_array(_write_string(tile_def.asset_id))
|
||||
data.append(tile_def.solid as int)
|
||||
data.append_array(_write_uint32(tile_def.flags))
|
||||
data.append(tile_def.variants)
|
||||
|
||||
data.append_array(_write_uint32(layer.tiles.size()))
|
||||
for tile_id in layer.tiles:
|
||||
data.append_array(_write_uint16(tile_id))
|
||||
|
||||
return _wrap_section(data, SECTION_TILE)
|
||||
|
||||
|
||||
static func _build_object_section(obj: WorldObjectData) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_string(obj.id))
|
||||
data.append_array(_write_string(obj.type))
|
||||
data.append_array(_write_string(obj.asset_id))
|
||||
data.append_array(_write_float32(obj.position.x))
|
||||
data.append_array(_write_float32(obj.position.y))
|
||||
data.append_array(_write_float32(obj.position.z))
|
||||
data.append_array(_write_float32(obj.rotation.x))
|
||||
data.append_array(_write_float32(obj.rotation.y))
|
||||
data.append_array(_write_float32(obj.rotation.z))
|
||||
data.append_array(_write_float32(obj.rotation.w))
|
||||
data.append_array(_write_float32(obj.scale.x))
|
||||
data.append_array(_write_float32(obj.scale.y))
|
||||
data.append_array(_write_float32(obj.scale.z))
|
||||
data.append(obj.dynamic as int)
|
||||
|
||||
var props_json := JSON.stringify(obj.properties, " ")
|
||||
data.append_array(_write_string(props_json))
|
||||
|
||||
data.append(obj.flags.violence)
|
||||
data.append(obj.flags.sexual)
|
||||
data.append(obj.flags.language)
|
||||
data.append(obj.flags.substance)
|
||||
data.append(obj.flags.gambling)
|
||||
data.append(obj.flags.horror)
|
||||
data.append(obj.flags.hate)
|
||||
data.append(obj.flags.custom)
|
||||
data.append(obj.flags.mature as int)
|
||||
data.append_array(_write_uint16(obj.flags.sources.size()))
|
||||
for src in obj.flags.sources:
|
||||
data.append_array(_write_string(src))
|
||||
|
||||
return _wrap_section(data, SECTION_OBJECT)
|
||||
|
||||
|
||||
static func _build_flags_section(flags: ContentFlags) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append(flags.violence)
|
||||
data.append(flags.sexual)
|
||||
data.append(flags.language)
|
||||
data.append(flags.substance)
|
||||
data.append(flags.gambling)
|
||||
data.append(flags.horror)
|
||||
data.append(flags.hate)
|
||||
data.append(flags.custom)
|
||||
data.append(flags.mature as int)
|
||||
data.append_array(_write_uint16(flags.sources.size()))
|
||||
for src in flags.sources:
|
||||
data.append_array(_write_string(src))
|
||||
return _wrap_section(data, SECTION_FLAGS)
|
||||
|
||||
|
||||
static func _build_revision_meta_section(payload: ChunkPayload) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_int64(payload.created_at))
|
||||
data.append_array(_write_int64(payload.updated_at))
|
||||
data.append_array(_write_uint32(payload.revision))
|
||||
data.append_array(_write_string(payload.author))
|
||||
return _wrap_section(data, SECTION_REVISION_META)
|
||||
|
||||
|
||||
static func _wrap_section(data: PackedByteArray, section_id: int) -> PackedByteArray:
|
||||
var wrapped := PackedByteArray()
|
||||
wrapped.append(section_id)
|
||||
wrapped.append_array(_write_uint32(data.size()))
|
||||
wrapped.append_array(data)
|
||||
return wrapped
|
||||
|
||||
|
||||
# ───────────────── Binary Helpers ─────────────────
|
||||
|
||||
static func _read_uint16(data: PackedByteArray, offset: int) -> int:
|
||||
return data[offset] | (data[offset + 1] << 8)
|
||||
|
||||
static func _read_uint32(data: PackedByteArray, offset: int) -> int:
|
||||
return (data[offset] | (data[offset + 1] << 8) |
|
||||
(data[offset + 2] << 16) | (data[offset + 3] << 24))
|
||||
|
||||
static func _read_int32(data: PackedByteArray, offset: int) -> int:
|
||||
var val := _read_uint32(data, offset)
|
||||
if val >= 0x80000000:
|
||||
return val - 0x100000000
|
||||
return val
|
||||
|
||||
static func _read_int64(data: PackedByteArray, offset: int) -> int:
|
||||
var lo := _read_uint32(data, offset)
|
||||
var hi := _read_uint32(data, offset + 4)
|
||||
return hi * 0x100000000 + lo
|
||||
|
||||
static func _read_float32(data: PackedByteArray, offset: int) -> float:
|
||||
var bits := _read_uint32(data, offset)
|
||||
return bits as float # GDScript float is 64-bit; bits repr works for IEEE 754
|
||||
|
||||
static func _read_hex_string(data: PackedByteArray, offset: int, length: int) -> String:
|
||||
var hex_chars := "0123456789abcdef"
|
||||
var result := ""
|
||||
for i in range(length):
|
||||
var byte_val := data[offset + i]
|
||||
result += hex_chars[byte_val >> 4]
|
||||
result += hex_chars[byte_val & 0x0F]
|
||||
return result
|
||||
|
||||
|
||||
static func _write_uint16(val: int) -> PackedByteArray:
|
||||
return PackedByteArray([(val & 0xFF), (val >> 8) & 0xFF])
|
||||
|
||||
static func _write_uint32(val: int) -> PackedByteArray:
|
||||
return PackedByteArray([
|
||||
val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF
|
||||
])
|
||||
|
||||
static func _write_int32(val: int) -> PackedByteArray:
|
||||
return _write_uint32(val)
|
||||
|
||||
static func _write_int64(val: int) -> PackedByteArray:
|
||||
var lo := val & 0xFFFFFFFF
|
||||
var hi := (val >> 32) & 0xFFFFFFFF
|
||||
return _write_uint32(lo) + _write_uint32(hi)
|
||||
|
||||
static func _write_float32(val: float) -> PackedByteArray:
|
||||
var bits := val as int
|
||||
return _write_uint32(bits)
|
||||
|
||||
static func _write_string(s: String) -> PackedByteArray:
|
||||
var bytes := s.to_utf8_buffer()
|
||||
return _write_uint16(bytes.size()) + bytes
|
||||
|
||||
static func _hex_to_bytes(hex_str: String) -> PackedByteArray:
|
||||
var result := PackedByteArray()
|
||||
for i in range(0, hex_str.length(), 2):
|
||||
var byte_val := ("0x" + hex_str.substr(i, 2)).hex_to_int()
|
||||
result.append(byte_val)
|
||||
return result
|
||||
|
||||
|
||||
# ───────────────── SHA-256 ─────────────────
|
||||
|
||||
static func _sha256_hex(data: PackedByteArray) -> String:
|
||||
var ctx := HashingContext.new()
|
||||
ctx.start(HashingContext.HASH_SHA256)
|
||||
ctx.update(data)
|
||||
var hash := ctx.finish()
|
||||
var hex_chars := "0123456789abcdef"
|
||||
var result := ""
|
||||
for byte_val in hash:
|
||||
result += hex_chars[(byte_val >> 4) & 0x0F]
|
||||
result += hex_chars[byte_val & 0x0F]
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bv445xrd6f5tk
|
||||
@@ -0,0 +1,170 @@
|
||||
class_name ChunkScene
|
||||
extends Node3D
|
||||
## A visual representation of a single loaded world chunk.
|
||||
##
|
||||
## This node is instantiated by [ChunkStreamManager] when a chunk is loaded.
|
||||
## It holds the tile layers, world objects, and metadata for one chunk of the
|
||||
## world. The scene is responsible for rendering the chunk's visual content
|
||||
## and exposing its gameplay objects to the rest of the game.
|
||||
##
|
||||
## ## Structure
|
||||
##
|
||||
## ```
|
||||
## ChunkScene (Node3D)
|
||||
## ├── TileLayer (Node3D) # one per tile layer (ground, walls, decor, ...)
|
||||
## ├── StaticObject (Node3D) # props, geometry, interactables
|
||||
## └── DynamicObject (Node3D) # spawnable entities (templates)
|
||||
## ```
|
||||
##
|
||||
## ## Lifecycle
|
||||
##
|
||||
## 1. [ChunkStreamManager] requests chunk data from the server.
|
||||
## 2. Server responds with binary payload.
|
||||
## 3. [ChunkStreamManager] decodes the payload and creates a [ChunkScene].
|
||||
## 4. [ChunkScene] populates itself with tile layers and objects.
|
||||
## 5. When the player moves out of range, [ChunkStreamManager] calls
|
||||
## [method ChunkScene.queue_free] to unload it.
|
||||
|
||||
## The chunk coordinate this scene represents.
|
||||
var coord: Vector3i = Vector3i.ZERO
|
||||
|
||||
## The canonical chunk key.
|
||||
var key: String = ""
|
||||
|
||||
## World-space bounding box of this chunk.
|
||||
var bounds: AABB = AABB()
|
||||
|
||||
## Format version of the chunk data.
|
||||
var format_version: int = 1
|
||||
|
||||
## Revision counter from the server.
|
||||
var revision: int = 0
|
||||
|
||||
## List of tile layers in this chunk.
|
||||
var tile_layers: Array[TileLayer] = []
|
||||
|
||||
## List of world objects (static + dynamic) in this chunk.
|
||||
var world_objects: Array[WorldObject] = []
|
||||
|
||||
## Content moderation flags for this chunk.
|
||||
var content_flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
|
||||
|
||||
## Whether this chunk has been modified locally (not yet synced to server).
|
||||
var is_dirty: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Position this chunk scene at its world-space origin
|
||||
global_position = bounds.position
|
||||
# Set up collision shape for the chunk bounds
|
||||
_setup_collision()
|
||||
|
||||
|
||||
func _setup_collision() -> void:
|
||||
# Add a collision shape for the chunk's bounding box
|
||||
var collision := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = bounds.size
|
||||
collision.shape = shape
|
||||
collision.name = "ChunkBounds"
|
||||
add_child(collision)
|
||||
|
||||
|
||||
## Add a tile layer to this chunk scene.
|
||||
func add_tile_layer(layer: TileLayer) -> void:
|
||||
tile_layers.append(layer)
|
||||
var layer_node := TileLayerNode.new()
|
||||
layer_node.tile_layer = layer
|
||||
layer_node.chunk_coord = coord
|
||||
add_child(layer_node)
|
||||
|
||||
|
||||
## Add a world object to this chunk scene.
|
||||
func add_object(obj: WorldObject) -> void:
|
||||
world_objects.append(obj)
|
||||
if obj.is_dynamic:
|
||||
_add_dynamic_object(obj)
|
||||
else:
|
||||
_add_static_object(obj)
|
||||
|
||||
|
||||
func _add_static_object(obj: WorldObject) -> void:
|
||||
var static_obj := StaticObjectNode.new()
|
||||
static_obj.world_object = obj
|
||||
static_obj.position = obj.position
|
||||
static_obj.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
|
||||
static_obj.scale = obj.scale
|
||||
static_obj.name = "static_%s" % obj.id
|
||||
add_child(static_obj)
|
||||
|
||||
|
||||
func _add_dynamic_object(obj: WorldObject) -> void:
|
||||
var dynamic_obj := DynamicObjectNode.new()
|
||||
dynamic_obj.world_object = obj
|
||||
dynamic_obj.position = obj.position
|
||||
dynamic_obj.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
|
||||
dynamic_obj.scale = obj.scale
|
||||
dynamic_obj.name = "dynamic_%s" % obj.id
|
||||
add_child(dynamic_obj)
|
||||
|
||||
|
||||
## Get a world object by its ID.
|
||||
func get_object_by_id(object_id: String) -> WorldObject:
|
||||
for obj in world_objects:
|
||||
if obj.id == object_id:
|
||||
return obj
|
||||
return null
|
||||
|
||||
|
||||
## Get all static objects in this chunk.
|
||||
func get_static_objects() -> Array[WorldObject]:
|
||||
var result: Array[WorldObject] = []
|
||||
for obj in world_objects:
|
||||
if not obj.is_dynamic:
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
|
||||
## Get all dynamic object templates in this chunk.
|
||||
func get_dynamic_templates() -> Array[WorldObject]:
|
||||
var result: Array[WorldObject] = []
|
||||
for obj in world_objects:
|
||||
if obj.is_dynamic:
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
|
||||
## Mark this chunk as dirty (locally modified).
|
||||
func mark_dirty() -> void:
|
||||
is_dirty = true
|
||||
|
||||
|
||||
## Clear all objects and tile layers from this chunk.
|
||||
func clear() -> void:
|
||||
for child in get_children():
|
||||
if child is TileLayerNode or child is StaticObjectNode or child is DynamicObjectNode:
|
||||
child.queue_free()
|
||||
tile_layers.clear()
|
||||
world_objects.clear()
|
||||
|
||||
|
||||
## Get the world-space position of a tile within this chunk.
|
||||
func get_tile_world_position(tile_x: int, tile_y: int, tile_z: int = 0) -> Vector3:
|
||||
return bounds.position + Vector3(
|
||||
tile_x * 1.0, # Tile size is 1 world unit
|
||||
tile_y * 1.0,
|
||||
tile_z * 1.0
|
||||
)
|
||||
|
||||
|
||||
## Check if a world-space position is within this chunk's bounds.
|
||||
func contains_position(pos: Vector3) -> bool:
|
||||
return bounds.has_point(pos)
|
||||
|
||||
|
||||
## Check if a chunk coordinate is adjacent to this chunk.
|
||||
func is_adjacent(other_coord: Vector3i) -> bool:
|
||||
var dx: int = abs(coord.x - other_coord.x)
|
||||
var dy: int = abs(coord.y - other_coord.y)
|
||||
var dz: int = abs(coord.z - other_coord.z)
|
||||
return dx <= 1 and dy <= 1 and dz <= 1 and (dx + dy + dz) > 0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user