Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65995f30a6 | |||
| fd88cef32f | |||
| eb39e4393d | |||
| 9c086a4b10 | |||
| b6c6ddfbc4 | |||
| 186a79a43e | |||
| 8822a03f53 | |||
| c54ea6f055 | |||
| b562e9f7b3 | |||
| 6809018ef1 | |||
| 43a3b888da | |||
| 181ba6ae02 | |||
| 4200a8b358 | |||
| 1e917e196b | |||
| eb4da62f99 | |||
| 3fb45f0ef2 | |||
| 1b1260f371 | |||
| 9731615c34 | |||
| 6ad91b895c | |||
| 44bfcb1871 | |||
| 3639fb3132 | |||
| da4e1420ee | |||
| 80f24bfd01 | |||
| 54bc763dff | |||
| 87f78b0226 | |||
| bd1bdc417a | |||
| 7ccdd3063a | |||
| 67473dddcd | |||
| d76bf4da19 | |||
| 4df1caa279 | |||
| 02747cc266 | |||
| 187ec90440 | |||
| fb52c4fd08 | |||
| bc3901da68 | |||
| 7bd8124199 | |||
| 855311e731 | |||
| 50c6a31c25 | |||
| dba865ae4b | |||
| 260b7a38da | |||
| 682f1f89f7 | |||
| 587a15a52e | |||
| e59cf158e5 | |||
| af85048deb | |||
| d6cd3293ae | |||
| f51c49fcfa | |||
| 3596922696 | |||
| 1526f3a636 | |||
| b6e146e4b2 | |||
| 59ca5a4c6e | |||
| b422e41a0f | |||
| bd44ecba14 | |||
| bae326e14c | |||
| 140db9df91 | |||
| 2dff83bae3 | |||
| 1a8821e3c8 | |||
| 019dfca14f | |||
| 7be50e3313 | |||
| 29de1c5c3a | |||
| 92c10fa657 | |||
| 8357643072 | |||
| 736ebce0d0 | |||
| b60480798f | |||
| 77f7511a4f | |||
| 8be962248d | |||
| 7790670559 | |||
| c4eab8db92 | |||
| 4d754ae48e | |||
| e2a77b055f | |||
| 5966fa9044 | |||
| d1d03df2e6 | |||
| 4dda636ed0 | |||
| 945c498a31 | |||
| be0acb1862 | |||
| 8118f3074b | |||
| 4276a89a6a | |||
| 5803f47069 | |||
| e33b91d976 | |||
| 1c3c57dbd0 | |||
| fdc43bd5f1 | |||
| 406ff3457e | |||
| 3f7acf857c | |||
| 6648b2bba5 | |||
| bfcbd0486f | |||
| f70b5b2ee5 | |||
| 2fa7b1f475 | |||
| 7f753cb1b8 | |||
| 1be947f9ef | |||
| 66adcff145 | |||
| 88197ffe8f | |||
| 38d97a3051 | |||
| 174d3c2630 | |||
| b86e4053bd | |||
| 07f655815f | |||
| 2ffcd9cd3f | |||
| aeccc78143 | |||
| 1c64dd4967 | |||
| 4dd6c7c8af | |||
| e90b394f03 | |||
| 33b0c3f1fb | |||
| 2b88fc476e | |||
| 0ecc16d963 | |||
| 4ec71b87b9 | |||
| dc0c133af8 | |||
| 8f1a1110e2 | |||
| 0f7531008f | |||
| 12fef51020 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| 3a7b2ab306 | |||
| afc2211d70 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| fcfb767c49 | |||
| e2e771d2f8 | |||
| 8a5d3aabea | |||
| 3dd5416e81 | |||
| c5a5476620 | |||
| 06604a24a8 | |||
| 5c21e5051c | |||
| 3cb4d01073 | |||
| 3e81d60268 | |||
| b3d2d71789 | |||
| b3227f5b78 | |||
| 2ba6bc6b9f | |||
| 7d89e860fd | |||
| 03da22aa9e | |||
| 55a65bb7b1 | |||
| 62e15d0c87 | |||
| cbe5e992dd | |||
| d4998d7359 | |||
| ef78caa4de | |||
| 87575eec43 | |||
| 6d5051bbb2 | |||
| 72f39bc547 | |||
| 69b6f40eb9 | |||
| 4d467c0c9e | |||
| e43bc9ff10 | |||
| 5c91bb3f34 | |||
| ca7924d5e1 | |||
| 758307bbee | |||
| 478638d4ff | |||
| 7b074a01ed | |||
| c68877239f | |||
| ed6811a438 | |||
| f64ffd6422 | |||
| 7e28e2b525 | |||
| 99be8f1dab | |||
| b3e66fc072 | |||
| 634b2e39c0 | |||
| 1c75367c87 | |||
| f5a7162285 | |||
| 61dedcf73e | |||
| 580b95635f | |||
| ef21bf9d69 | |||
| e06cd2a23d | |||
| 77f9b74286 | |||
| 6238078565 | |||
| 6108c63c46 | |||
| 109c6ef1a0 | |||
| 33c5236a2d | |||
| 4bb4e9fb8a | |||
| ffbc4849a7 | |||
| a81d29c882 | |||
| 4236c4792b | |||
| a29cfc74af | |||
| cf8fd5a6fd | |||
| fc9a39de93 | |||
| aa2f79b125 | |||
| 8551d14591 | |||
| 073813f3e7 | |||
| cc971ee9f5 | |||
| 54abe7ee96 | |||
| a7cf385d0d | |||
| b5525665dd | |||
| ff610c0466 | |||
| acbc518b4a | |||
| 25a8900357 | |||
| 36fd406925 | |||
| 5a1f99eb28 | |||
| ddfe5dfaad | |||
| cb95895612 | |||
| 62889f39f1 | |||
| ef646a8e54 | |||
| e4d3fe566b | |||
| 1d0222d980 | |||
| 433d5d8f9f | |||
| 30b8dc6de2 | |||
| dcaaf96d81 | |||
| 803405aba4 | |||
| ea6958c11e | |||
| 61ee81a974 | |||
| 62f12e55db | |||
| 4c0af03573 | |||
| 689c51e179 | |||
| 5cfbcd8efd | |||
| 27461d9b5e | |||
| a5dc5cb3b6 | |||
| ea5dd63cd7 | |||
| 5ebd60b0bb | |||
| b2a64a8933 | |||
| 9fcea730b9 | |||
| 5b3fc10649 | |||
| 6018396be2 | |||
| ee6513b8ad | |||
| 37a311b0c5 | |||
| 11e812cb6b | |||
| 5097cdd9f4 | |||
| 2224c34aa8 | |||
| 2c5f6fcb04 | |||
| 7cd41dc920 | |||
| c5d2eb6bd2 | |||
| b650572426 | |||
| 7d84dd5ec8 |
@@ -0,0 +1,39 @@
|
||||
name: checkpoint
|
||||
|
||||
# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke
|
||||
# tests, multiplayer, fresh-clone boot) on every push and PR.
|
||||
#
|
||||
# Runs on a `host`-label act_runner on the AGP box: Docker, ~/godot/godot,
|
||||
# and Python 3 are already there. Deliberately uses no JavaScript actions
|
||||
# (actions/checkout etc.) so the host needs no Node install -- checkout is a
|
||||
# plain git clone from the local Gitea. See docs/ci.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checkpoint:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf repo && mkdir repo && cd repo
|
||||
git init -q
|
||||
git remote add origin "http://x:${{ github.token }}@localhost:3000/${{ github.repository }}.git"
|
||||
git fetch -q --depth 1 origin "${{ github.sha }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Python env
|
||||
working-directory: repo
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --quiet --upgrade pip
|
||||
.venv/bin/pip install --quiet -r backend/requirements.txt
|
||||
|
||||
- name: Checkpoint suite
|
||||
working-directory: repo
|
||||
run: .venv/bin/python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690
|
||||
@@ -8,3 +8,7 @@ export/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Godot UID files (regenerated from path)
|
||||
*.uid
|
||||
assets/build_info.json
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bm01jsuomi50i"
|
||||
path="res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/accessory_belt_01.glb"
|
||||
dest_files=["res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dvou487niwnjt"
|
||||
path="res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/accessory_necklace_01.glb"
|
||||
dest_files=["res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dknbtq33pssf4"
|
||||
path="res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/back_backpack_01.glb"
|
||||
dest_files=["res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://brfp6g6pcbbw7"
|
||||
path="res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/base_human_male_01.glb"
|
||||
dest_files=["res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"assets": {
|
||||
"base_human_male_01": {
|
||||
"type": "base_body",
|
||||
"slot": "base_body",
|
||||
"path": "res://assets/avatars/base_human_male_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Human Male Base"
|
||||
},
|
||||
"head_cyber_01": {
|
||||
"type": "attachment",
|
||||
"slot": "head",
|
||||
"path": "res://assets/avatars/head_cyber_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Cyber Head",
|
||||
"mount_bone": "Head"
|
||||
},
|
||||
"hair_mohawk_01": {
|
||||
"type": "attachment",
|
||||
"slot": "hair",
|
||||
"path": "res://assets/avatars/hair_mohawk_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Mohawk",
|
||||
"mount_bone": "HeadTop"
|
||||
},
|
||||
"hair_ponytail_01": {
|
||||
"type": "attachment",
|
||||
"slot": "hair",
|
||||
"path": "res://assets/avatars/hair_ponytail_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Ponytail",
|
||||
"mount_bone": "HeadTop"
|
||||
},
|
||||
"face_glasses_01": {
|
||||
"type": "attachment",
|
||||
"slot": "face",
|
||||
"path": "res://assets/avatars/face_glasses_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Glasses",
|
||||
"mount_bone": "Jaw"
|
||||
},
|
||||
"chest_bandit_01": {
|
||||
"type": "attachment",
|
||||
"slot": "chest",
|
||||
"path": "res://assets/avatars/chest_bandit_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Bandit Shirt",
|
||||
"mount_bone": "Chest"
|
||||
},
|
||||
"back_backpack_01": {
|
||||
"type": "attachment",
|
||||
"slot": "back",
|
||||
"path": "res://assets/avatars/back_backpack_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Backpack",
|
||||
"mount_bone": "Chest"
|
||||
},
|
||||
"pants_cargo_01": {
|
||||
"type": "attachment",
|
||||
"slot": "pants",
|
||||
"path": "res://assets/avatars/pants_cargo_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Cargo Pants",
|
||||
"mount_bone": "Hips"
|
||||
},
|
||||
"left_hand_glove_01": {
|
||||
"type": "attachment",
|
||||
"slot": "left_hand",
|
||||
"path": "res://assets/avatars/left_hand_glove_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Left Glove",
|
||||
"mount_bone": "LeftHand"
|
||||
},
|
||||
"right_hand_glove_01": {
|
||||
"type": "attachment",
|
||||
"slot": "right_hand",
|
||||
"path": "res://assets/avatars/right_hand_glove_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Right Glove",
|
||||
"mount_bone": "RightHand"
|
||||
},
|
||||
"left_foot_boot_01": {
|
||||
"type": "attachment",
|
||||
"slot": "left_foot",
|
||||
"path": "res://assets/avatars/left_foot_boot_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Left Boot",
|
||||
"mount_bone": "LeftFoot"
|
||||
},
|
||||
"right_foot_boot_01": {
|
||||
"type": "attachment",
|
||||
"slot": "right_foot",
|
||||
"path": "res://assets/avatars/right_foot_boot_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Right Boot",
|
||||
"mount_bone": "RightFoot"
|
||||
},
|
||||
"accessory_belt_01": {
|
||||
"type": "attachment",
|
||||
"slot": "accessory",
|
||||
"path": "res://assets/avatars/accessory_belt_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Belt",
|
||||
"mount_bone": "Root"
|
||||
},
|
||||
"accessory_necklace_01": {
|
||||
"type": "attachment",
|
||||
"slot": "accessory",
|
||||
"path": "res://assets/avatars/accessory_necklace_01.glb",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Necklace",
|
||||
"mount_bone": "Root"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bva2etlpa8act"
|
||||
path="res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/chest_bandit_01.glb"
|
||||
dest_files=["res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://ctwcxf552y325"
|
||||
path="res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/face_glasses_01.glb"
|
||||
dest_files=["res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://j4dc6l3l0j6f"
|
||||
path="res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/hair_mohawk_01.glb"
|
||||
dest_files=["res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dp4vqp2trrank"
|
||||
path="res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/hair_ponytail_01.glb"
|
||||
dest_files=["res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bkp35fnns36xb"
|
||||
path="res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/head_cyber_01.glb"
|
||||
dest_files=["res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bgfjpdu8kg0aj"
|
||||
path="res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/left_foot_boot_01.glb"
|
||||
dest_files=["res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dtq7tgio1iymd"
|
||||
path="res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/left_hand_glove_01.glb"
|
||||
dest_files=["res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://yo204q1m6hs3"
|
||||
path="res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/pants_cargo_01.glb"
|
||||
dest_files=["res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://30ij0eapm6ti"
|
||||
path="res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/right_foot_boot_01.glb"
|
||||
dest_files=["res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bcrtxtp47dv8h"
|
||||
path="res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/avatars/right_hand_glove_01.glb"
|
||||
dest_files=["res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"content_flags": {
|
||||
"violence": "none",
|
||||
"mature": false
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"trigger": {"type": "on_interact"},
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
|
||||
{"verb": "play_sound", "sound_id": "chest_open"},
|
||||
{"verb": "rotate", "target": "self", "degrees": 90, "axis": "y", "duration": 0.4}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"templates": {
|
||||
"small_crate": {
|
||||
"asset_id": "props/crate_small.glb",
|
||||
"display_name": "Small Crate"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=change-me-to-a-secure-random-string
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
# 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
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
from app.models import user, session, world, asset # noqa: F401
|
||||
from app.models import user, session, world, asset, avatar_loadout, world_content # noqa: F401
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
||||
@@ -69,7 +69,8 @@ def upgrade() -> None:
|
||||
sa.Column(
|
||||
"visibility",
|
||||
postgresql.ENUM(
|
||||
"public", "private", "friends_only", name="world_visibility"
|
||||
"public", "private", "friends_only", name="world_visibility",
|
||||
create_type=False,
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
@@ -89,7 +90,7 @@ def upgrade() -> None:
|
||||
"asset_type",
|
||||
postgresql.ENUM(
|
||||
"image", "audio", "video", "document", "model_3d", "other",
|
||||
name="asset_type",
|
||||
name="asset_type", create_type=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
|
||||
@@ -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,39 @@
|
||||
"""add avatar_loadouts table
|
||||
|
||||
Revision ID: 0004_add_avatar_loadouts_table
|
||||
Revises: 0003_add_refresh_tokens_table
|
||||
Create Date: 2026-09-05 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0004_add_avatar_loadouts_table"
|
||||
down_revision: Union[str, None] = "0003_add_refresh_tokens_table"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the avatar_loadouts table (one row per user)."""
|
||||
op.create_table(
|
||||
"avatar_loadouts",
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("data", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the avatar_loadouts table."""
|
||||
op.drop_table("avatar_loadouts")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""add world_rooms, world_chunks, world_chunk_revisions tables
|
||||
|
||||
Revision ID: 0005_add_world_content_tables
|
||||
Revises: 0004_add_avatar_loadouts_table
|
||||
Create Date: 2026-09-14 00:00:00.000000
|
||||
|
||||
See docs/architecture/world_persistence_impl.md §4. Chunk content
|
||||
(rooms, chunk payloads, chunk revisions) lives in Postgres, purely
|
||||
additive to the existing ``worlds`` table.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0005_add_world_content_tables"
|
||||
down_revision: Union[str, None] = "0004_add_avatar_loadouts_table"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"world_rooms",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"world_id", sa.Integer(),
|
||||
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("slug", sa.String(64), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"grid", postgresql.JSONB(), nullable=False,
|
||||
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}',
|
||||
),
|
||||
sa.Column("bounds", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("spawn", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),
|
||||
)
|
||||
op.create_index("ix_world_rooms_world_id", "world_rooms", ["world_id"])
|
||||
|
||||
op.create_table(
|
||||
"world_chunks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"world_id", sa.Integer(),
|
||||
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"room_id", sa.Integer(),
|
||||
sa.ForeignKey("world_rooms.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("chunk_key", sa.String(160), nullable=False, unique=True),
|
||||
sa.Column("cx", sa.Integer(), nullable=False),
|
||||
sa.Column("cy", sa.Integer(), nullable=False),
|
||||
sa.Column("cz", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("format_version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("payload", sa.LargeBinary(), nullable=False),
|
||||
sa.Column("payload_len", sa.Integer(), nullable=False),
|
||||
sa.Column("checksum", sa.String(64), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("content_flags", postgresql.JSONB(), nullable=False, server_default="{}"),
|
||||
sa.Column("is_mature", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column(
|
||||
"updated_by_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
|
||||
)
|
||||
op.create_index("ix_world_chunks_world_id", "world_chunks", ["world_id"])
|
||||
op.create_index("ix_world_chunks_room_id", "world_chunks", ["room_id"])
|
||||
op.create_index(
|
||||
"ix_world_chunks_room_coord", "world_chunks", ["room_id", "cx", "cy", "cz"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_world_chunks_mature", "world_chunks", ["world_id"],
|
||||
postgresql_where=sa.text("is_mature"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"world_chunk_revisions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("chunk_key", sa.String(160), nullable=False),
|
||||
sa.Column("world_id", sa.Integer(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("action", sa.String(16), nullable=False),
|
||||
sa.Column(
|
||||
"actor_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("checksum", sa.String(64), nullable=False),
|
||||
sa.Column("payload", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_world_chunk_revisions_chunk_key", "world_chunk_revisions", ["chunk_key"])
|
||||
op.create_index("ix_world_chunk_revisions_world_id", "world_chunk_revisions", ["world_id"])
|
||||
op.create_index("ix_world_chunk_revisions_created_at", "world_chunk_revisions", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("world_chunk_revisions")
|
||||
op.drop_table("world_chunks")
|
||||
op.drop_table("world_rooms")
|
||||
+121
-6
@@ -1,10 +1,56 @@
|
||||
"""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"
|
||||
@@ -16,21 +62,64 @@ class Settings(BaseSettings):
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
REDIS_TIMEOUT_SECONDS: float = 2.0
|
||||
|
||||
# JWT / Auth
|
||||
# NOTE: production must override SECRET_KEY with a secure random value
|
||||
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
|
||||
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
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"
|
||||
|
||||
# Per-IP auth rate limiting (app/rate_limit.py). Disabled by the
|
||||
# checkpoint smoke suite, which runs every test from one client IP.
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
|
||||
# File uploads
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 100
|
||||
GLTF_MAX_FILE_SIZE_MB: int = 200
|
||||
|
||||
# ── Game-server orchestration ──────────────────────────────────────────
|
||||
# The join flow (POST /api/worlds/{id}/join) ensures a headless Godot
|
||||
# dedicated-server process exists for a world and returns its address.
|
||||
# Disabled by default so nothing tries to spawn Godot in environments
|
||||
# that don't have it (plain pytest, CI without the engine); the
|
||||
# checkpoint/playtest launcher turns it on.
|
||||
GAME_SERVERS_ENABLED: bool = False
|
||||
# Path to the Godot 4 binary. "godot" resolves via PATH.
|
||||
GODOT_BIN: str = str(Path.home() / "godot" / "godot")
|
||||
# Godot project directory (the game repo root -- backend/ is a subdir).
|
||||
GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2])
|
||||
# The host clients should connect to for ENet. Defaults to the LAN IP the
|
||||
# launcher bakes into the exe; overridable for local testing.
|
||||
GAME_SERVER_HOST: str = "127.0.0.1"
|
||||
# Port range for per-world dedicated servers.
|
||||
GAME_SERVER_PORT_MIN: int = 27000
|
||||
GAME_SERVER_PORT_MAX: int = 27099
|
||||
# Kill a world's server this long after its last join with no active
|
||||
# players reported.
|
||||
GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300
|
||||
# How long ensure_server() waits for a spawned server to report
|
||||
# "ready": true in its status file before giving up (503 to the
|
||||
# joining client). See world_persistence_impl.md §7.4 -- content
|
||||
# loading makes a slow start more likely than the old bare-port-bind
|
||||
# wait, so this is a separate, longer-by-default budget.
|
||||
GAME_SERVER_READY_TIMEOUT_SECONDS: float = 20.0
|
||||
# The base URL the dedicated Godot server should call back into this
|
||||
# backend on, for the read-only /api/internal content routes
|
||||
# (world_persistence_impl.md §4.4 / §7).
|
||||
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
|
||||
|
||||
# ── World content persistence (world_persistence_impl.md §4.4) ─────────
|
||||
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB
|
||||
WORLD_CHUNK_MAX_OBJECTS: int = 512
|
||||
WORLD_CHUNK_MAX_BATCH: int = 64
|
||||
WORLD_CHUNK_REVISION_KEEP: int = 20
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -42,6 +131,32 @@ class Settings(BaseSettings):
|
||||
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:
|
||||
|
||||
@@ -27,7 +27,7 @@ async def get_current_user(
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
|
||||
+9
-3
@@ -6,15 +6,18 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, users, worlds, assets
|
||||
from app.routers import auth, users, worlds, assets, avatar, world_content, internal
|
||||
|
||||
|
||||
from app.services.game_servers import supervisor
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown events."""
|
||||
# Startup: initialize database, connect to Redis, etc.
|
||||
supervisor.start()
|
||||
yield
|
||||
# Shutdown: close connections, cleanup
|
||||
await supervisor.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -37,7 +40,10 @@ app.add_middleware(
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
||||
app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"])
|
||||
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
||||
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
|
||||
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -2,5 +2,10 @@ from app.models.user import User
|
||||
from app.models.session import Session
|
||||
from app.models.world import World
|
||||
from app.models.asset import Asset
|
||||
from app.models.avatar_loadout import AvatarLoadout
|
||||
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
|
||||
|
||||
__all__ = ["User", "Session", "World", "Asset"]
|
||||
__all__ = [
|
||||
"User", "Session", "World", "Asset", "AvatarLoadout",
|
||||
"WorldRoom", "WorldChunk", "WorldChunkRevision",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
# The default a brand-new account gets before they touch the customization
|
||||
# screen. Kept in sync with assets/avatars/catalog.json (the base_body id) and
|
||||
# with the client's AvatarLoadout.to_json() shape.
|
||||
DEFAULT_LOADOUT: dict = {
|
||||
"base_body": "base_human_male_01",
|
||||
"slots": {},
|
||||
}
|
||||
|
||||
|
||||
class AvatarLoadout(Base):
|
||||
"""A user's equipped avatar composition.
|
||||
|
||||
One row per user. The whole loadout is stored as a single JSON document
|
||||
matching the client's ``AvatarLoadout.to_json()`` shape
|
||||
(``{"base_body": str, "slots": {...}, "skin_tone"?: {...}}``) rather than
|
||||
exploded into columns -- the server has no copy of the asset catalog, so
|
||||
deep validation (does this asset exist, is it valid for this slot) stays
|
||||
on the client. The server only enforces the envelope: valid JSON object
|
||||
with a non-empty ``base_body``.
|
||||
"""
|
||||
|
||||
__tablename__ = "avatar_loadouts"
|
||||
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
data = Column(JSONB, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="avatar_loadout")
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
|
||||
|
||||
@@ -18,3 +19,23 @@ class Session(Base):
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
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
|
||||
|
||||
@@ -12,10 +15,30 @@ class User(Base):
|
||||
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")
|
||||
avatar_loadout = relationship(
|
||||
"AvatarLoadout",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_adult(self) -> bool:
|
||||
"""Return True if the user is 18 or older based on date_of_birth."""
|
||||
if self.date_of_birth is None:
|
||||
return False
|
||||
today = date.today()
|
||||
age = today.year - self.date_of_birth.year
|
||||
# Adjust if birthday hasn't occurred yet this year
|
||||
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
|
||||
age -= 1
|
||||
return age >= 18
|
||||
|
||||
@@ -21,7 +21,12 @@ class World(Base):
|
||||
description = Column(Text, nullable=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
visibility = Column(
|
||||
ENUM(WorldVisibility, name="world_visibility", create_type=False),
|
||||
ENUM(
|
||||
WorldVisibility,
|
||||
name="world_visibility",
|
||||
create_type=False,
|
||||
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||
),
|
||||
default=WorldVisibility.PUBLIC,
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -29,3 +34,6 @@ class World(Base):
|
||||
|
||||
owner = relationship("User", back_populates="worlds")
|
||||
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
|
||||
# world_content.py / world_persistence_impl.md §4.1. Added here (only)
|
||||
# so PC1/PC2 code depending on this module is otherwise untouched.
|
||||
rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""World content persistence models.
|
||||
|
||||
Implements the schema decided in ``docs/architecture/world_persistence_impl.md``
|
||||
§4: chunk content (rooms, chunk payloads, chunk revisions) lives in Postgres,
|
||||
alongside ``worlds``, rather than in a per-world SQLite file
|
||||
(``docs/architecture/world.md`` §4, superseded).
|
||||
|
||||
The chunk *payload* format itself (the 48-byte header + sectioned binary
|
||||
body) is unchanged from ``world.md`` §3.6 and is decoded/encoded by
|
||||
``app.services.chunk_codec``, not by these models -- these just store the
|
||||
opaque ``BYTEA``.
|
||||
|
||||
Conventions follow the existing models (``app/models/world.py``,
|
||||
``app/models/avatar_loadout.py``): declarative ``Column(...)`` style,
|
||||
``Integer`` surrogate PKs, ``DateTime(timezone=True)`` with
|
||||
``server_default=func.now()``, JSONB for document-shaped fields.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Default chunk grid, matching Chunk.CHUNK_SIZE_* in scripts/chunk.gd (the
|
||||
# client's source of truth for the same constants) and world.md §2.2.
|
||||
DEFAULT_ROOM_GRID: dict = {"size": [32, 32, 1], "origin": [0, 0, 0]}
|
||||
|
||||
# The room every world gets automatically (world_persistence_impl.md §4.1).
|
||||
MAIN_ROOM_SLUG = "main"
|
||||
MAIN_ROOM_NAME = "Main"
|
||||
|
||||
|
||||
class WorldRoom(Base):
|
||||
"""A bounded region within a world -- a level, building, or zone
|
||||
(world.md §2.1). Holds the chunk grid config so a world can later hold
|
||||
rooms with different grids without touching the ``worlds`` table.
|
||||
"""
|
||||
|
||||
__tablename__ = "world_rooms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
world_id = Column(
|
||||
Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
slug = Column(String(64), nullable=False) # url-safe; "main" by default
|
||||
name = Column(String(255), nullable=False)
|
||||
# {"size": [x, y, z], "origin": [x, y, z]}
|
||||
grid = Column(
|
||||
JSONB, nullable=False,
|
||||
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}',
|
||||
)
|
||||
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
|
||||
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),
|
||||
)
|
||||
|
||||
world = relationship("World", back_populates="rooms")
|
||||
chunks = relationship("WorldChunk", back_populates="room", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class WorldChunk(Base):
|
||||
"""One chunk's persisted content -- the atomic load/save/stream unit
|
||||
(world.md §2.1). ``payload`` is the world.md §3.6 binary blob, stored
|
||||
verbatim as BYTEA.
|
||||
|
||||
Deltas from world.md §4.4 (SQLite design): a surrogate integer PK +
|
||||
unique coordinate constraint (rather than ``chunk_key`` as the PK), no
|
||||
``dirty`` column (write-behind doesn't apply over Postgres -- see
|
||||
world_persistence_impl.md §3.3/§5), and content flags folded into this
|
||||
row as JSONB + an indexed ``is_mature`` boolean rather than a separate
|
||||
``content_flags`` table (world.md §4.6).
|
||||
"""
|
||||
|
||||
__tablename__ = "world_chunks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
world_id = Column(
|
||||
Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
) # denormalized: world-scoped queries without a join through rooms
|
||||
room_id = Column(
|
||||
Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
chunk_key = Column(String(160), nullable=False, unique=True) # world.md §2.3 / §4.5
|
||||
cx = Column(Integer, nullable=False)
|
||||
cy = Column(Integer, nullable=False)
|
||||
cz = Column(Integer, nullable=False, server_default="0")
|
||||
|
||||
format_version = Column(Integer, nullable=False, server_default="1")
|
||||
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes
|
||||
payload_len = Column(Integer, nullable=False)
|
||||
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
|
||||
revision = Column(Integer, nullable=False, server_default="1")
|
||||
|
||||
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
|
||||
is_mature = Column(Boolean, nullable=False, server_default="false")
|
||||
|
||||
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
|
||||
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
|
||||
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
|
||||
)
|
||||
|
||||
room = relationship("WorldRoom", back_populates="chunks")
|
||||
|
||||
|
||||
class WorldChunkRevision(Base):
|
||||
"""Audit trail of chunk writes (world.md §4.7). ``chunk_key``/``world_id``
|
||||
are deliberately **not** foreign keys, so deleting a chunk or a world
|
||||
preserves the audit trail.
|
||||
|
||||
Retention (world_persistence_impl.md §4.3, required): only the newest
|
||||
``settings.WORLD_CHUNK_REVISION_KEEP`` rows per ``chunk_key`` keep a
|
||||
non-null ``payload``; older rows are pruned (payload nulled, metadata
|
||||
row kept) by ``WorldStore`` in the same transaction as the write that
|
||||
triggers it.
|
||||
"""
|
||||
|
||||
__tablename__ = "world_chunk_revisions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
chunk_key = Column(String(160), nullable=False, index=True)
|
||||
world_id = Column(Integer, nullable=False, index=True)
|
||||
revision = Column(Integer, nullable=False)
|
||||
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
|
||||
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
checksum = Column(String(64), nullable=False)
|
||||
payload = Column(LargeBinary, nullable=True) # full payload at this revision; nulled by retention
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
+53
-273
@@ -1,293 +1,73 @@
|
||||
"""Redis-backed token-bucket rate limiting.
|
||||
"""Lightweight in-process rate limiting.
|
||||
|
||||
This module provides a reusable FastAPI dependency that rate-limits requests
|
||||
per endpoint scope (optionally per-user) using an atomic Lua script on Redis.
|
||||
The backend runs single-worker (required by the game-server supervisor), so a
|
||||
process-local sliding-window counter is sufficient and needs no Redis. Keyed
|
||||
by client IP. Used to blunt credential brute-force on the auth endpoints.
|
||||
|
||||
If Redis is unavailable the limiter can either allow the request through
|
||||
(fail-open) or reject it with a ``503`` (fail-closed), depending on the
|
||||
policy configured for that limit. Chat send uses fail-closed by default
|
||||
because flooding/abuse is worse than a temporary outage.
|
||||
|
||||
Example
|
||||
-------
|
||||
from app.rate_limit import rate_limit, named_limit
|
||||
|
||||
chat_send_limit = rate_limit(
|
||||
named_limit("chat_send", capacity=5, refill_rate=0.2, fail_open=False),
|
||||
user_id_dependency=Depends(get_current_user_id),
|
||||
)
|
||||
|
||||
@router.post("/messages")
|
||||
async def send_message(
|
||||
payload: MessageCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
_: None = Depends(chat_send_limit),
|
||||
):
|
||||
...
|
||||
If the backend is ever scaled to multiple workers/hosts this must move to a
|
||||
shared store (Redis) -- flagged here rather than silently under-enforcing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from redis.asyncio import Redis
|
||||
from redis.exceptions import RedisError
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Atomic token-bucket implementation.
|
||||
#
|
||||
# KEYS[1] is the bucket key. ARGV[1] is the bucket capacity, ARGV[2] is the
|
||||
# refill rate in tokens per second, ARGV[3] is the current Unix time in
|
||||
# seconds (float), and ARGV[4] is the request cost (tokens to take).
|
||||
#
|
||||
# The bucket state is stored as a hash with "tokens" and "ts". The script
|
||||
# refills the bucket up to capacity, then takes the requested tokens. It
|
||||
# returns an array:
|
||||
# [0] allowed (1 or 0)
|
||||
# [1] tokens remaining after the take
|
||||
# [2] retry-after seconds (0 when allowed)
|
||||
_TOKEN_BUCKET_LUA = """
|
||||
local key = KEYS[1]
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local refill_rate = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local cost = tonumber(ARGV[4])
|
||||
|
||||
local state = redis.call("HMGET", key, "tokens", "ts")
|
||||
local tokens = capacity
|
||||
local ts = now
|
||||
class SlidingWindowLimiter:
|
||||
def __init__(self, max_hits: int, window_seconds: float) -> None:
|
||||
self.max_hits = max_hits
|
||||
self.window = window_seconds
|
||||
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
if state[1] ~= false then
|
||||
tokens = tonumber(state[1])
|
||||
ts = tonumber(state[2]) or now
|
||||
end
|
||||
|
||||
local elapsed = math.max(0, now - ts)
|
||||
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
|
||||
|
||||
local allowed = 0
|
||||
local retry_after = 0
|
||||
|
||||
if tokens >= cost then
|
||||
tokens = tokens - cost
|
||||
allowed = 1
|
||||
else
|
||||
-- Time until enough tokens for a single request have accrued.
|
||||
retry_after = math.ceil((cost - tokens) / refill_rate)
|
||||
end
|
||||
|
||||
-- Keep the bucket state small and expire-able. Retaining the timestamp as a
|
||||
-- float lets partial tokens accumulate smoothly between requests.
|
||||
redis.call("HSET", key, "tokens", tostring(tokens), "ts", tostring(now))
|
||||
redis.call("PEXPIRE", key, 600000)
|
||||
|
||||
return {allowed, tostring(tokens), retry_after}
|
||||
"""
|
||||
|
||||
|
||||
class RateLimitExceeded(HTTPException):
|
||||
"""Raised when a rate-limit bucket has no tokens left."""
|
||||
|
||||
def __init__(self, retry_after: int) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimit:
|
||||
"""Definition of a named token-bucket rate limit.
|
||||
|
||||
``capacity`` is the burst size and ``refill_rate`` is the steady-state
|
||||
token accumulation rate (tokens/second). For example,
|
||||
``RateLimit(name="chat_send", capacity=10, refill_rate=1.0)`` allows a
|
||||
burst of 10 requests and then one request per second on average.
|
||||
"""
|
||||
|
||||
name: str
|
||||
capacity: int
|
||||
refill_rate: float
|
||||
# When Redis is unreachable: ``True`` allows the request (fail-open),
|
||||
# ``False`` rejects it with 503 (fail-closed).
|
||||
fail_open: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise ValueError("name must be non-empty")
|
||||
if self.capacity <= 0:
|
||||
raise ValueError("capacity must be positive")
|
||||
if self.refill_rate <= 0:
|
||||
raise ValueError("refill_rate must be positive")
|
||||
|
||||
|
||||
def default_user_scope(request: Request, user_id: int | None) -> str:
|
||||
"""Scope a bucket per authenticated user, or per client IP if anonymous."""
|
||||
if user_id is not None:
|
||||
return f"user:{user_id}"
|
||||
client = request.client
|
||||
return f"ip:{client.host if client else 'unknown'}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitConfig:
|
||||
"""A named rate limit plus how it is applied to a request."""
|
||||
|
||||
limit: RateLimit
|
||||
# Builds the Redis key fragment for the request. The resolved user id is
|
||||
# passed as the second argument (or ``None`` for anonymous limits).
|
||||
scope: Callable[[Request, int | None], str] = default_user_scope
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitResult:
|
||||
"""Outcome of a rate-limit check (used by tests)."""
|
||||
|
||||
allowed: bool
|
||||
remaining: int
|
||||
retry_after: int
|
||||
|
||||
|
||||
_redis_client: Redis | None = None
|
||||
|
||||
|
||||
def get_redis() -> Redis:
|
||||
"""Return a shared async Redis client for rate limiting.
|
||||
|
||||
The client is created lazily (``Redis.from_url`` does not connect until a
|
||||
command is issued), so this dependency never blocks or fails just because
|
||||
Redis is down.
|
||||
"""
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = Redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=settings.REDIS_TIMEOUT_SECONDS,
|
||||
socket_timeout=settings.REDIS_TIMEOUT_SECONDS,
|
||||
)
|
||||
return _redis_client
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Enforce a token bucket in Redis.
|
||||
|
||||
Buckets are keyed as ``rate:{limit.name}:{scope_key}`` so multiple
|
||||
endpoints can share a named limit while remaining independently bucketed.
|
||||
"""
|
||||
|
||||
def __init__(self, config: RateLimitConfig, redis: Redis | None = None) -> None:
|
||||
self._config = config
|
||||
self._redis = redis
|
||||
|
||||
def _redis_client(self) -> Redis:
|
||||
return self._redis or get_redis()
|
||||
|
||||
def _bucket_key(self, request: Request, user_id: int | None) -> str:
|
||||
limit = self._config.limit
|
||||
scope_key = self._config.scope(request, user_id)
|
||||
return f"rate:{limit.name}:{scope_key}"
|
||||
|
||||
async def check(self, request: Request, user_id: int | None = None) -> RateLimitResult:
|
||||
"""Atomically refill/take a token and return the outcome.
|
||||
|
||||
Redis failures are mapped to ``allowed=True`` (fail-open) or
|
||||
``allowed=False`` (fail-closed, represented with ``retry_after=-1``)
|
||||
according to the limit's policy, without raising.
|
||||
"""
|
||||
limit = self._config.limit
|
||||
key = self._bucket_key(request, user_id)
|
||||
|
||||
try:
|
||||
result = await self._redis_client().eval(
|
||||
_TOKEN_BUCKET_LUA,
|
||||
1,
|
||||
key,
|
||||
str(limit.capacity),
|
||||
str(limit.refill_rate),
|
||||
str(time.time()),
|
||||
"1", # each request consumes one token
|
||||
)
|
||||
except RedisError:
|
||||
if limit.fail_open:
|
||||
return RateLimitResult(allowed=True, remaining=0, retry_after=0)
|
||||
# Fail-closed: no reliable retry estimate is available.
|
||||
return RateLimitResult(allowed=False, remaining=0, retry_after=-1)
|
||||
|
||||
# Redis may return ints or strings depending on decode_responses.
|
||||
allowed = bool(int(result[0]))
|
||||
remaining = int(result[1])
|
||||
retry_after = int(result[2])
|
||||
return RateLimitResult(allowed=allowed, remaining=remaining, retry_after=retry_after)
|
||||
|
||||
async def __call__(self, request: Request, user_id: int | None = None) -> None:
|
||||
"""Enforce the rate limit, raising 429 or 503 as appropriate."""
|
||||
result = await self.check(request, user_id)
|
||||
if result.allowed:
|
||||
return
|
||||
|
||||
if result.retry_after < 0:
|
||||
# Redis was unavailable and this limit is fail-closed.
|
||||
def check(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
q = self._hits[key]
|
||||
cutoff = now - self.window
|
||||
while q and q[0] < cutoff:
|
||||
q.popleft()
|
||||
if len(q) >= self.max_hits:
|
||||
retry = max(1, int(q[0] + self.window - now))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Rate limiting service unavailable",
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many attempts. Try again shortly.",
|
||||
headers={"Retry-After": str(retry)},
|
||||
)
|
||||
raise RateLimitExceeded(retry_after=result.retry_after)
|
||||
q.append(now)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._hits.clear()
|
||||
|
||||
|
||||
def named_limit(
|
||||
name: str,
|
||||
*,
|
||||
capacity: int,
|
||||
refill_rate: float,
|
||||
fail_open: bool = True,
|
||||
) -> RateLimitConfig:
|
||||
"""Create a named rate-limit config with the default per-user/IP scope.
|
||||
def _client_key(request: Request) -> str:
|
||||
# X-Forwarded-For first hop when behind the reverse proxy, else the socket.
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
``capacity`` is the burst size and ``refill_rate`` is tokens/second.
|
||||
|
||||
# 10 auth attempts / minute / IP -- generous for a human, painful for a
|
||||
# brute-forcer.
|
||||
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
|
||||
|
||||
|
||||
async def auth_rate_limit(request: Request) -> None:
|
||||
"""FastAPI dependency: rate-limit auth attempts per client IP.
|
||||
|
||||
A no-op when ``RATE_LIMIT_ENABLED`` is false -- the checkpoint smoke
|
||||
suite runs every test against one backend from one client IP, so the
|
||||
per-IP cap would trip mid-suite. The dedicated ``test_rate_limit``
|
||||
module exercises the real behaviour regardless of this flag.
|
||||
"""
|
||||
return RateLimitConfig(
|
||||
limit=RateLimit(
|
||||
name=name,
|
||||
capacity=capacity,
|
||||
refill_rate=refill_rate,
|
||||
fail_open=fail_open,
|
||||
)
|
||||
)
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
_auth_limiter.check(f"auth:{_client_key(request)}")
|
||||
|
||||
|
||||
def rate_limit(
|
||||
config: RateLimitConfig,
|
||||
*,
|
||||
user_id_dependency: Any = None,
|
||||
) -> Callable[[Request, Any], Awaitable[None]]:
|
||||
"""Build a FastAPI dependency enforcing ``config``.
|
||||
|
||||
``user_id_dependency`` optionally injects the authenticated user id, e.g.
|
||||
``Depends(get_current_user_id)``. If omitted the limiter buckets by client
|
||||
IP (the ``default_user_scope`` fallback), which is suitable for anonymous
|
||||
endpoints but should be paired with an auth resolver for per-user limits.
|
||||
|
||||
The returned callable is intended for ``Depends(...)``.
|
||||
"""
|
||||
|
||||
async def _anonymous_user_id() -> int | None:
|
||||
return None
|
||||
|
||||
resolver = user_id_dependency if user_id_dependency is not None else Depends(_anonymous_user_id)
|
||||
|
||||
async def dependency(
|
||||
request: Request,
|
||||
user_id: int | None = resolver,
|
||||
) -> None:
|
||||
limiter = RateLimiter(config)
|
||||
await limiter(request, user_id)
|
||||
|
||||
return dependency
|
||||
def reset_all() -> None:
|
||||
"""Test hook."""
|
||||
_auth_limiter.reset()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Asset management router."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -13,6 +14,7 @@ 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()
|
||||
@@ -64,6 +66,14 @@ def _sanitize_filename(filename: str) -> str:
|
||||
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)
|
||||
@@ -183,10 +193,16 @@ async def upload_asset_file(
|
||||
# Sanitize filename to prevent path traversal.
|
||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
||||
|
||||
# Validate file size against configured maximum.
|
||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
# 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,
|
||||
@@ -200,10 +216,28 @@ async def upload_asset_file(
|
||||
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
|
||||
|
||||
+206
-14
@@ -1,43 +1,61 @@
|
||||
"""Authentication router - login, register, token refresh."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from jose import jwt
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.session import RefreshToken
|
||||
from app.models.user import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.rate_limit import auth_rate_limit
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
## Minimum age to register. This platform is adults-only.
|
||||
MIN_AGE = 18
|
||||
|
||||
|
||||
# --- Pydantic schemas ---
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
access_token_expires_minutes: int
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
# Real date of birth -- the server computes age from it and rejects
|
||||
# anyone under MIN_AGE. Still self-attested (no document check yet), but
|
||||
# a concrete, stored, legally-meaningful data point rather than a
|
||||
# throwaway checkbox, and the seam for a future hard ID-verification
|
||||
# pass. See docs/architecture/content_moderation.md 5.2.
|
||||
date_of_birth: date
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
date_of_birth: date | None = None
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
@@ -47,12 +65,43 @@ class UserResponse(BaseModel):
|
||||
# --- 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.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def _create_refresh_token(data: dict) -> str:
|
||||
"""Create a signed refresh token using RS256.
|
||||
|
||||
Includes a unique ``jti`` claim so that concurrent calls produce
|
||||
distinct tokens even when issued within the same second.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
to_encode["jti"] = str(uuid.uuid4())
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
@@ -65,11 +114,67 @@ def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
"""Calculate age in years from a birth date.
|
||||
|
||||
Used by /register (PC3 Phase 3) to compute age from the submitted
|
||||
date_of_birth and enforce the 18+ gate. See
|
||||
docs/architecture/content_moderation.md 5.2.
|
||||
|
||||
Args:
|
||||
birth_date: The person's date of birth.
|
||||
today: The reference date. Defaults to today's date (UTC).
|
||||
|
||||
Returns:
|
||||
Age in whole years as of the reference date.
|
||||
"""
|
||||
if today is None:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
age = today.year - birth_date.year
|
||||
# Adjust if birthday hasn't occurred yet this year
|
||||
if (today.month, today.day) < (birth_date.month, birth_date.day):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
|
||||
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||
"""Create and persist a refresh token for the given user."""
|
||||
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
|
||||
refresh_token_record = RefreshToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
token=refresh_token_value,
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
)
|
||||
db.add(refresh_token_record)
|
||||
return refresh_token_value
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(auth_rate_limit)])
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user and return an access token."""
|
||||
"""Register a new user and return access + refresh tokens.
|
||||
|
||||
Requires a date of birth; the server computes age and rejects anyone
|
||||
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
|
||||
verification design and its self-attestation limitation.
|
||||
"""
|
||||
age = calculate_age(req.date_of_birth)
|
||||
if age < 0 or req.date_of_birth > date.today():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date of birth",
|
||||
)
|
||||
if age < MIN_AGE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"You must be at least {MIN_AGE} to use this platform",
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
result = await db.execute(select(User).where(User.username == req.username))
|
||||
if result.scalar_one_or_none():
|
||||
@@ -90,18 +195,29 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
hashed_password=hash_password(req.password),
|
||||
date_of_birth=req.date_of_birth,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
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)})
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@router.post("/login", response_model=TokenResponse, dependencies=[Depends(auth_rate_limit)])
|
||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate user and return an access token.
|
||||
"""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.
|
||||
@@ -121,8 +237,84 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
|
||||
detail="User account is inactive",
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = _create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange a valid refresh token for a new access token.
|
||||
|
||||
Validates the refresh token against the database:
|
||||
- Must exist in the refresh_tokens table
|
||||
- Must not be expired (expires_at > now)
|
||||
- Must be active (is_active == true)
|
||||
|
||||
On success, returns a new access token. The refresh token itself is
|
||||
NOT rotated (the client keeps using the same refresh token).
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
# Step 1: Decode and validate the JWT signature/claims
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
req.refresh_token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user_id_str: str | None = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
# Step 2: Look up the refresh token in the database
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token == req.refresh_token,
|
||||
RefreshToken.user_id == user_id,
|
||||
)
|
||||
)
|
||||
stored_token = result.scalar_one_or_none()
|
||||
|
||||
if stored_token is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 3: Check if the token is expired
|
||||
if datetime.now(timezone.utc) >= stored_token.expires_at:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 4: Check if the token is active
|
||||
if not stored_token.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
# Step 5: Issue a new access token
|
||||
access_token = _create_access_token({"sub": str(user_id)})
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=req.refresh_token, # Return the same refresh token
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Avatar router - the current user's persisted avatar loadout.
|
||||
|
||||
The loadout is a single JSON document matching the client's
|
||||
``AvatarLoadout.to_json()`` shape. The server has no copy of the asset
|
||||
catalog, so it only validates the envelope (JSON object, non-empty
|
||||
``base_body``); which assets are valid for which slot is the client's job.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.avatar_loadout import AvatarLoadout, DEFAULT_LOADOUT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
# A loadout document is small; reject anything absurd outright.
|
||||
MAX_LOADOUT_BYTES = 16 * 1024
|
||||
|
||||
|
||||
class LoadoutResponse(BaseModel):
|
||||
data: dict[str, Any]
|
||||
is_default: bool = Field(
|
||||
description="True when the user has never saved a loadout and this is "
|
||||
"the server default."
|
||||
)
|
||||
|
||||
|
||||
class LoadoutUpdate(BaseModel):
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
def _validate_envelope(data: dict[str, Any]) -> None:
|
||||
base_body = data.get("base_body")
|
||||
if not isinstance(base_body, str) or not base_body.strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="loadout.data must have a non-empty string 'base_body'",
|
||||
)
|
||||
slots = data.get("slots", {})
|
||||
if not isinstance(slots, dict):
|
||||
raise HTTPException(status_code=422, detail="loadout.data.slots must be an object")
|
||||
if len(str(data)) > MAX_LOADOUT_BYTES:
|
||||
raise HTTPException(status_code=413, detail="loadout document too large")
|
||||
|
||||
|
||||
@router.get("/loadout", response_model=LoadoutResponse)
|
||||
async def get_loadout(current_user: CurrentUser, db: DbDep):
|
||||
"""Return the current user's saved loadout, or the server default if they
|
||||
have never saved one."""
|
||||
row = await db.get(AvatarLoadout, current_user.id)
|
||||
if row is None:
|
||||
return LoadoutResponse(data=dict(DEFAULT_LOADOUT), is_default=True)
|
||||
return LoadoutResponse(data=row.data, is_default=False)
|
||||
|
||||
|
||||
@router.put("/loadout", response_model=LoadoutResponse)
|
||||
async def put_loadout(payload: LoadoutUpdate, current_user: CurrentUser, db: DbDep):
|
||||
"""Replace the current user's loadout."""
|
||||
_validate_envelope(payload.data)
|
||||
|
||||
row = await db.get(AvatarLoadout, current_user.id)
|
||||
if row is None:
|
||||
row = AvatarLoadout(user_id=current_user.id, data=payload.data)
|
||||
db.add(row)
|
||||
else:
|
||||
row.data = payload.data
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return LoadoutResponse(data=row.data, is_default=False)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Internal, dedicated-server-only content API.
|
||||
|
||||
Implements `docs/architecture/world_persistence_impl.md` §7 + §8.3. This is
|
||||
how a spawned Godot dedicated server (`scripts/server.gd`, spawned by
|
||||
`app.services.game_servers`) pulls its world's content read-only over HTTP
|
||||
before binding its ENet port. Mounted at ``/api/internal`` -- a prefix that
|
||||
can also be blocked at the reverse proxy, per the doc's rejected-alternative
|
||||
note in §7.3.
|
||||
|
||||
Gated by ``require_game_server`` (§7.3): a bearer token minted per spawn by
|
||||
``GameServerSupervisor._spawn`` and checked against
|
||||
``supervisor.validate_token(world_id, token)``. There is no user identity
|
||||
here at all -- these routes skip the per-user visibility check public
|
||||
routes apply, and apply no mature-content gating (gating is a client/user
|
||||
concern; the dedicated server still needs the geometry for collision).
|
||||
|
||||
Read-only by construction: there is no write route here, and adding one
|
||||
would violate world_persistence_impl.md D2 (the FastAPI backend is the
|
||||
only writer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.world import World
|
||||
from app.models.world_content import MAIN_ROOM_SLUG
|
||||
from app.services import chunk_codec
|
||||
from app.services.game_servers import supervisor
|
||||
from app.services.world_store import Bounds, WorldStore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
async def require_game_server(
|
||||
world_id: int,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
"""world_persistence_impl.md §7.3. A path whose ``world_id`` differs
|
||||
from the token's world is 403 -- ``validate_token`` enforces that by
|
||||
construction (it looks the token up *by* ``world_id``)."""
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing bearer token")
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
if not token or not supervisor.validate_token(world_id, token):
|
||||
raise HTTPException(status_code=403, detail="invalid or expired server token")
|
||||
|
||||
|
||||
async def _get_world_or_404(world_id: int, db: AsyncSession) -> World:
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if world is None:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
return world
|
||||
|
||||
|
||||
async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str):
|
||||
room = await store.get_room(world_id, slug)
|
||||
if room is None and slug == MAIN_ROOM_SLUG:
|
||||
room = await store.get_or_create_main_room(world_id)
|
||||
await db.commit()
|
||||
return room
|
||||
|
||||
|
||||
@router.get("/worlds/{world_id}/manifest", dependencies=[Depends(require_game_server)])
|
||||
async def manifest(world_id: int, db: DbDep):
|
||||
await _get_world_or_404(world_id, db)
|
||||
store = WorldStore(db)
|
||||
rooms = await store.list_rooms(world_id)
|
||||
if not rooms:
|
||||
rooms = [await store.get_or_create_main_room(world_id)]
|
||||
await db.commit()
|
||||
|
||||
room_payloads = []
|
||||
for r in rooms:
|
||||
room_payloads.append({
|
||||
"slug": r.slug, "name": r.name, "grid": r.grid, "bounds": r.bounds,
|
||||
"spawn": r.spawn, "chunk_count": await store.count_chunks(r.id),
|
||||
})
|
||||
|
||||
return {
|
||||
"world_id": world_id,
|
||||
"content_revision": await store.content_revision(world_id),
|
||||
"rooms": room_payloads,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/worlds/{world_id}/rooms/{slug}/chunks", dependencies=[Depends(require_game_server)])
|
||||
async def internal_list_chunks(
|
||||
world_id: int, slug: str, db: DbDep,
|
||||
cx_min: int | None = None, cx_max: int | None = None,
|
||||
cy_min: int | None = None, cy_max: int | None = None,
|
||||
cz: int | None = None,
|
||||
):
|
||||
await _get_world_or_404(world_id, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz)
|
||||
chunks = await store.list_chunks(world_id, room.id, bounds)
|
||||
return {
|
||||
"room": room.slug,
|
||||
"content_revision": await store.content_revision(world_id),
|
||||
"chunks": [
|
||||
{
|
||||
"key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz,
|
||||
"revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len,
|
||||
"is_mature": c.is_mature,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
for c in chunks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}",
|
||||
dependencies=[Depends(require_game_server)],
|
||||
)
|
||||
async def internal_get_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, db: DbDep):
|
||||
await _get_world_or_404(world_id, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
|
||||
chunk = await store.get_chunk(key)
|
||||
if chunk is None:
|
||||
raise HTTPException(status_code=404, detail="chunk not found")
|
||||
|
||||
return Response(
|
||||
content=chunk.payload, media_type="application/octet-stream",
|
||||
headers={
|
||||
"ETag": f'"{chunk.checksum}"', "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,451 @@
|
||||
"""World content router -- rooms + chunks, for the editor and player client.
|
||||
|
||||
Implements `docs/architecture/world_persistence_impl.md` §8.1. JWT-authenticated
|
||||
(``CurrentUser``, same as ``app/routers/worlds.py``). All chunk content
|
||||
reads/writes go through ``WorldStore``; this module is HTTP plumbing +
|
||||
authorization only.
|
||||
|
||||
Authorization (doc §5.3):
|
||||
- **Read** (world visibility, room listing, chunk fetch/index/revisions):
|
||||
same rule as ``GET /api/worlds/{id}`` -- 404 (not 403) if the world is
|
||||
neither PUBLIC nor owned by the caller. See ``_get_world_readable``.
|
||||
- **Write** (room create, chunk PUT/DELETE/rollback): caller must be
|
||||
``world.owner_id``, matching ``update_world`` -- 403 otherwise, with
|
||||
*no* visibility gate (this deliberately mirrors ``update_world``, which
|
||||
also has no visibility check; see ``_get_world_writable``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.user import User
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_content import MAIN_ROOM_SLUG, WorldRoom
|
||||
from app.services import chunk_codec
|
||||
from app.services.game_servers import supervisor
|
||||
from app.services.world_store import Bounds, RevisionConflict, RevisionNotAvailable, WorldStore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
# world_persistence_impl.md §8.1: "Bounds are capped (default 32x32 chunks
|
||||
# per query) and return 422 beyond that."
|
||||
MAX_BOUNDS_SPAN = 32
|
||||
# §8.1 batch write cap, beyond WORLD_CHUNK_MAX_BATCH chunks: "Capped at ...
|
||||
# 8 MB."
|
||||
MAX_BATCH_TOTAL_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
# ─────────────────────────── auth helpers ───────────────────────────
|
||||
|
||||
async def _get_world_readable(world_id: int, current_user: User, db: AsyncSession) -> World:
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if world is None:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
return world
|
||||
|
||||
|
||||
async def _get_world_writable(world_id: int, current_user: User, db: AsyncSession) -> World:
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if world is None:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
if world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
||||
return world
|
||||
|
||||
|
||||
async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str) -> WorldRoom | None:
|
||||
"""world_persistence_impl.md §4.1: lazily create the main room for
|
||||
worlds that predate this feature, on first read/write."""
|
||||
room = await store.get_room(world_id, slug)
|
||||
if room is None and slug == MAIN_ROOM_SLUG:
|
||||
room = await store.get_or_create_main_room(world_id)
|
||||
await db.commit()
|
||||
return room
|
||||
|
||||
|
||||
def _is_live(world_id: int) -> bool:
|
||||
return any(s["world_id"] == str(world_id) for s in supervisor.debug_state())
|
||||
|
||||
|
||||
def _validation_error_body(exc: chunk_codec.ChunkValidationError) -> dict:
|
||||
return {"detail": "chunk validation failed", "reason": exc.reason}
|
||||
|
||||
|
||||
def _conflict_body(exc: RevisionConflict) -> dict:
|
||||
body: dict = {
|
||||
"detail": "revision conflict",
|
||||
"current_revision": exc.current_revision,
|
||||
"current_checksum": exc.current_checksum,
|
||||
}
|
||||
if exc.coord is not None:
|
||||
body["coord"] = list(exc.coord)
|
||||
return body
|
||||
|
||||
|
||||
# ─────────────────────────── schemas ───────────────────────────
|
||||
|
||||
class RoomResponse(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
grid: dict
|
||||
bounds: dict | None
|
||||
spawn: dict | None
|
||||
chunk_count: int
|
||||
|
||||
|
||||
class RoomCreate(BaseModel):
|
||||
slug: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-z0-9_-]+$")
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
grid: dict | None = None
|
||||
bounds: dict | None = None
|
||||
spawn: dict | None = None
|
||||
|
||||
|
||||
# ─────────────────────────── rooms ───────────────────────────
|
||||
|
||||
@router.get("/{world_id}/rooms", response_model=list[RoomResponse])
|
||||
async def list_rooms(world_id: int, current_user: CurrentUser, db: DbDep):
|
||||
await _get_world_readable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
rooms = await store.list_rooms(world_id)
|
||||
if not rooms:
|
||||
rooms = [await store.get_or_create_main_room(world_id)]
|
||||
await db.commit()
|
||||
return [
|
||||
RoomResponse(
|
||||
id=r.id, slug=r.slug, name=r.name, grid=r.grid, bounds=r.bounds, spawn=r.spawn,
|
||||
chunk_count=await store.count_chunks(r.id),
|
||||
)
|
||||
for r in rooms
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{world_id}/rooms", response_model=RoomResponse, status_code=201)
|
||||
async def create_room(world_id: int, body: RoomCreate, current_user: CurrentUser, db: DbDep):
|
||||
await _get_world_writable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
if await store.get_room(world_id, body.slug) is not None:
|
||||
raise HTTPException(status_code=409, detail="a room with this slug already exists")
|
||||
room = await store.create_room(
|
||||
world_id, body.slug, body.name, grid=body.grid, bounds=body.bounds, spawn=body.spawn
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return RoomResponse(
|
||||
id=room.id, slug=room.slug, name=room.name, grid=room.grid,
|
||||
bounds=room.bounds, spawn=room.spawn, chunk_count=0,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────── chunk index ───────────────────────────
|
||||
|
||||
@router.get("/{world_id}/rooms/{slug}/chunks")
|
||||
async def list_chunks(
|
||||
world_id: int, slug: str, current_user: CurrentUser, db: DbDep,
|
||||
cx_min: int | None = None, cx_max: int | None = None,
|
||||
cy_min: int | None = None, cy_max: int | None = None,
|
||||
cz: int | None = None,
|
||||
):
|
||||
await _get_world_readable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
if cx_min is not None and cx_max is not None and (cx_max - cx_min + 1) > MAX_BOUNDS_SPAN:
|
||||
raise HTTPException(status_code=422, detail=f"cx bounds span exceeds {MAX_BOUNDS_SPAN}")
|
||||
if cy_min is not None and cy_max is not None and (cy_max - cy_min + 1) > MAX_BOUNDS_SPAN:
|
||||
raise HTTPException(status_code=422, detail=f"cy bounds span exceeds {MAX_BOUNDS_SPAN}")
|
||||
|
||||
bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz)
|
||||
chunks = await store.list_chunks(world_id, room.id, bounds)
|
||||
content_revision = await store.content_revision(world_id)
|
||||
return {
|
||||
"room": room.slug,
|
||||
"content_revision": content_revision,
|
||||
"chunks": [
|
||||
{
|
||||
"key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz,
|
||||
"revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len,
|
||||
"is_mature": c.is_mature,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
for c in chunks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────── chunk fetch ───────────────────────────
|
||||
|
||||
@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}")
|
||||
async def get_chunk(
|
||||
world_id: int, slug: str, cx: int, cy: int, cz: int,
|
||||
current_user: CurrentUser, db: DbDep,
|
||||
accept: Annotated[str | None, Header()] = None,
|
||||
if_none_match: Annotated[str | None, Header(alias="If-None-Match")] = None,
|
||||
):
|
||||
await _get_world_readable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
|
||||
chunk = await store.get_chunk(key)
|
||||
if chunk is None:
|
||||
raise HTTPException(status_code=404, detail="chunk not found")
|
||||
|
||||
etag = f'"{chunk.checksum}"'
|
||||
headers = {"ETag": etag, "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key}
|
||||
|
||||
if if_none_match is not None and if_none_match.strip('"') == chunk.checksum:
|
||||
return Response(status_code=304, headers=headers)
|
||||
|
||||
wants_json = (
|
||||
accept is not None
|
||||
and "application/json" in accept
|
||||
and "application/octet-stream" not in accept
|
||||
)
|
||||
if wants_json:
|
||||
decoded = chunk_codec.decode_binary(chunk.payload)
|
||||
body = chunk_codec.to_json_dict(decoded, key=key, chunk_checksum=chunk.checksum)
|
||||
return JSONResponse(content=body, headers=headers)
|
||||
|
||||
return Response(content=chunk.payload, media_type="application/octet-stream", headers=headers)
|
||||
|
||||
|
||||
# ─────────────────────────── chunk write ───────────────────────────
|
||||
|
||||
async def _read_write_payload(request: Request, x_base_revision: int | None) -> tuple[bytes, int]:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
body = await request.json()
|
||||
try:
|
||||
payload_bytes = base64.b64decode(body["payload_b64"], validate=True)
|
||||
except (KeyError, binascii.Error, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=f"invalid payload_b64: {exc}")
|
||||
base_revision = int(body.get("base_revision", 0))
|
||||
return payload_bytes, base_revision
|
||||
payload_bytes = await request.body()
|
||||
return payload_bytes, (x_base_revision if x_base_revision is not None else 0)
|
||||
|
||||
|
||||
@router.put("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}")
|
||||
async def put_chunk(
|
||||
world_id: int, slug: str, cx: int, cy: int, cz: int,
|
||||
request: Request, current_user: CurrentUser, db: DbDep,
|
||||
x_base_revision: Annotated[int | None, Header(alias="X-Base-Revision")] = None,
|
||||
):
|
||||
await _get_world_writable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
payload_bytes, base_revision = await _read_write_payload(request, x_base_revision)
|
||||
if len(payload_bytes) > settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"payload exceeds WORLD_CHUNK_MAX_PAYLOAD_BYTES ({settings.WORLD_CHUNK_MAX_PAYLOAD_BYTES})",
|
||||
)
|
||||
|
||||
try:
|
||||
chunk = await store.put_chunk(
|
||||
room=room, cx=cx, cy=cy, cz=cz, payload_bytes=payload_bytes,
|
||||
base_revision=base_revision, actor_id=current_user.id,
|
||||
)
|
||||
except chunk_codec.ChunkCodecError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)})
|
||||
except chunk_codec.ChunkValidationError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=422, content=_validation_error_body(exc))
|
||||
except RevisionConflict as exc:
|
||||
# Nothing must stick from a failed write. Returning a JSONResponse
|
||||
# here (rather than raising) means no exception propagates through
|
||||
# `get_db`, so this rollback has to be explicit -- see the note on
|
||||
# the batch endpoint below, where this is load-bearing for
|
||||
# all-or-nothing semantics.
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=409, content=_conflict_body(exc))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(chunk)
|
||||
|
||||
return {
|
||||
"key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum,
|
||||
"payload_len": chunk.payload_len,
|
||||
"updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None,
|
||||
"is_mature": chunk.is_mature, "live_session": _is_live(world_id),
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────── batch write ───────────────────────────
|
||||
|
||||
class BatchChunkItem(BaseModel):
|
||||
cx: int
|
||||
cy: int
|
||||
cz: int
|
||||
base_revision: int
|
||||
payload_b64: str
|
||||
|
||||
|
||||
class BatchPutRequest(BaseModel):
|
||||
chunks: list[BatchChunkItem]
|
||||
|
||||
|
||||
@router.put("/{world_id}/rooms/{slug}/chunks")
|
||||
async def put_chunks_batch(
|
||||
world_id: int, slug: str, body: BatchPutRequest, current_user: CurrentUser, db: DbDep,
|
||||
):
|
||||
await _get_world_writable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
if len(body.chunks) > settings.WORLD_CHUNK_MAX_BATCH:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"batch of {len(body.chunks)} exceeds WORLD_CHUNK_MAX_BATCH ({settings.WORLD_CHUNK_MAX_BATCH})",
|
||||
)
|
||||
|
||||
items = []
|
||||
total_bytes = 0
|
||||
for item in body.chunks:
|
||||
try:
|
||||
payload_bytes = base64.b64decode(item.payload_b64, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=f"invalid payload_b64 for ({item.cx},{item.cy},{item.cz}): {exc}")
|
||||
total_bytes += len(payload_bytes)
|
||||
items.append({
|
||||
"cx": item.cx, "cy": item.cy, "cz": item.cz,
|
||||
"base_revision": item.base_revision, "payload_bytes": payload_bytes,
|
||||
})
|
||||
|
||||
if total_bytes > MAX_BATCH_TOTAL_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"batch exceeds {MAX_BATCH_TOTAL_BYTES} bytes")
|
||||
|
||||
try:
|
||||
rows = await store.put_chunks_batch(room=room, items=items, actor_id=current_user.id)
|
||||
except chunk_codec.ChunkCodecError as exc:
|
||||
# All-or-nothing (world_persistence_impl.md §8.1): earlier items in
|
||||
# this batch may already be flushed (but never committed) to this
|
||||
# transaction. Rolling back here is what actually makes the batch
|
||||
# atomic -- without it, returning a JSONResponse instead of raising
|
||||
# means no exception reaches `get_db`, and its own transaction
|
||||
# handling would otherwise commit those earlier writes anyway.
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=422, content={"detail": "chunk decode failed", "reason": "malformed", "message": str(exc)})
|
||||
except chunk_codec.ChunkValidationError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=422, content=_validation_error_body(exc))
|
||||
except RevisionConflict as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=409, content=_conflict_body(exc))
|
||||
|
||||
await db.commit()
|
||||
for row in rows:
|
||||
await db.refresh(row)
|
||||
|
||||
return {
|
||||
"live_session": _is_live(world_id),
|
||||
"chunks": [
|
||||
{
|
||||
"key": r.chunk_key, "revision": r.revision, "checksum": r.checksum,
|
||||
"payload_len": r.payload_len,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
"is_mature": r.is_mature,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────── delete ───────────────────────────
|
||||
|
||||
@router.delete("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}", status_code=204)
|
||||
async def delete_chunk(
|
||||
world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep,
|
||||
):
|
||||
await _get_world_writable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
room = await _resolve_room(store, db, world_id, slug)
|
||||
if room is None:
|
||||
raise HTTPException(status_code=404, detail="room not found")
|
||||
|
||||
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
|
||||
await store.delete_chunk(key, current_user.id)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# ─────────────────────────── revisions + rollback ───────────────────────────
|
||||
|
||||
@router.get("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions")
|
||||
async def chunk_revisions(
|
||||
world_id: int, slug: str, cx: int, cy: int, cz: int, current_user: CurrentUser, db: DbDep,
|
||||
):
|
||||
# doc: "(owner)" -- same gate as writes.
|
||||
await _get_world_writable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
|
||||
revisions = await store.list_revisions(key)
|
||||
return [
|
||||
{
|
||||
"revision": r.revision, "action": r.action, "actor_id": r.actor_id,
|
||||
"checksum": r.checksum, "payload_available": r.payload is not None,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in revisions
|
||||
]
|
||||
|
||||
|
||||
class RollbackRequest(BaseModel):
|
||||
revision: int
|
||||
|
||||
|
||||
@router.post("/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback")
|
||||
async def rollback_chunk(
|
||||
world_id: int, slug: str, cx: int, cy: int, cz: int,
|
||||
body: RollbackRequest, current_user: CurrentUser, db: DbDep,
|
||||
):
|
||||
await _get_world_writable(world_id, current_user, db)
|
||||
store = WorldStore(db)
|
||||
key = chunk_codec.build_key(world_id, slug, cx, cy, cz)
|
||||
try:
|
||||
chunk = await store.rollback_chunk(key, body.revision, current_user.id)
|
||||
except RevisionNotAvailable as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
||||
except chunk_codec.ChunkValidationError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=422, content=_validation_error_body(exc))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(chunk)
|
||||
return {
|
||||
"key": chunk.chunk_key, "revision": chunk.revision, "checksum": chunk.checksum,
|
||||
"payload_len": chunk.payload_len,
|
||||
"updated_at": chunk.updated_at.isoformat() if chunk.updated_at else None,
|
||||
"is_mature": chunk.is_mature, "live_session": _is_live(world_id),
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Worlds router - CRUD for game worlds."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -11,6 +12,8 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.services.game_servers import supervisor
|
||||
from app.services.world_store import WorldStore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -40,7 +43,7 @@ class WorldResponse(BaseModel):
|
||||
description: str | None
|
||||
visibility: WorldVisibility
|
||||
owner_id: int
|
||||
created_at: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -105,13 +108,52 @@ async def get_world(
|
||||
return world
|
||||
|
||||
|
||||
class JoinResponse(BaseModel):
|
||||
"""Address of the dedicated server hosting this world."""
|
||||
world_id: int
|
||||
host: str
|
||||
port: int
|
||||
|
||||
|
||||
@router.post("/{world_id}/join", response_model=JoinResponse)
|
||||
async def join_world(
|
||||
world_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Ensure a dedicated game server is running for this world and return its
|
||||
ENet address for the client to connect to.
|
||||
|
||||
503 if game-server orchestration isn't enabled on this backend (e.g. a
|
||||
plain dev/test instance with no Godot binary).
|
||||
"""
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if not world:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="World not found")
|
||||
|
||||
try:
|
||||
info = await supervisor.ensure_server(str(world_id))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc))
|
||||
|
||||
return JoinResponse(world_id=world_id, host=info["host"], port=info["port"])
|
||||
|
||||
|
||||
@router.post("/", response_model=WorldResponse, status_code=201)
|
||||
async def create_world(
|
||||
world_data: WorldCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Create a new world."""
|
||||
"""Create a new world.
|
||||
|
||||
Also creates the world's "main" room (world_persistence_impl.md §4.1) --
|
||||
every world gets one so chunk content has somewhere to live from the
|
||||
start.
|
||||
"""
|
||||
world = World(
|
||||
name=world_data.name,
|
||||
description=world_data.description,
|
||||
@@ -119,6 +161,8 @@ async def create_world(
|
||||
visibility=world_data.visibility,
|
||||
)
|
||||
db.add(world)
|
||||
await db.flush() # assigns world.id, needed for the room's FK
|
||||
await WorldStore(db).get_or_create_main_room(world.id)
|
||||
await db.commit()
|
||||
await db.refresh(world)
|
||||
return world
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
"""Python port of the chunk binary/JSON codec, `docs/architecture/world.md` §3.
|
||||
|
||||
This is the server-side half of the "single shared format, two
|
||||
implementations" contract world.md §5.2 and
|
||||
`world_persistence_impl.md` §5.5/§9 call for. The Godot-side implementation
|
||||
is `scripts/chunk_data_decoder.gd`; this module must decode anything it
|
||||
produces and produce bytes it can decode.
|
||||
|
||||
IMPORTANT DEVIATION (see the "Known cross-codec divergence" note at the
|
||||
bottom of this file): the current `scripts/chunk_data_decoder.gd` does not
|
||||
actually implement IEEE-754 float32 for the `pos`/`rot`/`scale`/`bounds`
|
||||
fields -- it performs a lossy numeric `int`/`float` cast instead of a bit
|
||||
reinterpretation, which both truncates fractional values and does not
|
||||
write IEEE-754 bits at all. This module implements the format as
|
||||
*specified* (real `<f` IEEE-754 float32, matching every other field in the
|
||||
spec and matching what any conformant decoder would assume). See the note
|
||||
for what this means for cross-codec byte parity today.
|
||||
|
||||
Nothing here talks to a database or the FastAPI app; it is a pure,
|
||||
side-effect-free codec so it can be golden-file tested in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
MAGIC = b"AGPC"
|
||||
HEADER_SIZE = 48
|
||||
CURRENT_VERSION = 1
|
||||
|
||||
SECTION_META = 0x01
|
||||
SECTION_TILE = 0x02
|
||||
SECTION_OBJECT = 0x03
|
||||
SECTION_FLAGS = 0x04
|
||||
SECTION_REVISION_META = 0x05
|
||||
|
||||
CATEGORY_FIELDS = (
|
||||
"violence", "sexual", "language", "substance",
|
||||
"gambling", "horror", "hate", "custom",
|
||||
)
|
||||
|
||||
|
||||
class ChunkCodecError(Exception):
|
||||
"""Malformed bytes -- the payload isn't shaped like a chunk at all."""
|
||||
|
||||
|
||||
class ChunkValidationError(Exception):
|
||||
"""A structurally-valid chunk failed a content/business-rule check.
|
||||
|
||||
``reason`` is the machine-readable code the API surface (world_persistence_impl.md
|
||||
§8.1) puts in the 422 response body's ``reason`` field, e.g.
|
||||
``"checksum_mismatch"``, ``"layer_dims"``, ``"object_out_of_bounds"``.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, message: str | None = None) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(message or reason)
|
||||
|
||||
|
||||
# ───────────────────────── Logical model ─────────────────────────
|
||||
|
||||
@dataclass
|
||||
class TileDef:
|
||||
id: int
|
||||
asset_id: str = ""
|
||||
solid: bool = False
|
||||
flags: int = 0
|
||||
variants: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class TileLayer:
|
||||
name: str
|
||||
layer_id: int = 0
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
depth: int = 1
|
||||
palette: list[TileDef] = field(default_factory=list)
|
||||
tiles: list[int] = field(default_factory=list) # flattened, row-major
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentFlags:
|
||||
violence: int = 0
|
||||
sexual: int = 0
|
||||
language: int = 0
|
||||
substance: int = 0
|
||||
gambling: int = 0
|
||||
horror: int = 0
|
||||
hate: int = 0
|
||||
custom: int = 0
|
||||
mature: bool = False
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"categories": {name: getattr(self, name) for name in CATEGORY_FIELDS},
|
||||
"mature": self.mature,
|
||||
"sources": list(self.sources),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ContentFlags":
|
||||
cats = d.get("categories", {})
|
||||
return cls(
|
||||
**{name: int(cats.get(name, 0)) for name in CATEGORY_FIELDS},
|
||||
mature=bool(d.get("mature", False)),
|
||||
sources=list(d.get("sources", [])),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorldObject:
|
||||
id: str
|
||||
type: str
|
||||
asset_id: str
|
||||
pos: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)
|
||||
scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
|
||||
dynamic: bool = False
|
||||
properties: dict = field(default_factory=dict)
|
||||
flags: ContentFlags = field(default_factory=ContentFlags)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkBounds:
|
||||
"""Stored here as min-corner + size (matching the Godot client's AABB
|
||||
type) for convenience, but the §3.6 BINARY wire format is min + max
|
||||
(world.md §3.1, and the client's actual ChunkDataDecoder) -- fixed
|
||||
2026-09-14 after a real client/server interop test caught this class's
|
||||
original binary codec writing min+size on the wire, which the client
|
||||
decoder (unaware of the "size" convention) read as a second min/max
|
||||
pair, corrupting every chunk's bounds. The two representations are
|
||||
fully recoverable from each other; `max` below is computed for exactly
|
||||
this reason."""
|
||||
|
||||
min: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
size: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
|
||||
@property
|
||||
def max(self) -> tuple[float, float, float]:
|
||||
return tuple(a + b for a, b in zip(self.min, self.size))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkPayload:
|
||||
version: int = CURRENT_VERSION
|
||||
world_id: str = ""
|
||||
room_id: str = ""
|
||||
coord: tuple[int, int, int] = (0, 0, 0)
|
||||
bounds: ChunkBounds = field(default_factory=ChunkBounds)
|
||||
tile_layers: list[TileLayer] = field(default_factory=list)
|
||||
objects: list[WorldObject] = field(default_factory=list)
|
||||
flags: ContentFlags = field(default_factory=ContentFlags)
|
||||
created_at: int = 0
|
||||
updated_at: int = 0
|
||||
revision: int = 0
|
||||
author: str = ""
|
||||
|
||||
|
||||
def build_key(world_id: str | int, room_slug: str, cx: int, cy: int, cz: int) -> str:
|
||||
"""world_persistence_impl.md §4.5: ``"{world.id}:{room.slug}:{cx}:{cy}:{cz}"``."""
|
||||
return f"{world_id}:{room_slug}:{cx}:{cy}:{cz}"
|
||||
|
||||
|
||||
# ───────────────────────── Binary primitives ─────────────────────────
|
||||
# All integers little-endian, matching world.md §3.6.3. Floats are packed
|
||||
# as real IEEE-754 float32 -- see the module docstring's deviation note.
|
||||
|
||||
def _read_u16(data: bytes, off: int) -> int:
|
||||
return struct.unpack_from("<H", data, off)[0]
|
||||
|
||||
|
||||
def _read_u32(data: bytes, off: int) -> int:
|
||||
return struct.unpack_from("<I", data, off)[0]
|
||||
|
||||
|
||||
def _read_i32(data: bytes, off: int) -> int:
|
||||
return struct.unpack_from("<i", data, off)[0]
|
||||
|
||||
|
||||
def _read_i64(data: bytes, off: int) -> int:
|
||||
return struct.unpack_from("<q", data, off)[0]
|
||||
|
||||
|
||||
def _read_f32(data: bytes, off: int) -> float:
|
||||
return struct.unpack_from("<f", data, off)[0]
|
||||
|
||||
|
||||
def _w_u16(v: int) -> bytes:
|
||||
return struct.pack("<H", v & 0xFFFF)
|
||||
|
||||
|
||||
def _w_u32(v: int) -> bytes:
|
||||
return struct.pack("<I", v & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def _w_i32(v: int) -> bytes:
|
||||
return struct.pack("<i", v)
|
||||
|
||||
|
||||
def _w_i64(v: int) -> bytes:
|
||||
return struct.pack("<q", v)
|
||||
|
||||
|
||||
def _w_f32(v: float) -> bytes:
|
||||
return struct.pack("<f", v)
|
||||
|
||||
|
||||
def _read_str(data: bytes, off: int) -> tuple[str, int]:
|
||||
"""Length-prefixed UTF-8 string. Returns (value, next_offset)."""
|
||||
n = _read_u16(data, off)
|
||||
off += 2
|
||||
s = data[off:off + n].decode("utf-8")
|
||||
return s, off + n
|
||||
|
||||
|
||||
def _w_str(s: str) -> bytes:
|
||||
b = s.encode("utf-8")
|
||||
if len(b) > 0xFFFF:
|
||||
raise ChunkCodecError(f"string field too long to encode ({len(b)} bytes)")
|
||||
return _w_u16(len(b)) + b
|
||||
|
||||
|
||||
def checksum(payload_body: bytes) -> str:
|
||||
"""SHA-256 hex digest of the payload body (world.md §3.6.3)."""
|
||||
return hashlib.sha256(payload_body).hexdigest()
|
||||
|
||||
|
||||
# ───────────────────────── Section codecs ─────────────────────────
|
||||
|
||||
def _encode_metadata(payload: ChunkPayload) -> bytes:
|
||||
out = bytearray()
|
||||
out += _w_str(payload.world_id)
|
||||
out += _w_str(payload.room_id)
|
||||
out += _w_i32(payload.coord[0])
|
||||
out += _w_i32(payload.coord[1])
|
||||
out += _w_i32(payload.coord[2])
|
||||
out += _w_f32(payload.bounds.min[0])
|
||||
out += _w_f32(payload.bounds.min[1])
|
||||
out += _w_f32(payload.bounds.min[2])
|
||||
out += _w_f32(payload.bounds.max[0])
|
||||
out += _w_f32(payload.bounds.max[1])
|
||||
out += _w_f32(payload.bounds.max[2])
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decode_metadata(data: bytes, result: ChunkPayload) -> None:
|
||||
off = 0
|
||||
result.world_id, off = _read_str(data, off)
|
||||
result.room_id, off = _read_str(data, off)
|
||||
coord = (_read_i32(data, off), _read_i32(data, off + 4), _read_i32(data, off + 8))
|
||||
off += 12
|
||||
min_pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
|
||||
max_pos = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20))
|
||||
size = tuple(a - b for a, b in zip(max_pos, min_pos))
|
||||
result.coord = coord
|
||||
result.bounds = ChunkBounds(min=min_pos, size=size)
|
||||
|
||||
|
||||
def _encode_tile_layer(layer: TileLayer) -> bytes:
|
||||
out = bytearray()
|
||||
out += _w_str(layer.name)
|
||||
out += _w_u16(layer.layer_id)
|
||||
out += _w_u16(layer.width)
|
||||
out += _w_u16(layer.height)
|
||||
out += _w_u16(layer.depth)
|
||||
out += _w_u16(len(layer.palette))
|
||||
for td in layer.palette:
|
||||
out += _w_u16(td.id)
|
||||
out += _w_str(td.asset_id)
|
||||
out += bytes([1 if td.solid else 0])
|
||||
out += _w_u32(td.flags)
|
||||
out += bytes([td.variants & 0xFF])
|
||||
out += _w_u32(len(layer.tiles))
|
||||
for tile_id in layer.tiles:
|
||||
out += _w_u16(tile_id)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decode_tile_layer(data: bytes) -> TileLayer:
|
||||
off = 0
|
||||
name, off = _read_str(data, off)
|
||||
layer_id = _read_u16(data, off); off += 2
|
||||
width = _read_u16(data, off); off += 2
|
||||
height = _read_u16(data, off); off += 2
|
||||
depth = _read_u16(data, off); off += 2
|
||||
palette_count = _read_u16(data, off); off += 2
|
||||
palette: list[TileDef] = []
|
||||
for _ in range(palette_count):
|
||||
tid = _read_u16(data, off); off += 2
|
||||
asset_id, off = _read_str(data, off)
|
||||
solid = data[off] != 0; off += 1
|
||||
flags = _read_u32(data, off); off += 4
|
||||
variants = data[off]; off += 1
|
||||
palette.append(TileDef(id=tid, asset_id=asset_id, solid=solid, flags=flags, variants=variants))
|
||||
tile_count = _read_u32(data, off); off += 4
|
||||
tiles: list[int] = []
|
||||
for _ in range(tile_count):
|
||||
tiles.append(_read_u16(data, off))
|
||||
off += 2
|
||||
return TileLayer(name=name, layer_id=layer_id, width=width, height=height,
|
||||
depth=depth, palette=palette, tiles=tiles)
|
||||
|
||||
|
||||
def _encode_flags(flags: ContentFlags) -> bytes:
|
||||
out = bytearray()
|
||||
for name in CATEGORY_FIELDS:
|
||||
out.append(getattr(flags, name) & 0xFF)
|
||||
out.append(1 if flags.mature else 0)
|
||||
out += _w_u16(len(flags.sources))
|
||||
for src in flags.sources:
|
||||
out += _w_str(src)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decode_flags(data: bytes) -> ContentFlags:
|
||||
off = 0
|
||||
values = {}
|
||||
for name in CATEGORY_FIELDS:
|
||||
values[name] = data[off]
|
||||
off += 1
|
||||
mature = data[off] != 0
|
||||
off += 1
|
||||
source_count = _read_u16(data, off)
|
||||
off += 2
|
||||
sources = []
|
||||
for _ in range(source_count):
|
||||
s, off = _read_str(data, off)
|
||||
sources.append(s)
|
||||
return ContentFlags(**values, mature=mature, sources=sources)
|
||||
|
||||
|
||||
def _encode_object(obj: WorldObject) -> bytes:
|
||||
out = bytearray()
|
||||
out += _w_str(obj.id)
|
||||
out += _w_str(obj.type)
|
||||
out += _w_str(obj.asset_id)
|
||||
for v in obj.pos:
|
||||
out += _w_f32(v)
|
||||
for v in obj.rot:
|
||||
out += _w_f32(v)
|
||||
for v in obj.scale:
|
||||
out += _w_f32(v)
|
||||
out.append(1 if obj.dynamic else 0)
|
||||
# Canonical JSON: keys sorted lexicographically (world.md §3.6.3).
|
||||
props_json = json.dumps(obj.properties, sort_keys=True, separators=(",", ":"))
|
||||
out += _w_str(props_json)
|
||||
out += _encode_flags(obj.flags)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decode_object(data: bytes) -> WorldObject:
|
||||
off = 0
|
||||
oid, off = _read_str(data, off)
|
||||
otype, off = _read_str(data, off)
|
||||
asset_id, off = _read_str(data, off)
|
||||
pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
|
||||
off += 12
|
||||
rot = (
|
||||
_read_f32(data, off), _read_f32(data, off + 4),
|
||||
_read_f32(data, off + 8), _read_f32(data, off + 12),
|
||||
)
|
||||
off += 16
|
||||
scale = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
|
||||
off += 12
|
||||
dynamic = data[off] != 0
|
||||
off += 1
|
||||
props_str, off = _read_str(data, off)
|
||||
try:
|
||||
properties = json.loads(props_str) if props_str else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ChunkCodecError(f"object {oid!r}: invalid properties JSON") from exc
|
||||
obj_flags = _decode_flags(data[off:])
|
||||
return WorldObject(id=oid, type=otype, asset_id=asset_id, pos=pos, rot=rot,
|
||||
scale=scale, dynamic=dynamic, properties=properties, flags=obj_flags)
|
||||
|
||||
|
||||
def _encode_revision_meta(payload: ChunkPayload) -> bytes:
|
||||
out = bytearray()
|
||||
out += _w_i64(payload.created_at)
|
||||
out += _w_i64(payload.updated_at)
|
||||
out += _w_u32(payload.revision)
|
||||
out += _w_str(payload.author)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decode_revision_meta(data: bytes, result: ChunkPayload) -> None:
|
||||
off = 0
|
||||
result.created_at = _read_i64(data, off); off += 8
|
||||
result.updated_at = _read_i64(data, off); off += 8
|
||||
result.revision = _read_u32(data, off); off += 4
|
||||
result.author, off = _read_str(data, off)
|
||||
|
||||
|
||||
def _wrap_section(section_id: int, data: bytes) -> bytes:
|
||||
return bytes([section_id]) + _w_u32(len(data)) + data
|
||||
|
||||
|
||||
# ───────────────────────── Top-level encode/decode ─────────────────────────
|
||||
|
||||
def encode_binary(payload: ChunkPayload) -> bytes:
|
||||
"""Encode a ChunkPayload to the world.md §3.6 wire format."""
|
||||
sections = bytearray()
|
||||
sections += _wrap_section(SECTION_META, _encode_metadata(payload))
|
||||
for layer in payload.tile_layers:
|
||||
sections += _wrap_section(SECTION_TILE, _encode_tile_layer(layer))
|
||||
for obj in payload.objects:
|
||||
sections += _wrap_section(SECTION_OBJECT, _encode_object(obj))
|
||||
sections += _wrap_section(SECTION_FLAGS, _encode_flags(payload.flags))
|
||||
sections += _wrap_section(SECTION_REVISION_META, _encode_revision_meta(payload))
|
||||
|
||||
payload_body = bytes(sections)
|
||||
digest = hashlib.sha256(payload_body).digest() # 32 raw bytes
|
||||
|
||||
header = bytearray()
|
||||
header += MAGIC
|
||||
header += _w_u16(CURRENT_VERSION)
|
||||
header += _w_u16(HEADER_SIZE - 8) # header_len: bytes of header after this field (spec-correct)
|
||||
header += _w_u32(0) # flags (reserved)
|
||||
header += _w_u32(len(payload_body))
|
||||
header += digest
|
||||
|
||||
return bytes(header) + payload_body
|
||||
|
||||
|
||||
def decode_binary(data: bytes) -> ChunkPayload:
|
||||
"""Decode a world.md §3.6 binary chunk. Raises ChunkCodecError on
|
||||
structurally malformed input (short buffer, bad magic, unsupported
|
||||
version, truncated payload)."""
|
||||
if len(data) < HEADER_SIZE:
|
||||
raise ChunkCodecError(f"data too short for header ({len(data)} bytes)")
|
||||
|
||||
if data[0:4] != MAGIC:
|
||||
raise ChunkCodecError("bad magic bytes")
|
||||
|
||||
version = _read_u16(data, 4)
|
||||
if version < 1 or version > CURRENT_VERSION:
|
||||
raise ChunkCodecError(f"unsupported chunk format version {version}")
|
||||
|
||||
# header_len (offset 6) is intentionally not used for offset math --
|
||||
# both this decoder and the Godot one always slice at the fixed
|
||||
# HEADER_SIZE, matching the reference implementation's actual (if
|
||||
# spec-inconsistent) behaviour.
|
||||
payload_len = _read_u32(data, 12)
|
||||
# bytes 16:48 are the header's advisory checksum -- decode_and_validate
|
||||
# re-reads it directly and recomputes against the parsed payload body.
|
||||
|
||||
if len(data) - HEADER_SIZE != payload_len:
|
||||
raise ChunkCodecError(
|
||||
f"declared payload_len ({payload_len}) does not match actual "
|
||||
f"remaining bytes ({len(data) - HEADER_SIZE})"
|
||||
)
|
||||
|
||||
payload_body = data[HEADER_SIZE:HEADER_SIZE + payload_len]
|
||||
|
||||
result = ChunkPayload(version=version)
|
||||
|
||||
offset = 0
|
||||
while offset < len(payload_body):
|
||||
if offset + 5 > len(payload_body):
|
||||
raise ChunkCodecError("truncated section header")
|
||||
section_id = payload_body[offset]
|
||||
section_len = _read_u32(payload_body, offset + 1)
|
||||
data_start = offset + 5
|
||||
data_end = data_start + section_len
|
||||
if data_end > len(payload_body):
|
||||
raise ChunkCodecError("truncated section body")
|
||||
section_data = payload_body[data_start:data_end]
|
||||
|
||||
if section_id == SECTION_META:
|
||||
_decode_metadata(section_data, result)
|
||||
elif section_id == SECTION_TILE:
|
||||
result.tile_layers.append(_decode_tile_layer(section_data))
|
||||
elif section_id == SECTION_OBJECT:
|
||||
result.objects.append(_decode_object(section_data))
|
||||
elif section_id == SECTION_FLAGS:
|
||||
result.flags = _decode_flags(section_data)
|
||||
elif section_id == SECTION_REVISION_META:
|
||||
_decode_revision_meta(section_data, result)
|
||||
# Unknown section ids are skipped (forward-compat), matching the
|
||||
# Godot decoder's match-with-no-default behaviour.
|
||||
|
||||
offset = data_end
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ───────────────────────── JSON encoding (world.md §3.5) ─────────────────────────
|
||||
# Derived, never stored (world_persistence_impl.md §8.1) -- produced here
|
||||
# from a decoded ChunkPayload for the ``Accept: application/json`` path.
|
||||
|
||||
def to_json_dict(payload: ChunkPayload, *, key: str, chunk_checksum: str) -> dict:
|
||||
return {
|
||||
"version": payload.version,
|
||||
"key": key,
|
||||
"world_id": payload.world_id,
|
||||
"room_id": payload.room_id,
|
||||
"coord": {"cx": payload.coord[0], "cy": payload.coord[1], "cz": payload.coord[2]},
|
||||
"bounds": {
|
||||
"min_x": payload.bounds.min[0], "min_y": payload.bounds.min[1], "min_z": payload.bounds.min[2],
|
||||
"max_x": payload.bounds.max[0], "max_y": payload.bounds.max[1], "max_z": payload.bounds.max[2],
|
||||
},
|
||||
"tiles": [
|
||||
{
|
||||
"name": layer.name,
|
||||
"layer_id": layer.layer_id,
|
||||
"width": layer.width,
|
||||
"height": layer.height,
|
||||
"depth": layer.depth,
|
||||
"palette": [
|
||||
{"id": td.id, "asset_id": td.asset_id, "solid": td.solid,
|
||||
"flags": td.flags, "variants": td.variants}
|
||||
for td in layer.palette
|
||||
],
|
||||
"tiles": list(layer.tiles),
|
||||
}
|
||||
for layer in payload.tile_layers
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"id": obj.id, "type": obj.type, "asset_id": obj.asset_id,
|
||||
"transform": {
|
||||
"pos": list(obj.pos), "rot": list(obj.rot), "scale": list(obj.scale),
|
||||
},
|
||||
"properties": obj.properties,
|
||||
"dynamic": obj.dynamic,
|
||||
"flags": obj.flags.to_dict(),
|
||||
}
|
||||
for obj in payload.objects
|
||||
],
|
||||
"flags": payload.flags.to_dict(),
|
||||
"meta": {
|
||||
"created_at": payload.created_at, "updated_at": payload.updated_at,
|
||||
"revision": payload.revision, "author": payload.author,
|
||||
},
|
||||
"checksum": chunk_checksum,
|
||||
}
|
||||
|
||||
|
||||
# ───────────────────────── Validation (world_persistence_impl.md §5.5) ─────────────────────────
|
||||
|
||||
# Not precisely quantified by the design doc beyond "length limits" --
|
||||
# these are this implementation's conservative defaults (world.md §7).
|
||||
MAX_STRING_FIELD_LEN = 256
|
||||
MAX_PROPERTIES_JSON_LEN = 16_384
|
||||
MAX_SOURCES_PER_FLAGSET = 32
|
||||
|
||||
|
||||
def _check_string_lengths(obj: WorldObject) -> None:
|
||||
for field_name, value in (("id", obj.id), ("type", obj.type), ("asset_id", obj.asset_id)):
|
||||
if len(value) > MAX_STRING_FIELD_LEN:
|
||||
raise ChunkValidationError(
|
||||
"string_field_too_long", f"object.{field_name} exceeds {MAX_STRING_FIELD_LEN} chars"
|
||||
)
|
||||
props_json = json.dumps(obj.properties)
|
||||
if len(props_json) > MAX_PROPERTIES_JSON_LEN:
|
||||
raise ChunkValidationError(
|
||||
"string_field_too_long", f"object {obj.id!r} properties exceed {MAX_PROPERTIES_JSON_LEN} bytes"
|
||||
)
|
||||
if len(obj.flags.sources) > MAX_SOURCES_PER_FLAGSET:
|
||||
raise ChunkValidationError("string_field_too_long", f"object {obj.id!r} has too many flag sources")
|
||||
|
||||
|
||||
def decode_and_validate(
|
||||
data: bytes,
|
||||
*,
|
||||
expected_key: str,
|
||||
grid_size: tuple[int, int, int],
|
||||
max_objects: int,
|
||||
) -> ChunkPayload:
|
||||
"""Decode + fully validate a client-submitted chunk payload.
|
||||
|
||||
This is the boundary world_persistence_impl.md §5.5 requires: "the
|
||||
backend must not trust the client". Raises ``ChunkCodecError`` for
|
||||
structurally malformed bytes (mapped to a 422 by the router with a
|
||||
generic reason) or ``ChunkValidationError`` with a specific ``reason``
|
||||
code for a well-formed-but-invalid payload.
|
||||
"""
|
||||
payload = decode_binary(data) # raises ChunkCodecError for structural problems first
|
||||
|
||||
# Checksum is advisory in the header (world.md §7); the value that
|
||||
# matters is recomputed from the actual payload body we just parsed.
|
||||
payload_body = data[HEADER_SIZE:]
|
||||
recomputed = checksum(payload_body)
|
||||
header_checksum_hex = data[16:48].hex()
|
||||
if header_checksum_hex != recomputed:
|
||||
raise ChunkValidationError(
|
||||
"checksum_mismatch",
|
||||
"header checksum does not match the recomputed SHA-256 of the payload body",
|
||||
)
|
||||
|
||||
actual_key = build_key(payload.world_id, payload.room_id, *payload.coord)
|
||||
if actual_key != expected_key:
|
||||
raise ChunkValidationError(
|
||||
"chunk_key_mismatch",
|
||||
f"payload key {actual_key!r} does not match URL {expected_key!r}",
|
||||
)
|
||||
|
||||
for layer in payload.tile_layers:
|
||||
if (layer.width, layer.height, layer.depth) != tuple(grid_size):
|
||||
raise ChunkValidationError(
|
||||
"layer_dims",
|
||||
f"tile layer {layer.name!r} dims {(layer.width, layer.height, layer.depth)} "
|
||||
f"!= room grid {tuple(grid_size)}",
|
||||
)
|
||||
valid_ids = {0} | {td.id for td in layer.palette}
|
||||
for tile_id in layer.tiles:
|
||||
if tile_id not in valid_ids:
|
||||
raise ChunkValidationError(
|
||||
"tile_out_of_palette",
|
||||
f"tile layer {layer.name!r} references palette id {tile_id} not in its palette",
|
||||
)
|
||||
if len(layer.tiles) != layer.width * layer.height * layer.depth:
|
||||
raise ChunkValidationError(
|
||||
"layer_dims",
|
||||
f"tile layer {layer.name!r} has {len(layer.tiles)} cells, expected "
|
||||
f"{layer.width * layer.height * layer.depth}",
|
||||
)
|
||||
|
||||
if len(payload.objects) > max_objects:
|
||||
raise ChunkValidationError(
|
||||
"too_many_objects", f"{len(payload.objects)} objects exceeds max {max_objects}"
|
||||
)
|
||||
|
||||
bmin, bmax = payload.bounds.min, payload.bounds.max
|
||||
for obj in payload.objects:
|
||||
_check_string_lengths(obj)
|
||||
if not all(bmin[i] <= obj.pos[i] <= bmax[i] for i in range(3)):
|
||||
raise ChunkValidationError(
|
||||
"object_out_of_bounds",
|
||||
f"object {obj.id!r} position {obj.pos} outside chunk bounds "
|
||||
f"[{bmin}, {bmax}]",
|
||||
)
|
||||
|
||||
if len(payload.author) > MAX_STRING_FIELD_LEN:
|
||||
raise ChunkValidationError("string_field_too_long", "meta.author too long")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# ───────────────────────── Known cross-codec divergence ─────────────────────────
|
||||
#
|
||||
# `scripts/chunk_data_decoder.gd`'s `_read_float32`/`_write_float32` do:
|
||||
#
|
||||
# static func _write_float32(val: float) -> PackedByteArray:
|
||||
# var bits := val as int
|
||||
# return _write_uint32(bits)
|
||||
#
|
||||
# static func _read_float32(data: PackedByteArray, offset: int) -> float:
|
||||
# var bits := _read_uint32(data, offset)
|
||||
# return bits as float
|
||||
#
|
||||
# `as` between int and float in GDScript is a numeric conversion, not a bit
|
||||
# reinterpretation (verified empirically against Godot 4.6.2 headless:
|
||||
# `1.5 as int` -> `1`, `1 as float` -> `1.0`; the IEEE-754 bit pattern of
|
||||
# 1.5f is 0x3FC00000 = 1069547520, not 1). So the shipped client codec
|
||||
# truncates every float32 field (bounds, object pos/rot/scale) to its
|
||||
# integer part and stores that integer as a raw int32 -- not as float32
|
||||
# bits at all. This is a real bug in already-merged, client-side code
|
||||
# (scripts/ is out of scope for this task) that breaks the "byte-stable
|
||||
# across two implementations" guarantee world.md §3.6.3 states.
|
||||
#
|
||||
# This module implements the *specified* format (real IEEE-754 float32)
|
||||
# rather than replicating that bug, because world.md §3.6 is normative and
|
||||
# says "float32" for these fields, and mirroring the bug would mean the
|
||||
# backend also corrupts every non-integer transform/bounds value it
|
||||
# stores. Practical effect until the client-side bug is fixed: payloads
|
||||
# with integer-only floats will decode identically on both sides; any
|
||||
# payload with a fractional position/rotation/scale/bounds value will not
|
||||
# be byte-identical (or numerically identical) between this module and the
|
||||
# current `scripts/chunk_data_decoder.gd`. This is flagged for the
|
||||
# client/editor follow-on task, not fixed here.
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Game-server orchestration.
|
||||
|
||||
On ``POST /api/worlds/{id}/join`` the backend ensures a headless Godot
|
||||
dedicated-server process is running for that world and returns its address.
|
||||
One process per world, keyed by ``world_id``. A background reaper kills a
|
||||
world's server once its status file reports zero players for
|
||||
``GAME_SERVER_IDLE_TIMEOUT_SECONDS``.
|
||||
|
||||
This lives in-process (a module-level singleton) rather than as a separate
|
||||
service, so the backend MUST run single-worker -- which it does
|
||||
(``uvicorn app.main:app`` with no ``--workers``). The Godot binary and
|
||||
project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``);
|
||||
the backend spawns them as subprocesses.
|
||||
|
||||
Design notes / limits (PC3):
|
||||
- Port allocation is a linear scan of the configured range.
|
||||
- No crash-restart: if a server process dies, the next join respawns it.
|
||||
With world content now durable in Postgres (world_persistence_impl.md
|
||||
D2), this is genuinely safe rather than merely tolerated: the sim
|
||||
process holds no unsaved world content, so a reap/crash/kill can never
|
||||
lose world data.
|
||||
|
||||
Per-spawn auth token (world_persistence_impl.md §7.3): each spawned server
|
||||
gets a random ``secrets.token_urlsafe(32)`` on its command line, which it
|
||||
uses as a Bearer token against ``/api/internal`` to pull its world's
|
||||
content read-only before binding its ENet port. The token grants access to
|
||||
exactly one world and dies with its process (the reaper's
|
||||
``del self._servers[wid]`` invalidates it; see ``validate_token``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class _WorldServer:
|
||||
world_id: str
|
||||
port: int
|
||||
proc: subprocess.Popen
|
||||
status_path: Path
|
||||
token: str
|
||||
started_at: float = field(default_factory=time.time)
|
||||
last_join: float = field(default_factory=time.time)
|
||||
|
||||
def status(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.status_path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
def player_count(self) -> int:
|
||||
return int(self.status().get("players", 0))
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
return self.status().get("ready") is True
|
||||
|
||||
def alive(self) -> bool:
|
||||
return self.proc.poll() is None
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.alive():
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.status_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class GameServerSupervisor:
|
||||
def __init__(self) -> None:
|
||||
self._servers: dict[str, _WorldServer] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._reaper: asyncio.Task | None = None
|
||||
self._runtime_dir = Path(tempfile.gettempdir()) / "agp-game-servers"
|
||||
self._runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
if not settings.GAME_SERVERS_ENABLED:
|
||||
return
|
||||
if self._reaper is None or self._reaper.done():
|
||||
self._reaper = asyncio.create_task(self._reap_loop())
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._reaper:
|
||||
self._reaper.cancel()
|
||||
async with self._lock:
|
||||
for srv in list(self._servers.values()):
|
||||
srv.stop()
|
||||
self._servers.clear()
|
||||
|
||||
# ── join ─────────────────────────────────────────────────────────────
|
||||
async def ensure_server(self, world_id: str) -> dict:
|
||||
"""Return ``{"host", "port", "world_id"}`` for the world's dedicated
|
||||
server, spawning it if needed. Raises RuntimeError if orchestration
|
||||
is disabled or no port/engine is available."""
|
||||
if not settings.GAME_SERVERS_ENABLED:
|
||||
raise RuntimeError("game servers are not enabled on this backend")
|
||||
|
||||
async with self._lock:
|
||||
srv = self._servers.get(world_id)
|
||||
if srv is not None and not srv.alive():
|
||||
srv.stop()
|
||||
srv = None
|
||||
del self._servers[world_id]
|
||||
|
||||
if srv is None:
|
||||
srv = self._spawn(world_id)
|
||||
self._servers[world_id] = srv
|
||||
try:
|
||||
await self._await_ready(srv)
|
||||
except TimeoutError:
|
||||
# Real failure now, not a silent "hope it's fine" --
|
||||
# see _await_ready's docstring. Don't leave a possibly
|
||||
# half-started process registered.
|
||||
srv.stop()
|
||||
del self._servers[world_id]
|
||||
raise RuntimeError(
|
||||
f"dedicated server for world {world_id} did not become ready in time"
|
||||
)
|
||||
|
||||
srv.last_join = time.time()
|
||||
return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id}
|
||||
|
||||
def validate_token(self, world_id: str, token: str) -> bool:
|
||||
"""`require_game_server` dependency support (world_persistence_impl.md
|
||||
§7.3): true iff *token* is the live token for *world_id*'s server.
|
||||
A dict lookup, as the doc specifies, plus a constant-time compare
|
||||
since it's a bearer credential."""
|
||||
srv = self._servers.get(str(world_id))
|
||||
if srv is None or not srv.alive():
|
||||
return False
|
||||
return hmac.compare_digest(srv.token, token)
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────
|
||||
def _spawn(self, world_id: str) -> _WorldServer:
|
||||
port = self._free_port()
|
||||
token = secrets.token_urlsafe(32)
|
||||
status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
|
||||
status_path.unlink(missing_ok=True)
|
||||
cmd = [
|
||||
settings.GODOT_BIN, "--headless",
|
||||
"--path", settings.GAME_PROJECT_DIR,
|
||||
"res://scenes/server.tscn", "--",
|
||||
"--port", str(port),
|
||||
"--world", str(world_id),
|
||||
"--status-file", str(status_path),
|
||||
"--api-url", settings.GAME_SERVER_INTERNAL_API_URL,
|
||||
"--server-token", token,
|
||||
]
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return _WorldServer(
|
||||
world_id=world_id, port=port, proc=proc, status_path=status_path, token=token
|
||||
)
|
||||
|
||||
def _free_port(self) -> int:
|
||||
used = {s.port for s in self._servers.values()}
|
||||
for p in range(settings.GAME_SERVER_PORT_MIN, settings.GAME_SERVER_PORT_MAX + 1):
|
||||
if p in used:
|
||||
continue
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
try:
|
||||
s.bind(("0.0.0.0", p))
|
||||
return p
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("no free game-server port in the configured range")
|
||||
|
||||
async def _await_ready(self, srv: _WorldServer) -> None:
|
||||
"""Wait for a ``"ready": true`` field in the status file
|
||||
(world_persistence_impl.md §7.4).
|
||||
|
||||
This replaces the old ``_await_port``, which had two real bugs:
|
||||
it treated a bound UDP port as "ready", but with content loading
|
||||
the port binds *before* the server has fetched its world's rooms
|
||||
and chunks (world_persistence_impl.md §7.2 step 4) -- so a client
|
||||
could get handed an address for a server still mid-fetch. And it
|
||||
*failed open*: past its 8s deadline it simply returned, with no
|
||||
error, so ``ensure_server`` reported success for a server that may
|
||||
never have started at all. This raises ``TimeoutError`` instead,
|
||||
which ``ensure_server`` turns into a real 503 for the joining
|
||||
client, and also bails out early (same exception) if the process
|
||||
has already exited.
|
||||
"""
|
||||
deadline = time.time() + settings.GAME_SERVER_READY_TIMEOUT_SECONDS
|
||||
while time.time() < deadline:
|
||||
if srv.is_ready():
|
||||
return
|
||||
if not srv.alive():
|
||||
raise TimeoutError(
|
||||
f"server process for world {srv.world_id} exited before becoming ready"
|
||||
)
|
||||
await asyncio.sleep(0.25)
|
||||
raise TimeoutError(
|
||||
f"server for world {srv.world_id} did not report ready within "
|
||||
f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s"
|
||||
)
|
||||
|
||||
async def _reap_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
for wid, srv in list(self._servers.items()):
|
||||
if not srv.alive():
|
||||
srv.stop()
|
||||
del self._servers[wid]
|
||||
continue
|
||||
idle_for = now - srv.last_join
|
||||
if idle_for > settings.GAME_SERVER_IDLE_TIMEOUT_SECONDS and srv.player_count() == 0:
|
||||
srv.stop()
|
||||
del self._servers[wid]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def debug_state(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"world_id": s.world_id, "port": s.port, "alive": s.alive(),
|
||||
"players": s.player_count(), "uptime_s": round(time.time() - s.started_at),
|
||||
}
|
||||
for s in self._servers.values()
|
||||
]
|
||||
|
||||
|
||||
supervisor = GameServerSupervisor()
|
||||
@@ -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,431 @@
|
||||
"""World content storage service.
|
||||
|
||||
Implements the `world.md` §5.1 service interface over Postgres, per
|
||||
`world_persistence_impl.md` §9 -- **minus** ``flush_dirty`` and
|
||||
``rebuild_index``, which don't apply once there is no write-behind dirty
|
||||
set and no derived index to rebuild (see that doc's §3.3 and §4.2).
|
||||
|
||||
This is the *only* place chunk content is written (world_persistence_impl.md
|
||||
D2 / §5). Callers (the routers) own transaction boundaries: methods here
|
||||
``flush()`` so later statements in the same request see earlier ones and so
|
||||
integrity errors surface before a caller commits, but they never
|
||||
``commit()`` -- that mirrors the existing convention in
|
||||
``app/routers/worlds.py`` (``get_db`` docs: "Handlers are responsible for
|
||||
committing or rolling back their own transactions").
|
||||
|
||||
Known gap this module had to resolve on its own (flagged, not silently
|
||||
decided): `world_persistence_impl.md` §6/§7.2/§8.1 all reference a world's
|
||||
monotonic ``content_revision`` ("bumped on every chunk write"), but no
|
||||
column for it is defined anywhere in §4's schema. Rather than add an
|
||||
undocumented column, ``content_revision()`` below derives it as
|
||||
``MAX(id)`` over ``world_chunk_revisions`` scoped to the world: that table
|
||||
is append-only (retention only nulls ``payload``, it never deletes rows),
|
||||
so the value is genuinely monotonic and increments on every
|
||||
create/update/delete/rollback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import load_only
|
||||
|
||||
from app.config import settings
|
||||
from app.models.world_content import (
|
||||
MAIN_ROOM_NAME,
|
||||
MAIN_ROOM_SLUG,
|
||||
WorldChunk,
|
||||
WorldChunkRevision,
|
||||
WorldRoom,
|
||||
)
|
||||
from app.services import chunk_codec
|
||||
|
||||
|
||||
class RevisionConflict(Exception):
|
||||
"""Optimistic-concurrency conflict (world.md §5.4). ``current_revision``
|
||||
is 0 and ``current_checksum`` is None when the caller's ``base_revision``
|
||||
assumed a chunk exists but it doesn't."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_revision: int,
|
||||
current_checksum: str | None,
|
||||
coord: tuple[int, int, int] | None = None,
|
||||
) -> None:
|
||||
self.current_revision = current_revision
|
||||
self.current_checksum = current_checksum
|
||||
self.coord = coord
|
||||
super().__init__(f"revision conflict: current_revision={current_revision}")
|
||||
|
||||
|
||||
class RevisionNotAvailable(Exception):
|
||||
"""Raised by ``rollback_chunk`` when the requested revision doesn't
|
||||
exist, or exists but its payload was pruned by the retention policy
|
||||
(world_persistence_impl.md §4.3)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bounds:
|
||||
cx_min: int | None = None
|
||||
cx_max: int | None = None
|
||||
cy_min: int | None = None
|
||||
cy_max: int | None = None
|
||||
cz: int | None = None
|
||||
|
||||
|
||||
class WorldStore:
|
||||
"""Owns chunk/room read/write operations over the async session
|
||||
handed to it. One instance per request is the expected usage
|
||||
(``WorldStore(db)`` where ``db`` is the ``DbDep`` session)."""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
# ── rooms ────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_room(self, world_id: int, slug: str) -> WorldRoom | None:
|
||||
result = await self.db.execute(
|
||||
select(WorldRoom).where(WorldRoom.world_id == world_id, WorldRoom.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_rooms(self, world_id: int) -> list[WorldRoom]:
|
||||
result = await self.db.execute(
|
||||
select(WorldRoom).where(WorldRoom.world_id == world_id).order_by(WorldRoom.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create_room(
|
||||
self,
|
||||
world_id: int,
|
||||
slug: str,
|
||||
name: str,
|
||||
*,
|
||||
grid: dict | None = None,
|
||||
bounds: dict | None = None,
|
||||
spawn: dict | None = None,
|
||||
) -> WorldRoom:
|
||||
room = WorldRoom(world_id=world_id, slug=slug, name=name)
|
||||
if grid is not None:
|
||||
room.grid = grid
|
||||
if bounds is not None:
|
||||
room.bounds = bounds
|
||||
if spawn is not None:
|
||||
room.spawn = spawn
|
||||
self.db.add(room)
|
||||
await self.db.flush()
|
||||
await self.db.refresh(room)
|
||||
return room
|
||||
|
||||
async def get_or_create_main_room(self, world_id: int) -> WorldRoom:
|
||||
"""world_persistence_impl.md §4.1: every world gets a ``main`` room.
|
||||
Called from ``create_world`` for new worlds, and lazily here for
|
||||
worlds that predate this migration."""
|
||||
room = await self.get_room(world_id, MAIN_ROOM_SLUG)
|
||||
if room is not None:
|
||||
return room
|
||||
try:
|
||||
return await self.create_room(world_id, MAIN_ROOM_SLUG, MAIN_ROOM_NAME)
|
||||
except IntegrityError:
|
||||
# Lost a race with a concurrent creator; the row now exists.
|
||||
await self.db.rollback()
|
||||
room = await self.get_room(world_id, MAIN_ROOM_SLUG)
|
||||
if room is None:
|
||||
raise
|
||||
return room
|
||||
|
||||
async def count_chunks(self, room_id: int) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count()).select_from(WorldChunk).where(WorldChunk.room_id == room_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def content_revision(self, world_id: int) -> int:
|
||||
"""See module docstring for why this is derived rather than stored."""
|
||||
result = await self.db.execute(
|
||||
select(func.max(WorldChunkRevision.id)).where(WorldChunkRevision.world_id == world_id)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
# ── chunk reads ──────────────────────────────────────────────────────
|
||||
|
||||
async def get_chunk(self, key: str) -> WorldChunk | None:
|
||||
result = await self.db.execute(select(WorldChunk).where(WorldChunk.chunk_key == key))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_chunk_by_coord(self, room_id: int, cx: int, cy: int, cz: int) -> WorldChunk | None:
|
||||
result = await self.db.execute(
|
||||
select(WorldChunk).where(
|
||||
WorldChunk.room_id == room_id, WorldChunk.cx == cx,
|
||||
WorldChunk.cy == cy, WorldChunk.cz == cz,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_chunk_binary(self, key: str) -> bytes | None:
|
||||
chunk = await self.get_chunk(key)
|
||||
return chunk.payload if chunk is not None else None
|
||||
|
||||
async def get_chunk_flags(self, key: str) -> dict | None:
|
||||
chunk = await self.get_chunk(key)
|
||||
return chunk.content_flags if chunk is not None else None
|
||||
|
||||
async def list_chunks(
|
||||
self,
|
||||
world_id: int,
|
||||
room_id: int | None = None,
|
||||
bounds: Bounds | None = None,
|
||||
) -> list[WorldChunk]:
|
||||
"""Index query -- world.md §5.1's ``list_chunks``. Payload bytes are
|
||||
deliberately not loaded (world_persistence_impl.md §8.1: "Payloads
|
||||
are not included")."""
|
||||
query = select(WorldChunk).options(
|
||||
load_only(
|
||||
WorldChunk.id, WorldChunk.world_id, WorldChunk.room_id, WorldChunk.chunk_key,
|
||||
WorldChunk.cx, WorldChunk.cy, WorldChunk.cz, WorldChunk.revision,
|
||||
WorldChunk.checksum, WorldChunk.payload_len, WorldChunk.is_mature,
|
||||
WorldChunk.updated_at,
|
||||
)
|
||||
).where(WorldChunk.world_id == world_id)
|
||||
if room_id is not None:
|
||||
query = query.where(WorldChunk.room_id == room_id)
|
||||
if bounds is not None:
|
||||
if bounds.cx_min is not None:
|
||||
query = query.where(WorldChunk.cx >= bounds.cx_min)
|
||||
if bounds.cx_max is not None:
|
||||
query = query.where(WorldChunk.cx <= bounds.cx_max)
|
||||
if bounds.cy_min is not None:
|
||||
query = query.where(WorldChunk.cy >= bounds.cy_min)
|
||||
if bounds.cy_max is not None:
|
||||
query = query.where(WorldChunk.cy <= bounds.cy_max)
|
||||
if bounds.cz is not None:
|
||||
query = query.where(WorldChunk.cz == bounds.cz)
|
||||
query = query.order_by(WorldChunk.cx, WorldChunk.cy, WorldChunk.cz)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# ── chunk writes ─────────────────────────────────────────────────────
|
||||
|
||||
async def put_chunk(
|
||||
self,
|
||||
*,
|
||||
room: WorldRoom,
|
||||
cx: int,
|
||||
cy: int,
|
||||
cz: int,
|
||||
payload_bytes: bytes,
|
||||
base_revision: int,
|
||||
actor_id: int | None,
|
||||
) -> WorldChunk:
|
||||
"""Full-chunk write with optimistic concurrency (world.md §5.4,
|
||||
world_persistence_impl.md §5.4/§8.2). Validates the payload
|
||||
server-side (§5.5) before ever touching a row. ``base_revision=0``
|
||||
means "create; 409 if it already exists"."""
|
||||
key = chunk_codec.build_key(room.world_id, room.slug, cx, cy, cz)
|
||||
grid_size = tuple(room.grid.get("size", [32, 32, 1]))
|
||||
|
||||
# Raises ChunkCodecError / ChunkValidationError -- the router maps
|
||||
# those to 422. Nothing below trusts the request body's own claims
|
||||
# about content_flags/is_mature; both come from `decoded`.
|
||||
decoded = chunk_codec.decode_and_validate(
|
||||
payload_bytes,
|
||||
expected_key=key,
|
||||
grid_size=grid_size,
|
||||
max_objects=settings.WORLD_CHUNK_MAX_OBJECTS,
|
||||
)
|
||||
body_checksum = chunk_codec.checksum(payload_bytes[chunk_codec.HEADER_SIZE:])
|
||||
|
||||
result = await self.db.execute(
|
||||
select(WorldChunk)
|
||||
.where(
|
||||
WorldChunk.room_id == room.id, WorldChunk.cx == cx,
|
||||
WorldChunk.cy == cy, WorldChunk.cz == cz,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if base_revision == 0:
|
||||
if row is not None:
|
||||
raise RevisionConflict(row.revision, row.checksum, (cx, cy, cz))
|
||||
row = WorldChunk(
|
||||
world_id=room.world_id, room_id=room.id, chunk_key=key,
|
||||
cx=cx, cy=cy, cz=cz, revision=1,
|
||||
)
|
||||
self.db.add(row)
|
||||
action = "create"
|
||||
else:
|
||||
if row is None or row.revision != base_revision:
|
||||
raise RevisionConflict(
|
||||
row.revision if row else 0, row.checksum if row else None, (cx, cy, cz)
|
||||
)
|
||||
row.revision = row.revision + 1
|
||||
action = "update"
|
||||
|
||||
row.format_version = decoded.version
|
||||
row.payload = payload_bytes
|
||||
row.payload_len = len(payload_bytes)
|
||||
row.checksum = body_checksum
|
||||
row.content_flags = decoded.flags.to_dict()
|
||||
row.is_mature = decoded.flags.mature
|
||||
row.updated_by_id = actor_id
|
||||
|
||||
try:
|
||||
await self.db.flush()
|
||||
except IntegrityError:
|
||||
# Lost a create race to a concurrent writer between our SELECT
|
||||
# and this flush. Roll back and report the real current state.
|
||||
await self.db.rollback()
|
||||
existing = await self.get_chunk_by_coord(room.id, cx, cy, cz)
|
||||
raise RevisionConflict(
|
||||
existing.revision if existing else 0,
|
||||
existing.checksum if existing else None,
|
||||
(cx, cy, cz),
|
||||
)
|
||||
|
||||
await self._record_revision(key, room.world_id, row.revision, action, actor_id, body_checksum, payload_bytes)
|
||||
await self.db.refresh(row)
|
||||
return row
|
||||
|
||||
async def put_chunks_batch(
|
||||
self,
|
||||
*,
|
||||
room: WorldRoom,
|
||||
items: list[dict],
|
||||
actor_id: int | None,
|
||||
) -> list[WorldChunk]:
|
||||
"""Batch write (world_persistence_impl.md §8.1). One transaction,
|
||||
all-or-nothing: none of this ``commit()``s, so a ``RevisionConflict``
|
||||
raised partway leaves nothing persisted once the caller's request
|
||||
handler lets the exception (or the HTTPException it becomes)
|
||||
propagate through ``get_db``, which rolls back on any exception."""
|
||||
rows: list[WorldChunk] = []
|
||||
for item in items:
|
||||
row = await self.put_chunk(
|
||||
room=room, cx=item["cx"], cy=item["cy"], cz=item["cz"],
|
||||
payload_bytes=item["payload_bytes"], base_revision=item["base_revision"],
|
||||
actor_id=actor_id,
|
||||
)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
async def delete_chunk(self, key: str, actor_id: int | None) -> None:
|
||||
row = await self.get_chunk(key)
|
||||
if row is None:
|
||||
return
|
||||
await self._record_revision(key, row.world_id, row.revision, "delete", actor_id, row.checksum, None)
|
||||
await self.db.delete(row)
|
||||
await self.db.flush()
|
||||
|
||||
async def rollback_chunk(self, key: str, revision: int, actor_id: int | None) -> WorldChunk:
|
||||
"""Restore a chunk to a previously-recorded revision's payload
|
||||
(world.md §5.1). Raises ``RevisionNotAvailable`` if that revision
|
||||
was never recorded, its payload was pruned by retention, or the
|
||||
chunk has since been deleted outright (nothing to update in place)."""
|
||||
rev_result = await self.db.execute(
|
||||
select(WorldChunkRevision)
|
||||
.where(WorldChunkRevision.chunk_key == key, WorldChunkRevision.revision == revision)
|
||||
.order_by(WorldChunkRevision.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
rev_row = rev_result.scalar_one_or_none()
|
||||
if rev_row is None:
|
||||
raise RevisionNotAvailable(f"no revision {revision} recorded for {key!r}")
|
||||
if rev_row.payload is None:
|
||||
raise RevisionNotAvailable(f"revision {revision} of {key!r} was pruned; payload unavailable")
|
||||
|
||||
chunk = await self.get_chunk(key)
|
||||
if chunk is None:
|
||||
raise RevisionNotAvailable(f"chunk {key!r} no longer exists; cannot roll back in place")
|
||||
|
||||
room_result = await self.db.execute(select(WorldRoom).where(WorldRoom.id == chunk.room_id))
|
||||
room = room_result.scalar_one()
|
||||
grid_size = tuple(room.grid.get("size", [32, 32, 1]))
|
||||
decoded = chunk_codec.decode_and_validate(
|
||||
rev_row.payload, expected_key=key, grid_size=grid_size,
|
||||
max_objects=settings.WORLD_CHUNK_MAX_OBJECTS,
|
||||
)
|
||||
body_checksum = chunk_codec.checksum(rev_row.payload[chunk_codec.HEADER_SIZE:])
|
||||
|
||||
chunk.revision = chunk.revision + 1
|
||||
chunk.payload = rev_row.payload
|
||||
chunk.payload_len = len(rev_row.payload)
|
||||
chunk.checksum = body_checksum
|
||||
chunk.format_version = decoded.version
|
||||
chunk.content_flags = decoded.flags.to_dict()
|
||||
chunk.is_mature = decoded.flags.mature
|
||||
chunk.updated_by_id = actor_id
|
||||
|
||||
await self.db.flush()
|
||||
# Tagged "update" (not a fourth action value) to keep the
|
||||
# documented 'create'|'update'|'delete' action enum intact.
|
||||
await self._record_revision(
|
||||
key, chunk.world_id, chunk.revision, "update", actor_id, body_checksum, rev_row.payload
|
||||
)
|
||||
await self.db.refresh(chunk)
|
||||
return chunk
|
||||
|
||||
async def list_revisions(self, chunk_key: str, limit: int = 50) -> list[WorldChunkRevision]:
|
||||
"""Not in world.md §5.1's interface by name, but required to
|
||||
implement the revision-history endpoint world_persistence_impl.md
|
||||
§8.1 lists (``GET .../revisions``)."""
|
||||
result = await self.db.execute(
|
||||
select(WorldChunkRevision)
|
||||
.where(WorldChunkRevision.chunk_key == chunk_key)
|
||||
.order_by(WorldChunkRevision.revision.desc(), WorldChunkRevision.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def migrate(self) -> None:
|
||||
"""No-op. `world.md` §4.8's hand-rolled SQLite migration runner is
|
||||
superseded by Alembic (`backend/alembic/`) now that content lives
|
||||
in Postgres (world_persistence_impl.md §3.3). Kept only so this
|
||||
class's shape still matches the §5.1 interface it's implementing
|
||||
a subset of."""
|
||||
return None
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────
|
||||
|
||||
async def _record_revision(
|
||||
self,
|
||||
chunk_key: str,
|
||||
world_id: int,
|
||||
revision: int,
|
||||
action: str,
|
||||
actor_id: int | None,
|
||||
checksum_hex: str,
|
||||
payload_bytes: bytes | None,
|
||||
) -> None:
|
||||
"""Append an audit row and enforce the retention policy
|
||||
(world_persistence_impl.md §4.3, required): keep the newest
|
||||
``WORLD_CHUNK_REVISION_KEEP`` payloads per ``chunk_key``, null out
|
||||
``payload`` on older rows, in the same transaction as the write."""
|
||||
rev = WorldChunkRevision(
|
||||
chunk_key=chunk_key, world_id=world_id, revision=revision, action=action,
|
||||
actor_id=actor_id, checksum=checksum_hex, payload=payload_bytes,
|
||||
)
|
||||
self.db.add(rev)
|
||||
await self.db.flush()
|
||||
|
||||
keep = settings.WORLD_CHUNK_REVISION_KEEP
|
||||
keep_ids_result = await self.db.execute(
|
||||
select(WorldChunkRevision.id)
|
||||
.where(WorldChunkRevision.chunk_key == chunk_key)
|
||||
.order_by(WorldChunkRevision.id.desc())
|
||||
.limit(keep)
|
||||
)
|
||||
keep_ids = [row[0] for row in keep_ids_result.all()]
|
||||
if keep_ids:
|
||||
await self.db.execute(
|
||||
update(WorldChunkRevision)
|
||||
.where(
|
||||
WorldChunkRevision.chunk_key == chunk_key,
|
||||
WorldChunkRevision.id.notin_(keep_ids),
|
||||
WorldChunkRevision.payload.isnot(None),
|
||||
)
|
||||
.values(payload=None)
|
||||
)
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_PASSWORD: agp_password
|
||||
POSTGRES_DB: agp_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "${REDIS_HOST_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
@@ -9,6 +9,14 @@ 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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Shared pytest fixtures + hermetic-DB bootstrap.
|
||||
|
||||
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
|
||||
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
|
||||
with no manual setup, ``pytest_configure`` below brings up an ephemeral
|
||||
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
|
||||
not already pointing at a reachable database. When it is (dev loop, or the
|
||||
checkpoint runner which stands the DB up itself), that path is skipped and
|
||||
the existing database is reused.
|
||||
|
||||
If Docker isn't available and no DB is reachable, the whole DB-backed suite
|
||||
is skipped with a clear message rather than erroring out 60+ times.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_TABLES = (
|
||||
"avatar_loadouts", "assets",
|
||||
# world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 /
|
||||
# world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world
|
||||
# must not lose the audit trail), so it won't be cascade-truncated via
|
||||
# `worlds` the way world_rooms/world_chunks would be. List all three
|
||||
# explicitly rather than rely on cascade for some of them.
|
||||
"world_chunk_revisions", "world_chunks", "world_rooms",
|
||||
"worlds", "sessions", "refresh_tokens", "users",
|
||||
)
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
_COMPOSE_PROJECT = "agp-pytest"
|
||||
_EPHEMERAL_PG_PORT = 25432
|
||||
|
||||
# Set by _ensure_database() when it starts a container it owns; torn down in
|
||||
# pytest_unconfigure.
|
||||
_started_container = False
|
||||
_db_unavailable_reason: str | None = None
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _db_reachable(url: str) -> bool:
|
||||
parts = urlsplit(url)
|
||||
return _port_open(parts.hostname or "localhost", parts.port or 5432)
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
global _started_container, _db_unavailable_reason
|
||||
|
||||
if _db_reachable(settings.DATABASE_URL):
|
||||
return # dev loop / checkpoint runner already provided one
|
||||
|
||||
if not _run(["docker", "--version"]).returncode == 0:
|
||||
_db_unavailable_reason = (
|
||||
"no reachable DATABASE_URL and docker is unavailable -- run the "
|
||||
"checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres "
|
||||
"manually"
|
||||
)
|
||||
return
|
||||
|
||||
env = os.environ.copy()
|
||||
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
|
||||
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||
up = _run(["docker", "compose", "up", "-d", "postgres"], env=env)
|
||||
if up.returncode != 0:
|
||||
_db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}"
|
||||
return
|
||||
_started_container = True
|
||||
|
||||
for _ in range(30):
|
||||
r = _run(
|
||||
["docker", "compose", "exec", "-T", "postgres",
|
||||
"pg_isready", "-U", "agp_user", "-d", "agp_db"],
|
||||
env=env,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
_db_unavailable_reason = "ephemeral Postgres never became ready"
|
||||
return
|
||||
|
||||
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
|
||||
os.environ["DATABASE_URL"] = url
|
||||
settings.DATABASE_URL = url
|
||||
|
||||
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||
env={**os.environ, "DATABASE_URL": url})
|
||||
if mig.returncode != 0:
|
||||
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
|
||||
|
||||
|
||||
def pytest_unconfigure(config):
|
||||
if not _started_container:
|
||||
return
|
||||
env = os.environ.copy()
|
||||
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
|
||||
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||
_run(["docker", "compose", "down", "-v"], env=env)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _reset_database():
|
||||
"""Truncate all application tables once per test session."""
|
||||
if _db_unavailable_reason:
|
||||
pytest.skip(_db_unavailable_reason, allow_module_level=True)
|
||||
|
||||
async def _truncate() -> None:
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE"))
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_truncate())
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiter():
|
||||
"""Every TestClient request looks like the same client IP, so the
|
||||
per-IP auth limiter would trip mid-suite. Clear it between tests; the
|
||||
dedicated rate-limit test disables this to exercise the real behaviour.
|
||||
"""
|
||||
from app import rate_limit
|
||||
rate_limit.reset_all()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test.
|
||||
|
||||
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
||||
(sync) TestClient runs each request through anyio's blocking-portal
|
||||
machinery, which can hand different requests different event loops.
|
||||
asyncpg connections are bound to the loop they were created on, so
|
||||
reusing one from the shared pool across requests raises "attached to a
|
||||
different loop" the moment a test makes more than one request. NullPool
|
||||
sidesteps it: every checkout is a brand-new connection.
|
||||
"""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
Binary file not shown.
BIN
Binary file not shown.
+105
@@ -0,0 +1,105 @@
|
||||
"""Generates the golden-file chunk fixtures in this directory.
|
||||
|
||||
These pin the world.md §3.6 binary format so the Python codec
|
||||
(app/services/chunk_codec.py) and any future Godot-side test fixture can be
|
||||
checked for drift (world_persistence_impl.md §5.5's "drift control"
|
||||
requirement). No Godot-side golden fixture existed at the time this was
|
||||
written (searched scripts/ and tests/ for AGPC / binary-format test
|
||||
vectors -- none found), so these are authored here, from this
|
||||
implementation, as the first golden files for the format. The client/editor
|
||||
follow-on task should decode these same files with
|
||||
`ChunkDataDecoder.decode_binary()` and assert the same logical values
|
||||
(see the note in chunk_codec.py about a confirmed float32 handling bug in
|
||||
that decoder that will need fixing for full byte parity on non-integer
|
||||
transforms).
|
||||
|
||||
Run with: `python tests/fixtures/chunks/generate_fixtures.py` from `backend/`.
|
||||
Fixtures are checked in; this script is for regenerating them if the format
|
||||
or these examples change, not run as part of the test suite.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # backend/
|
||||
|
||||
from app.services import chunk_codec as cc
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _empty_chunk() -> cc.ChunkPayload:
|
||||
return cc.ChunkPayload(
|
||||
world_id="7", room_id="main", coord=(0, 0, 0),
|
||||
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
tile_layers=[], objects=[],
|
||||
flags=cc.ContentFlags(),
|
||||
created_at=1_700_000_000, updated_at=1_700_000_000, revision=1, author="fixture-gen",
|
||||
)
|
||||
|
||||
|
||||
def _simple_tile_chunk() -> cc.ChunkPayload:
|
||||
layer = cc.TileLayer(
|
||||
name="ground", layer_id=0, width=4, height=4, depth=1,
|
||||
palette=[
|
||||
cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1),
|
||||
cc.TileDef(id=1, asset_id="tiles/grass", solid=False, flags=1, variants=4),
|
||||
cc.TileDef(id=2, asset_id="tiles/stone", solid=True, flags=2, variants=2),
|
||||
],
|
||||
tiles=[0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0],
|
||||
)
|
||||
return cc.ChunkPayload(
|
||||
world_id="12", room_id="main", coord=(3, -1, 0),
|
||||
bounds=cc.ChunkBounds(min=(96.0, -32.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
tile_layers=[layer], objects=[],
|
||||
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
|
||||
created_at=1_700_000_000, updated_at=1_700_000_500, revision=2, author="fixture-gen",
|
||||
)
|
||||
|
||||
|
||||
def _chunk_with_objects() -> cc.ChunkPayload:
|
||||
layer = cc.TileLayer(
|
||||
name="ground", layer_id=0, width=2, height=2, depth=1,
|
||||
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
|
||||
tiles=[0, 0, 0, 0],
|
||||
)
|
||||
obj1 = cc.WorldObject(
|
||||
id="obj_0001", type="interactable", asset_id="props/chest",
|
||||
pos=(101.5, -110.25, 0.5), rot=(0.0, 0.0, 0.0, 1.0), scale=(1.0, 1.0, 1.0),
|
||||
dynamic=False,
|
||||
properties={"locked": False, "loot_table": "chest_basic"},
|
||||
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
|
||||
)
|
||||
obj2 = cc.WorldObject(
|
||||
id="obj_0002", type="spawn_point", asset_id="",
|
||||
pos=(96.0, -128.0, 1.0), rot=(0.0, 0.7071, 0.0, 0.7071), scale=(1.0, 1.0, 1.0),
|
||||
dynamic=True,
|
||||
properties={},
|
||||
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
|
||||
)
|
||||
return cc.ChunkPayload(
|
||||
world_id="12", room_id="main", coord=(3, -4, 0),
|
||||
bounds=cc.ChunkBounds(min=(96.0, -128.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
tile_layers=[layer], objects=[obj1, obj2],
|
||||
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
|
||||
created_at=1_700_000_000, updated_at=1_700_001_000, revision=5, author="agent-bot",
|
||||
)
|
||||
|
||||
|
||||
FIXTURES = {
|
||||
"empty_chunk.bin": _empty_chunk,
|
||||
"simple_tile_chunk.bin": _simple_tile_chunk,
|
||||
"chunk_with_objects.bin": _chunk_with_objects,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for filename, builder in FIXTURES.items():
|
||||
payload = builder()
|
||||
data = cc.encode_binary(payload)
|
||||
(FIXTURES_DIR / filename).write_bytes(data)
|
||||
print(f"wrote {filename} ({len(data)} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,104 @@
|
||||
"""Tests for the 18+ age gate on user registration."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.routers.auth import calculate_age, MIN_AGE, RegisterRequest
|
||||
|
||||
|
||||
class TestCalculateAge:
|
||||
"""Unit tests for the calculate_age helper."""
|
||||
|
||||
def test_exactly_18(self):
|
||||
today = date(2026, 6, 15)
|
||||
dob = today.replace(year=today.year - 18)
|
||||
assert calculate_age(dob, today=today) == 18
|
||||
|
||||
def test_just_turned_18(self):
|
||||
today = date(2026, 6, 15)
|
||||
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
||||
assert calculate_age(dob, today=today) == 18
|
||||
|
||||
def test_one_day_short_of_18(self):
|
||||
today = date(2026, 6, 15)
|
||||
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
||||
assert calculate_age(dob, today=today) == 17
|
||||
|
||||
def test_leap_day_birthday_before(self):
|
||||
assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23
|
||||
|
||||
def test_leap_day_birthday_after(self):
|
||||
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
|
||||
|
||||
|
||||
class TestRegisterRequestDob:
|
||||
def test_dob_parsed_from_iso_string(self):
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p",
|
||||
date_of_birth="1990-01-01")
|
||||
assert req.date_of_birth == date(1990, 1, 1)
|
||||
|
||||
def test_dob_required(self):
|
||||
with pytest.raises(Exception):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||
|
||||
def test_bad_dob_string_rejected(self):
|
||||
with pytest.raises(Exception):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p",
|
||||
date_of_birth="not-a-date")
|
||||
|
||||
|
||||
class TestRegisterAgeGate:
|
||||
"""End-to-end: the /register endpoint computes age from the DOB."""
|
||||
|
||||
def _dob_for_age(self, years: int) -> str:
|
||||
return (date.today().replace(year=date.today().year - years)).isoformat()
|
||||
|
||||
def test_adult_accepted(self):
|
||||
client = TestClient(app)
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": "gate_adult", "email": "gate_adult@e.com",
|
||||
"password": "secret123", "date_of_birth": self._dob_for_age(25),
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
def test_minor_rejected(self):
|
||||
client = TestClient(app)
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": "gate_minor", "email": "gate_minor@e.com",
|
||||
"password": "secret123", "date_of_birth": self._dob_for_age(15),
|
||||
})
|
||||
assert r.status_code == 403
|
||||
assert str(MIN_AGE) in r.json()["detail"]
|
||||
|
||||
def test_exactly_18_accepted(self):
|
||||
client = TestClient(app)
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": "gate_18", "email": "gate_18@e.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": (date.today().replace(year=date.today().year - 18) - timedelta(days=1)).isoformat(),
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
def test_future_dob_rejected(self):
|
||||
client = TestClient(app)
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": "gate_future", "email": "gate_future@e.com",
|
||||
"password": "secret123", "date_of_birth": "2999-01-01",
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_dob_is_stored(self):
|
||||
client = TestClient(app)
|
||||
dob = self._dob_for_age(30)
|
||||
reg = client.post("/api/auth/register", json={
|
||||
"username": "gate_stored", "email": "gate_stored@e.com",
|
||||
"password": "secret123", "date_of_birth": dob,
|
||||
})
|
||||
assert reg.status_code == 201
|
||||
me = client.get("/api/auth/me", headers={
|
||||
"Authorization": f"Bearer {reg.json()['access_token']}"
|
||||
})
|
||||
assert me.json()["date_of_birth"] == dob
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Integration tests for the auth endpoints (register + login).
|
||||
|
||||
These tests hit the real FastAPI app against the real configured database
|
||||
(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated
|
||||
in-memory DB), so they exercise the full stack: Pydantic validation → DB →
|
||||
JWT → response. Requires migrations already applied (alembic upgrade head)
|
||||
before running.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
from app.routers.auth import pwd_context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test.
|
||||
|
||||
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
||||
(sync) TestClient runs each request through anyio's blocking-portal
|
||||
machinery, which can hand different requests different event loops.
|
||||
asyncpg connections are bound to the loop they were created on, so
|
||||
reusing one from the shared engine's connection pool across requests
|
||||
raises "cannot perform operation: another operation is in progress" the
|
||||
moment a test makes more than one request (register-then-login,
|
||||
register-twice, etc.) -- confirmed this is the actual cause, not a bug
|
||||
in the endpoints themselves. NullPool sidesteps it: every checkout is a
|
||||
brand-new connection, never handed to a different loop than the one
|
||||
that opened it.
|
||||
"""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegister:
|
||||
"""Tests for POST /api/auth/register."""
|
||||
|
||||
def test_register_success(self):
|
||||
"""A valid registration returns 201 with a token."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_register_rejects_under_18(self):
|
||||
"""A DOB that computes to under 18 gets a 403 (see test_auth_age_gate
|
||||
for the age-math coverage)."""
|
||||
from datetime import date
|
||||
client = TestClient(app)
|
||||
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "kid",
|
||||
"email": "kid@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": minor_dob,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "at least 18" in resp.json()["detail"]
|
||||
|
||||
def test_register_rejects_duplicate_username(self):
|
||||
"""Registering the same username twice returns 409."""
|
||||
client = TestClient(app)
|
||||
# First registration
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "dup1@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
# Second registration with same username, different email
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "dup2@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Username already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_rejects_duplicate_email(self):
|
||||
"""Registering the same email twice returns 409."""
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user1",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user2",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_missing_dob(self):
|
||||
"""Missing the required date_of_birth field returns 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLogin:
|
||||
"""Tests for POST /api/auth/login."""
|
||||
|
||||
def _create_user(self, username: str, password: str, email: str = None):
|
||||
"""Helper to create a user via register."""
|
||||
client = TestClient(app)
|
||||
if email is None:
|
||||
email = f"{username}@example.com"
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def test_login_success(self):
|
||||
"""Correct credentials return a token."""
|
||||
self._create_user("loginuser", "correctpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "loginuser", "password": "correctpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_login_wrong_password(self):
|
||||
"""Wrong password returns 401."""
|
||||
# Distinct username from test_login_success -- there's no per-test
|
||||
# DB rollback/isolation here (real Postgres via the app's own
|
||||
# dependency-overridden session), so reusing "loginuser" collided
|
||||
# with the user that test already created (409 on register instead
|
||||
# of the intended 401-on-login assertion this test is actually for).
|
||||
self._create_user("loginuser_wrongpass", "correctpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "loginuser_wrongpass", "password": "wrongpass"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect username or password" in resp.json()["detail"]
|
||||
|
||||
def test_login_nonexistent_user(self):
|
||||
"""Non-existent user returns 401 (not 404)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "nobody", "password": "any"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect username or password" in resp.json()["detail"]
|
||||
|
||||
def test_login_accepts_form_encoded(self):
|
||||
"""Login accepts application/x-www-form-urlencoded (OAuth2)."""
|
||||
self._create_user("formuser", "formpass")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "formuser", "password": "formpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.json()
|
||||
|
||||
def test_login_token_is_valid_jwt(self):
|
||||
"""The returned token is a valid JWT that can be decoded."""
|
||||
self._create_user("jwttoken", "jwt123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "jwttoken", "password": "jwt123"},
|
||||
)
|
||||
token = resp.json()["access_token"]
|
||||
# Verify it's a 3-part JWT
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for expired-token rejection across auth endpoints.
|
||||
|
||||
Covers two distinct expiry paths:
|
||||
|
||||
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
|
||||
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
|
||||
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
|
||||
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
|
||||
signature is still valid.
|
||||
|
||||
Requires migrations already applied (alembic upgrade head) before running.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from jose import jwt
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
from app.models.session import RefreshToken
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test."""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mint_access_token(exp: datetime) -> str:
|
||||
"""Mint an RS256 access token with an explicit ``exp`` claim."""
|
||||
return jwt.encode(
|
||||
{"sub": "1", "exp": exp},
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def _expire_refresh_token_in_db(token: str) -> None:
|
||||
"""Set the persisted refresh token's ``expires_at`` into the past."""
|
||||
|
||||
async def _do():
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
from sqlalchemy import update
|
||||
|
||||
await session.execute(
|
||||
update(RefreshToken)
|
||||
.where(RefreshToken.token == token)
|
||||
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_do())
|
||||
|
||||
|
||||
def _register_user(client: TestClient, username: str) -> str:
|
||||
"""Register a user and return its refresh token."""
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["refresh_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expired access token (GET /api/auth/me)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpiredAccessToken:
|
||||
def test_expired_access_token_is_rejected_by_me(self):
|
||||
"""An access JWT with a past exp claim returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
expired_token = _mint_access_token(
|
||||
datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
)
|
||||
resp = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Could not validate credentials" in resp.json()["detail"]
|
||||
|
||||
def test_garbage_access_token_is_rejected_by_me(self):
|
||||
"""A non-JWT bearer token returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer not-a-real-jwt"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_bearer_token_returns_401(self):
|
||||
"""No Authorization header at all returns 401 on /me."""
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expired refresh token (POST /api/auth/refresh)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpiredRefreshToken:
|
||||
def test_expired_refresh_token_is_rejected(self):
|
||||
"""A refresh token whose DB expires_at is in the past returns 401."""
|
||||
client = TestClient(app)
|
||||
refresh_token = _register_user(client, "expired_rt_user")
|
||||
|
||||
# Force the persisted record to be expired while its JWT signature
|
||||
# is still valid.
|
||||
_expire_refresh_token_in_db(refresh_token)
|
||||
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
|
||||
def test_expired_refresh_token_jwt_is_rejected(self):
|
||||
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
|
||||
client = TestClient(app)
|
||||
expired_jwt = jwt.encode(
|
||||
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": expired_jwt},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Integration tests for GET/PUT /api/avatar/loadout.
|
||||
|
||||
Hits the real app against the configured Postgres (same contract as the
|
||||
other integration tests -- migrations applied first).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.models.avatar_loadout import DEFAULT_LOADOUT
|
||||
|
||||
|
||||
def _auth_headers(client: TestClient, username: str) -> dict:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
class TestGetLoadout:
|
||||
def test_new_user_gets_the_default(self):
|
||||
client = TestClient(app)
|
||||
headers = _auth_headers(client, "av_default")
|
||||
resp = client.get("/api/avatar/loadout", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["is_default"] is True
|
||||
assert body["data"] == DEFAULT_LOADOUT
|
||||
|
||||
def test_requires_auth(self):
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/avatar/loadout").status_code == 401
|
||||
|
||||
|
||||
class TestPutLoadout:
|
||||
def test_save_then_read_back(self):
|
||||
client = TestClient(app)
|
||||
headers = _auth_headers(client, "av_save")
|
||||
loadout = {
|
||||
"base_body": "base_human_male_01",
|
||||
"slots": {"hair": "hair_mohawk_01", "accessory": ["accessory_belt_01"]},
|
||||
"skin_tone": {"r": 0.8, "g": 0.6, "b": 0.5, "a": 1.0},
|
||||
}
|
||||
put = client.put("/api/avatar/loadout", headers=headers, json={"data": loadout})
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["is_default"] is False
|
||||
|
||||
got = client.get("/api/avatar/loadout", headers=headers)
|
||||
assert got.status_code == 200
|
||||
assert got.json()["is_default"] is False
|
||||
assert got.json()["data"] == loadout
|
||||
|
||||
def test_put_is_idempotent_replace(self):
|
||||
client = TestClient(app)
|
||||
headers = _auth_headers(client, "av_replace")
|
||||
client.put(
|
||||
"/api/avatar/loadout", headers=headers,
|
||||
json={"data": {"base_body": "base_human_male_01", "slots": {"hair": "hair_mohawk_01"}}},
|
||||
)
|
||||
second = {"base_body": "base_human_male_01", "slots": {"chest": "chest_bandit_01"}}
|
||||
client.put("/api/avatar/loadout", headers=headers, json={"data": second})
|
||||
got = client.get("/api/avatar/loadout", headers=headers)
|
||||
assert got.json()["data"] == second
|
||||
|
||||
def test_rejects_missing_base_body(self):
|
||||
client = TestClient(app)
|
||||
headers = _auth_headers(client, "av_nobase")
|
||||
resp = client.put("/api/avatar/loadout", headers=headers, json={"data": {"slots": {}}})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_rejects_non_object_slots(self):
|
||||
client = TestClient(app)
|
||||
headers = _auth_headers(client, "av_badslots")
|
||||
resp = client.put(
|
||||
"/api/avatar/loadout", headers=headers,
|
||||
json={"data": {"base_body": "base_human_male_01", "slots": "nope"}},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_requires_auth(self):
|
||||
client = TestClient(app)
|
||||
resp = client.put("/api/avatar/loadout", json={"data": {"base_body": "x", "slots": {}}})
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Tests for app.services.chunk_codec -- the Python port of world.md §3.6.
|
||||
|
||||
Pure unit tests: no database, no app, no network. Golden fixtures live in
|
||||
tests/fixtures/chunks/ (see generate_fixtures.py there for how/why).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import chunk_codec as cc
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "chunks"
|
||||
|
||||
|
||||
def _read_fixture(name: str) -> bytes:
|
||||
return (FIXTURES_DIR / name).read_bytes()
|
||||
|
||||
|
||||
class TestGoldenFixtures:
|
||||
"""Byte-stability: decode a committed fixture, re-encode it, and the
|
||||
bytes must match exactly (world.md §3.6.3)."""
|
||||
|
||||
@pytest.mark.parametrize("filename", [
|
||||
"empty_chunk.bin", "simple_tile_chunk.bin", "chunk_with_objects.bin",
|
||||
])
|
||||
def test_round_trip_is_byte_identical(self, filename):
|
||||
original = _read_fixture(filename)
|
||||
decoded = cc.decode_binary(original)
|
||||
re_encoded = cc.encode_binary(decoded)
|
||||
assert re_encoded == original
|
||||
|
||||
def test_simple_tile_chunk_logical_values(self):
|
||||
decoded = cc.decode_binary(_read_fixture("simple_tile_chunk.bin"))
|
||||
assert decoded.world_id == "12"
|
||||
assert decoded.room_id == "main"
|
||||
assert decoded.coord == (3, -1, 0)
|
||||
assert decoded.bounds.min == (96.0, -32.0, 0.0)
|
||||
assert decoded.bounds.size == (32.0, 32.0, 1.0)
|
||||
assert len(decoded.tile_layers) == 1
|
||||
layer = decoded.tile_layers[0]
|
||||
assert layer.name == "ground"
|
||||
assert (layer.width, layer.height, layer.depth) == (4, 4, 1)
|
||||
assert len(layer.palette) == 3
|
||||
assert layer.tiles == [0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0]
|
||||
assert decoded.revision == 2
|
||||
assert decoded.author == "fixture-gen"
|
||||
|
||||
def test_chunk_with_objects_logical_values(self):
|
||||
decoded = cc.decode_binary(_read_fixture("chunk_with_objects.bin"))
|
||||
assert len(decoded.objects) == 2
|
||||
obj1, obj2 = decoded.objects
|
||||
assert obj1.id == "obj_0001"
|
||||
assert obj1.type == "interactable"
|
||||
assert obj1.pos == (101.5, -110.25, 0.5)
|
||||
assert obj1.properties == {"locked": False, "loot_table": "chest_basic"}
|
||||
assert obj2.id == "obj_0002"
|
||||
assert obj2.dynamic is True
|
||||
assert obj2.flags.violence == 1
|
||||
assert obj2.flags.sources == ["asset:props/chest"]
|
||||
assert decoded.flags.violence == 1
|
||||
assert decoded.flags.sources == ["asset:props/chest"]
|
||||
|
||||
def test_empty_chunk_has_no_layers_or_objects(self):
|
||||
decoded = cc.decode_binary(_read_fixture("empty_chunk.bin"))
|
||||
assert decoded.tile_layers == []
|
||||
assert decoded.objects == []
|
||||
|
||||
|
||||
class TestChecksum:
|
||||
def test_checksum_is_sha256_hex(self):
|
||||
import hashlib
|
||||
assert cc.checksum(b"") == hashlib.sha256(b"").hexdigest()
|
||||
assert len(cc.checksum(b"")) == 64
|
||||
|
||||
def test_header_checksum_matches_recomputed_for_valid_fixture(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
header_checksum_hex = data[16:48].hex()
|
||||
body = data[cc.HEADER_SIZE:]
|
||||
assert header_checksum_hex == cc.checksum(body)
|
||||
|
||||
|
||||
class TestBuildKey:
|
||||
def test_matches_world_persistence_impl_4_5_format(self):
|
||||
assert cc.build_key(12, "main", 3, -1, 0) == "12:main:3:-1:0"
|
||||
assert cc.build_key("12", "main", 3, -1, 0) == "12:main:3:-1:0"
|
||||
|
||||
|
||||
class TestFloat32Fidelity:
|
||||
"""This is the concrete regression test for the cross-codec divergence
|
||||
documented at the bottom of chunk_codec.py: this module must encode
|
||||
real IEEE-754 float32, including fractional and negative values,
|
||||
round-tripping exactly (up to float32 precision) -- unlike
|
||||
scripts/chunk_data_decoder.gd's current int-truncating implementation."""
|
||||
|
||||
def test_fractional_and_negative_floats_survive_round_trip(self):
|
||||
payload = cc.ChunkPayload(
|
||||
world_id="1", room_id="main", coord=(0, 0, 0),
|
||||
bounds=cc.ChunkBounds(min=(-17.25, 0.125, -3.0), size=(32.0, 32.0, 1.0)),
|
||||
objects=[cc.WorldObject(
|
||||
id="o1", type="prop", asset_id="a",
|
||||
pos=(1.5, -2.75, 0.001), rot=(0.0, 0.7071068, 0.0, 0.7071068),
|
||||
scale=(1.0, 1.0, 1.0),
|
||||
)],
|
||||
)
|
||||
encoded = cc.encode_binary(payload)
|
||||
decoded = cc.decode_binary(encoded)
|
||||
assert decoded.bounds.min == pytest.approx((-17.25, 0.125, -3.0), abs=1e-6)
|
||||
assert decoded.objects[0].pos == pytest.approx((1.5, -2.75, 0.001), abs=1e-6)
|
||||
assert decoded.objects[0].rot == pytest.approx(
|
||||
(0.0, 0.7071068, 0.0, 0.7071068), abs=1e-6
|
||||
)
|
||||
|
||||
|
||||
class TestMalformedInput:
|
||||
def test_bad_magic_raises_codec_error(self):
|
||||
data = bytearray(_read_fixture("empty_chunk.bin"))
|
||||
data[0:4] = b"XXXX"
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(bytes(data))
|
||||
|
||||
def test_too_short_raises_codec_error(self):
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(b"AGPC\x01\x00")
|
||||
|
||||
def test_unsupported_version_raises_codec_error(self):
|
||||
data = bytearray(_read_fixture("empty_chunk.bin"))
|
||||
data[4:6] = (99).to_bytes(2, "little")
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(bytes(data))
|
||||
|
||||
def test_payload_len_mismatch_raises_codec_error(self):
|
||||
data = bytearray(_read_fixture("empty_chunk.bin"))
|
||||
# Corrupt the declared payload_len (offset 12..16).
|
||||
data[12:16] = (99999).to_bytes(4, "little")
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(bytes(data))
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""decode_and_validate: the server-side "don't trust the client"
|
||||
boundary (world_persistence_impl.md §5.5)."""
|
||||
|
||||
def _valid_args(self):
|
||||
return {
|
||||
"expected_key": "12:main:3:-1:0",
|
||||
"grid_size": (4, 4, 1),
|
||||
"max_objects": 512,
|
||||
}
|
||||
|
||||
def test_valid_payload_passes(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
payload = cc.decode_and_validate(data, **self._valid_args())
|
||||
assert payload.coord == (3, -1, 0)
|
||||
|
||||
def test_checksum_mismatch_detected(self):
|
||||
data = bytearray(_read_fixture("simple_tile_chunk.bin"))
|
||||
# Corrupt one byte of the header's advisory checksum field
|
||||
# (offset 16..48) only -- magic/version/payload_len and the whole
|
||||
# payload body stay untouched, so this exercises exactly the
|
||||
# "recomputed checksum disagrees with the header" path rather than
|
||||
# also breaking structural decoding.
|
||||
data[16] ^= 0xFF
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(bytes(data), **self._valid_args())
|
||||
assert exc_info.value.reason == "checksum_mismatch"
|
||||
|
||||
def test_chunk_key_mismatch_detected(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
args = self._valid_args()
|
||||
args["expected_key"] = "12:main:99:99:0" # doesn't match the payload's own coord
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(data, **args)
|
||||
assert exc_info.value.reason == "chunk_key_mismatch"
|
||||
|
||||
def test_layer_dims_mismatch_detected(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
args = self._valid_args()
|
||||
args["grid_size"] = (8, 8, 1) # fixture's layer is 4x4x1
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(data, **args)
|
||||
assert exc_info.value.reason == "layer_dims"
|
||||
|
||||
def test_too_many_objects_detected(self):
|
||||
data = _read_fixture("chunk_with_objects.bin") # has 2 objects
|
||||
args = self._valid_args()
|
||||
args["expected_key"] = "12:main:3:-4:0"
|
||||
args["grid_size"] = (2, 2, 1)
|
||||
args["max_objects"] = 1
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(data, **args)
|
||||
assert exc_info.value.reason == "too_many_objects"
|
||||
|
||||
def test_object_out_of_bounds_detected(self):
|
||||
payload = cc.ChunkPayload(
|
||||
world_id="1", room_id="main", coord=(0, 0, 0),
|
||||
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
objects=[cc.WorldObject(id="o1", type="prop", asset_id="a", pos=(9999.0, 0.0, 0.0))],
|
||||
)
|
||||
data = cc.encode_binary(payload)
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(
|
||||
data, expected_key="1:main:0:0:0", grid_size=(32, 32, 1), max_objects=10
|
||||
)
|
||||
assert exc_info.value.reason == "object_out_of_bounds"
|
||||
|
||||
def test_tile_out_of_palette_detected(self):
|
||||
layer = cc.TileLayer(
|
||||
name="ground", width=2, height=2, depth=1,
|
||||
palette=[cc.TileDef(id=1)], # tile 5 below isn't in this palette
|
||||
tiles=[1, 5, 0, 1],
|
||||
)
|
||||
payload = cc.ChunkPayload(world_id="1", room_id="main", coord=(0, 0, 0), tile_layers=[layer])
|
||||
data = cc.encode_binary(payload)
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(
|
||||
data, expected_key="1:main:0:0:0", grid_size=(2, 2, 1), max_objects=10
|
||||
)
|
||||
assert exc_info.value.reason == "tile_out_of_palette"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Unit tests for the game_servers.py changes made for world persistence
|
||||
(world_persistence_impl.md §7.3/§7.4): per-spawn token validation, and the
|
||||
``_await_ready`` replacement for the old, fail-open ``_await_port``.
|
||||
|
||||
No real Godot process is spawned here (that stays a manual/checkpoint
|
||||
integration test, per test_world_join.py's docstring) -- these exercise
|
||||
``GameServerSupervisor`` against a fake process object standing in for
|
||||
``subprocess.Popen``.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.services.game_servers import GameServerSupervisor, _WorldServer
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
"""Stands in for subprocess.Popen: only .poll() is needed by the code
|
||||
paths under test here."""
|
||||
|
||||
def __init__(self, exited: bool = False):
|
||||
self._exited = exited
|
||||
|
||||
def poll(self):
|
||||
return 0 if self._exited else None
|
||||
|
||||
def terminate(self):
|
||||
self._exited = True
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self._exited = True
|
||||
|
||||
|
||||
def _make_srv(tmp_path, token="secret", alive=True) -> _WorldServer:
|
||||
status_path = tmp_path / "status.json"
|
||||
return _WorldServer(
|
||||
world_id="7", port=27000, proc=_FakeProc(exited=not alive),
|
||||
status_path=status_path, token=token,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateToken:
|
||||
def test_unknown_world_is_false(self):
|
||||
sup = GameServerSupervisor()
|
||||
assert sup.validate_token("no-such-world", "anything") is False
|
||||
|
||||
def test_correct_token_is_true(self, tmp_path):
|
||||
sup = GameServerSupervisor()
|
||||
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
||||
assert sup.validate_token("7", "secret") is True
|
||||
# world_id is looked up as str(world_id), so an int works too --
|
||||
# this matters because the FastAPI path param is an int.
|
||||
assert sup.validate_token(7, "secret") is True
|
||||
|
||||
def test_wrong_token_is_false(self, tmp_path):
|
||||
sup = GameServerSupervisor()
|
||||
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
||||
assert sup.validate_token("7", "wrong") is False
|
||||
|
||||
def test_token_scoped_to_its_own_world_only(self, tmp_path):
|
||||
sup = GameServerSupervisor()
|
||||
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
||||
# Same token, different world_id -- must not authenticate (§7.3:
|
||||
# "a path whose world_id differs from the token's world is 403").
|
||||
assert sup.validate_token("8", "secret") is False
|
||||
|
||||
def test_dead_server_token_is_false(self, tmp_path):
|
||||
sup = GameServerSupervisor()
|
||||
sup._servers["7"] = _make_srv(tmp_path, token="secret", alive=False)
|
||||
assert sup.validate_token("7", "secret") is False
|
||||
|
||||
def test_reaping_invalidates_the_token(self, tmp_path):
|
||||
"""§7.3: "dies with its process" -- once removed from _servers
|
||||
(what the reaper does), the same token no longer validates."""
|
||||
sup = GameServerSupervisor()
|
||||
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
||||
assert sup.validate_token("7", "secret") is True
|
||||
del sup._servers["7"]
|
||||
assert sup.validate_token("7", "secret") is False
|
||||
|
||||
|
||||
class TestAwaitReady:
|
||||
"""Replaces the old _await_port, which (a) treated a bound UDP port as
|
||||
readiness even though content loading happens before create_server, and
|
||||
(b) failed open past its deadline with no error at all."""
|
||||
|
||||
async def test_raises_timeout_error_when_never_ready(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
|
||||
sup = GameServerSupervisor()
|
||||
srv = _make_srv(tmp_path) # status file never written
|
||||
with pytest.raises(TimeoutError):
|
||||
await sup._await_ready(srv)
|
||||
|
||||
async def test_returns_once_status_file_reports_ready(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 5.0)
|
||||
sup = GameServerSupervisor()
|
||||
srv = _make_srv(tmp_path)
|
||||
srv.status_path.write_text(json.dumps({"ready": True, "players": 0}))
|
||||
await sup._await_ready(srv) # must not raise / must not hang
|
||||
|
||||
async def test_ready_false_is_not_ready(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
|
||||
sup = GameServerSupervisor()
|
||||
srv = _make_srv(tmp_path)
|
||||
srv.status_path.write_text(json.dumps({"ready": False, "players": 0}))
|
||||
with pytest.raises(TimeoutError):
|
||||
await sup._await_ready(srv)
|
||||
|
||||
async def test_raises_promptly_if_process_already_exited(self, tmp_path, monkeypatch):
|
||||
# A long deadline, but the process is already dead -- must not wait
|
||||
# out the full deadline (this is the "no crash restart mid-wait"
|
||||
# case: don't silently poll a corpse for 20s).
|
||||
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 20.0)
|
||||
sup = GameServerSupervisor()
|
||||
srv = _make_srv(tmp_path, alive=False)
|
||||
with pytest.raises(TimeoutError):
|
||||
await sup._await_ready(srv)
|
||||
|
||||
|
||||
class TestSpawnCommandLine:
|
||||
def test_spawn_includes_api_url_and_server_token(self, monkeypatch, tmp_path):
|
||||
"""world_persistence_impl.md §7.2 step 1 / §7.4: `_spawn` must pass
|
||||
--api-url and --server-token on the command line."""
|
||||
sup = GameServerSupervisor()
|
||||
monkeypatch.setattr(sup, "_free_port", lambda: 27050)
|
||||
|
||||
captured = {}
|
||||
|
||||
class _Proc:
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def _fake_popen(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
return _Proc()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.game_servers.subprocess.Popen", _fake_popen
|
||||
)
|
||||
srv = sup._spawn("42")
|
||||
|
||||
cmd = captured["cmd"]
|
||||
assert "--api-url" in cmd
|
||||
assert cmd[cmd.index("--api-url") + 1] == settings.GAME_SERVER_INTERNAL_API_URL
|
||||
assert "--server-token" in cmd
|
||||
token_in_cmd = cmd[cmd.index("--server-token") + 1]
|
||||
assert token_in_cmd == srv.token
|
||||
assert len(srv.token) > 20 # secrets.token_urlsafe(32) -- not a short/guessable value
|
||||
@@ -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]")
|
||||
@@ -1,193 +1,56 @@
|
||||
"""Tests for the Redis-backed token-bucket rate limiter.
|
||||
|
||||
These tests use fakeredis (Lua enabled via lupa) so no live Redis server is
|
||||
required.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
"""The auth endpoints are rate-limited per client IP."""
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, "/workspace")
|
||||
|
||||
import fakeredis.aioredis
|
||||
from redis.asyncio import Redis
|
||||
from redis.exceptions import ConnectionError
|
||||
|
||||
from app.rate_limit import (
|
||||
RateLimit,
|
||||
RateLimitConfig,
|
||||
RateLimiter,
|
||||
default_user_scope,
|
||||
named_limit,
|
||||
rate_limit,
|
||||
)
|
||||
from app import rate_limit
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_redis():
|
||||
return fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_limiter():
|
||||
# This module tests the limiter itself: force it on (the checkpoint
|
||||
# runner disables it suite-wide via RATE_LIMIT_ENABLED) and start each
|
||||
# test from an empty window.
|
||||
prev = settings.RATE_LIMIT_ENABLED
|
||||
settings.RATE_LIMIT_ENABLED = True
|
||||
rate_limit.reset_all()
|
||||
yield
|
||||
rate_limit.reset_all()
|
||||
settings.RATE_LIMIT_ENABLED = prev
|
||||
|
||||
|
||||
def make_request(client_ip: str = "127.0.0.1") -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"client": (client_ip, 12345),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_bucket_allows_burst_then_exhausts(fake_redis):
|
||||
limiter = RateLimiter(
|
||||
RateLimitConfig(
|
||||
limit=RateLimit(name="burst", capacity=3, refill_rate=0.1),
|
||||
),
|
||||
redis=fake_redis,
|
||||
)
|
||||
request = make_request()
|
||||
|
||||
results = []
|
||||
for _ in range(4):
|
||||
result = await limiter.check(request, user_id=42)
|
||||
results.append(result)
|
||||
|
||||
assert [r.allowed for r in results] == [True, True, True, False]
|
||||
assert results[0].remaining == 2
|
||||
assert results[1].remaining == 1
|
||||
assert results[2].remaining == 0
|
||||
assert results[3].retry_after > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_after_reflects_refill_rate(fake_redis):
|
||||
limiter = RateLimiter(
|
||||
RateLimitConfig(
|
||||
limit=RateLimit(name="slow", capacity=1, refill_rate=0.5),
|
||||
),
|
||||
redis=fake_redis,
|
||||
)
|
||||
request = make_request()
|
||||
|
||||
assert (await limiter.check(request, user_id=7)).allowed is True
|
||||
denied = await limiter.check(request, user_id=7)
|
||||
assert denied.allowed is False
|
||||
# 1 token / 0.5 tokens per second = 2 seconds.
|
||||
assert denied.retry_after == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_separate_scopes_get_separate_buckets(fake_redis):
|
||||
limiter = RateLimiter(
|
||||
RateLimitConfig(
|
||||
limit=RateLimit(name="scoped", capacity=1, refill_rate=0.1),
|
||||
),
|
||||
redis=fake_redis,
|
||||
)
|
||||
request = make_request()
|
||||
|
||||
assert (await limiter.check(request, user_id=1)).allowed is True
|
||||
assert (await limiter.check(request, user_id=2)).allowed is True
|
||||
assert (await limiter.check(request, user_id=1)).allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_open_returns_allowed_when_redis_unavailable():
|
||||
class BrokenRedis(Redis):
|
||||
async def eval(self, *args, **kwargs):
|
||||
raise ConnectionError("down")
|
||||
|
||||
limiter = RateLimiter(
|
||||
RateLimitConfig(
|
||||
limit=RateLimit(name="open", capacity=1, refill_rate=0.1, fail_open=True),
|
||||
),
|
||||
redis=BrokenRedis(),
|
||||
)
|
||||
result = await limiter.check(make_request(), user_id=1)
|
||||
assert result.allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_closed_returns_denied_with_negative_retry_after():
|
||||
class BrokenRedis(Redis):
|
||||
async def eval(self, *args, **kwargs):
|
||||
raise ConnectionError("down")
|
||||
|
||||
limiter = RateLimiter(
|
||||
RateLimitConfig(
|
||||
limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False),
|
||||
),
|
||||
redis=BrokenRedis(),
|
||||
)
|
||||
result = await limiter.check(make_request(), user_id=1)
|
||||
assert result.allowed is False
|
||||
assert result.retry_after == -1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_closed_call_raises_503(fake_redis):
|
||||
class BrokenRedis(Redis):
|
||||
async def eval(self, *args, **kwargs):
|
||||
raise ConnectionError("down")
|
||||
|
||||
limiter = RateLimiter(
|
||||
RateLimitConfig(
|
||||
limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False),
|
||||
),
|
||||
redis=BrokenRedis(),
|
||||
)
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await limiter(make_request(), user_id=1)
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
|
||||
def test_named_limit_builds_expected_config():
|
||||
config = named_limit(
|
||||
"chat_send",
|
||||
capacity=5,
|
||||
refill_rate=0.2,
|
||||
fail_open=False,
|
||||
)
|
||||
assert config.limit.name == "chat_send"
|
||||
assert config.limit.capacity == 5
|
||||
assert config.limit.refill_rate == 0.2
|
||||
assert config.limit.fail_open is False
|
||||
assert config.scope is default_user_scope
|
||||
|
||||
|
||||
def test_invalid_limit_definition_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
named_limit("bad", capacity=0, refill_rate=1.0)
|
||||
with pytest.raises(ValueError):
|
||||
named_limit("bad", capacity=1, refill_rate=0)
|
||||
|
||||
|
||||
def test_dependency_integration_returns_429_with_retry_after():
|
||||
async def fake_user_id() -> int:
|
||||
return 99
|
||||
|
||||
chat_limit = rate_limit(
|
||||
named_limit("chat_send_test", capacity=2, refill_rate=0.1, fail_open=False),
|
||||
user_id_dependency=Depends(fake_user_id),
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/probe")
|
||||
async def probe(_: None = Depends(chat_limit)):
|
||||
return {"ok": True}
|
||||
|
||||
def test_login_is_limited_after_the_threshold():
|
||||
client = TestClient(app)
|
||||
assert client.get("/probe").status_code == 200
|
||||
assert client.get("/probe").status_code == 200
|
||||
codes = []
|
||||
for i in range(15):
|
||||
r = client.post("/api/auth/login", data={"username": f"nobody{i}", "password": "x"})
|
||||
codes.append(r.status_code)
|
||||
# First 10 get through to the (failing) auth check; the rest are 429.
|
||||
assert codes[:10] == [401] * 10
|
||||
assert 429 in codes[10:]
|
||||
assert codes[-1] == 429
|
||||
|
||||
denied = client.get("/probe")
|
||||
assert denied.status_code == 429
|
||||
assert denied.headers["retry-after"]
|
||||
assert denied.json() == {"detail": "Rate limit exceeded"}
|
||||
|
||||
def test_register_shares_the_same_bucket():
|
||||
client = TestClient(app)
|
||||
# Burn the budget on login...
|
||||
for i in range(10):
|
||||
client.post("/api/auth/login", data={"username": f"x{i}", "password": "y"})
|
||||
# ...register from the same IP is now blocked too.
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": "rl_user", "email": "rl@e.com",
|
||||
"password": "secret123", "date_of_birth": "1990-01-01",
|
||||
})
|
||||
assert r.status_code == 429
|
||||
assert "Retry-After" in r.headers
|
||||
|
||||
|
||||
def test_limiter_window_is_per_key():
|
||||
limiter = rate_limit.SlidingWindowLimiter(max_hits=2, window_seconds=60)
|
||||
limiter.check("a")
|
||||
limiter.check("a")
|
||||
with pytest.raises(Exception):
|
||||
limiter.check("a")
|
||||
limiter.check("b") # different key, unaffected
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the POST /api/auth/refresh endpoint.
|
||||
|
||||
These tests exercise the full stack: register → get refresh token →
|
||||
refresh → new access token. Requires migrations already applied
|
||||
(alembic upgrade head) before running.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db():
|
||||
"""Override get_db so the app uses a fresh session per test."""
|
||||
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async_session = test_session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
await async_session.commit()
|
||||
except Exception:
|
||||
await async_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refresh endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRefresh:
|
||||
"""Tests for POST /api/auth/refresh."""
|
||||
|
||||
def _register_user(self, username: str, password: str, email: str = None):
|
||||
"""Helper to register a user and return the refresh token."""
|
||||
client = TestClient(app)
|
||||
if email is None:
|
||||
email = f"{username}@example.com"
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["refresh_token"]
|
||||
|
||||
def test_refresh_success(self):
|
||||
"""A valid refresh token returns a new access token."""
|
||||
refresh_token = self._register_user("refep_refreshuser", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
# The refresh token in the response is the same one we sent
|
||||
assert data["refresh_token"] == refresh_token
|
||||
|
||||
def test_refresh_returns_different_access_token(self):
|
||||
"""Refreshing produces a new access token (different from any previous)."""
|
||||
refresh_token = self._register_user("refep_refreshtoken", "secret123")
|
||||
client = TestClient(app)
|
||||
|
||||
# First refresh
|
||||
resp1 = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
access_token_1 = resp1.json()["access_token"]
|
||||
|
||||
# Second refresh
|
||||
resp2 = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
access_token_2 = resp2.json()["access_token"]
|
||||
|
||||
# Each refresh should produce a new access token
|
||||
assert access_token_1 != access_token_2
|
||||
|
||||
def test_refresh_invalid_token(self):
|
||||
"""An invalid (garbage) refresh token returns 401."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": "not.a.jwt.token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid or expired refresh token" in resp.json()["detail"]
|
||||
|
||||
def test_refresh_empty_body(self):
|
||||
"""Missing refresh_token field returns 422 validation error."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_refresh_missing_field(self):
|
||||
"""No body at all returns 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
content="{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_refresh_with_login_token(self):
|
||||
"""A refresh token obtained from login also works for /refresh."""
|
||||
client = TestClient(app)
|
||||
# Register first
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "refep_loginrefuser",
|
||||
"email": "loginref@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
# Login to get a refresh token
|
||||
login_resp = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "refep_loginrefuser", "password": "secret123"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
refresh_token = login_resp.json()["refresh_token"]
|
||||
|
||||
# Use it to refresh
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.json()
|
||||
|
||||
def test_refresh_access_token_is_valid_jwt(self):
|
||||
"""The access token returned by /refresh is a valid JWT."""
|
||||
from jose import jwt
|
||||
|
||||
refresh_token = self._register_user("refep_jwttoken", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
access_token = resp.json()["access_token"]
|
||||
|
||||
# Verify it's a 3-part JWT
|
||||
parts = access_token.split(".")
|
||||
assert len(parts) == 3
|
||||
|
||||
# Verify it decodes with the public key
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert "sub" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
def test_refresh_expires_minutes_in_response(self):
|
||||
"""The response includes access_token_expires_minutes."""
|
||||
refresh_token = self._register_user("refep_expiretest", "secret123")
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access_token_expires_minutes" in resp.json()
|
||||
assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for refresh token uniqueness (jti claim) and model column length."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.routers.auth import _create_refresh_token
|
||||
from app.models.session import RefreshToken
|
||||
|
||||
|
||||
class TestRefreshTokenJtiUniqueness:
|
||||
"""Verify that _create_refresh_token produces unique tokens even when
|
||||
called with identical data in rapid succession (issue #1 from review)."""
|
||||
|
||||
def test_two_calls_produce_different_tokens(self):
|
||||
"""Two calls with the same sub produce different JWTs because of jti."""
|
||||
token_a = _create_refresh_token({"sub": "42"})
|
||||
token_b = _create_refresh_token({"sub": "42"})
|
||||
assert token_a != token_b
|
||||
|
||||
def test_jti_is_valid_uuid(self):
|
||||
"""The jti claim inside the refresh token is a valid UUID."""
|
||||
token = _create_refresh_token({"sub": "1"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert "jti" in payload
|
||||
# Verify it parses as a UUID (raises ValueError if not)
|
||||
uuid.UUID(payload["jti"])
|
||||
|
||||
def test_each_token_has_unique_jti(self):
|
||||
"""Generate 100 tokens for the same user — all jtis must differ."""
|
||||
jtis = set()
|
||||
for _ in range(100):
|
||||
token = _create_refresh_token({"sub": "999"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
jtis.add(payload["jti"])
|
||||
assert len(jtis) == 100
|
||||
|
||||
def test_jti_survives_decode(self):
|
||||
"""The jti claim is present and correct after decode."""
|
||||
token = _create_refresh_token({"sub": "7"})
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_pem,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
assert payload["sub"] == "7"
|
||||
assert "jti" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
class TestRefreshTokenModelColumnLength:
|
||||
"""Verify the model's token column length matches the migration (issue #2)."""
|
||||
|
||||
def test_token_column_has_length_512(self):
|
||||
"""RefreshToken.token column should be String(512), not unbounded."""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# Get the column definition from the model's table
|
||||
column = RefreshToken.__table__.columns["token"]
|
||||
# The type should be String with length 512
|
||||
assert column.type.length == 512
|
||||
|
||||
def test_token_column_type_is_string(self):
|
||||
"""The token column should be a String type."""
|
||||
from sqlalchemy import String as SAString
|
||||
|
||||
column = RefreshToken.__table__.columns["token"]
|
||||
assert isinstance(column.type, SAString)
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Integration tests for the world content API: rooms + chunks.
|
||||
|
||||
Covers docs/architecture/world_persistence_impl.md §8.1 (public router,
|
||||
app/routers/world_content.py) and §8.3 (internal router,
|
||||
app/routers/internal.py), plus the create_world -> auto "main" room
|
||||
behaviour (§4.1).
|
||||
|
||||
Runs against the real ephemeral Postgres the hermetic conftest.py stands
|
||||
up -- these are full HTTP round trips through TestClient, not mocked.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services import chunk_codec as cc
|
||||
from app.services.game_servers import supervisor
|
||||
|
||||
GRID_32 = (32, 32, 1)
|
||||
|
||||
|
||||
def _auth(client: TestClient, name: str) -> tuple[dict, int]:
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": name, "email": f"{name}@e.com", "password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
return headers, me.json()["id"]
|
||||
|
||||
|
||||
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
|
||||
r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _build_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, *,
|
||||
grid=GRID_32, revision: int = 1, extra_tag: str = "") -> bytes:
|
||||
"""A small but structurally-real chunk payload for `world_id`/`slug`/coord,
|
||||
sized to match the room's default 32x32x1 grid."""
|
||||
w, h, d = grid
|
||||
layer = cc.TileLayer(
|
||||
name="ground", layer_id=0, width=w, height=h, depth=d,
|
||||
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
|
||||
tiles=[0] * (w * h * d),
|
||||
)
|
||||
obj = cc.WorldObject(
|
||||
id=f"obj_{extra_tag or '1'}", type="prop", asset_id="props/rock",
|
||||
pos=(1.0, 1.0, 0.0), properties={"tag": extra_tag},
|
||||
)
|
||||
payload = cc.ChunkPayload(
|
||||
world_id=str(world_id), room_id=slug, coord=(cx, cy, cz),
|
||||
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(float(w), float(h), float(d))),
|
||||
tile_layers=[layer], objects=[obj], revision=revision, author="pytest",
|
||||
created_at=1_700_000_000, updated_at=1_700_000_000,
|
||||
)
|
||||
return cc.encode_binary(payload)
|
||||
|
||||
|
||||
class TestCreateWorldMainRoom:
|
||||
def test_create_world_auto_creates_main_room(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "cw_main")
|
||||
wid = _make_world(client, headers)
|
||||
|
||||
r = client.get(f"/api/worlds/{wid}/rooms", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
rooms = r.json()
|
||||
assert len(rooms) == 1
|
||||
assert rooms[0]["slug"] == "main"
|
||||
assert rooms[0]["name"] == "Main"
|
||||
assert rooms[0]["chunk_count"] == 0
|
||||
assert rooms[0]["grid"]["size"] == [32, 32, 1]
|
||||
|
||||
|
||||
class TestRoomAuth:
|
||||
def test_create_room_requires_owner(self):
|
||||
client = TestClient(app)
|
||||
owner_headers, _ = _auth(client, "room_owner")
|
||||
wid = _make_world(client, owner_headers)
|
||||
other_headers, _ = _auth(client, "room_other")
|
||||
r = client.post(f"/api/worlds/{wid}/rooms", headers=other_headers,
|
||||
json={"slug": "annex", "name": "Annex"})
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_list_rooms_of_private_world_is_404_for_non_owner(self):
|
||||
client = TestClient(app)
|
||||
owner_headers, _ = _auth(client, "room_priv_owner")
|
||||
wid = _make_world(client, owner_headers, "private")
|
||||
other_headers, _ = _auth(client, "room_priv_other")
|
||||
r = client.get(f"/api/worlds/{wid}/rooms", headers=other_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestChunkPutGetRoundTrip:
|
||||
def test_binary_put_then_binary_get_round_trips(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_rt")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0)
|
||||
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["revision"] == 1
|
||||
assert body["payload_len"] == len(payload)
|
||||
assert body["checksum"] == cc.checksum(payload[cc.HEADER_SIZE:])
|
||||
assert body["live_session"] is False
|
||||
|
||||
g = client.get(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Accept": "application/octet-stream"},
|
||||
)
|
||||
assert g.status_code == 200
|
||||
assert g.content == payload
|
||||
assert g.headers["ETag"] == f'"{body["checksum"]}"'
|
||||
assert g.headers["X-Chunk-Revision"] == "1"
|
||||
|
||||
def test_json_accept_returns_decoded_shape(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_json")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 1, 1, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
g = client.get(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
|
||||
headers={**headers, "Accept": "application/json"},
|
||||
)
|
||||
assert g.status_code == 200
|
||||
data = g.json()
|
||||
assert data["coord"] == {"cx": 1, "cy": 1, "cz": 0}
|
||||
assert data["world_id"] == str(wid)
|
||||
assert data["room_id"] == "main"
|
||||
assert len(data["objects"]) == 1
|
||||
|
||||
def test_json_put_with_base64_payload(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_json_put")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 2, 2, 0)
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/2/2/0", headers=headers,
|
||||
json={"base_revision": 0, "payload_b64": base64.b64encode(payload).decode()},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["revision"] == 1
|
||||
|
||||
def test_if_none_match_returns_304(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_304")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 3, 3, 0)
|
||||
put = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
checksum = put.json()["checksum"]
|
||||
g = client.get(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
|
||||
headers={**headers, "If-None-Match": f'"{checksum}"'},
|
||||
)
|
||||
assert g.status_code == 304
|
||||
|
||||
def test_unwritten_coordinate_is_404(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_404")
|
||||
wid = _make_world(client, headers)
|
||||
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/9/9/0", headers=headers)
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
class TestRevisionConflict:
|
||||
def test_create_conflict_when_chunk_already_exists(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "conflict_create")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 4, 4, 0)
|
||||
first = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
second = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert second.status_code == 409
|
||||
body = second.json()
|
||||
assert body["detail"] == "revision conflict"
|
||||
assert body["current_revision"] == 1
|
||||
|
||||
def test_stale_base_revision_conflict_reports_current_state(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "conflict_stale")
|
||||
wid = _make_world(client, headers)
|
||||
payload_v1 = _build_chunk(wid, "main", 5, 5, 0, revision=1)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload_v1,
|
||||
)
|
||||
payload_v2 = _build_chunk(wid, "main", 5, 5, 0, revision=2, extra_tag="v2")
|
||||
r2 = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
|
||||
content=payload_v2,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["revision"] == 2
|
||||
|
||||
# Now retry with the now-stale base_revision=1 again.
|
||||
payload_v3 = _build_chunk(wid, "main", 5, 5, 0, revision=3, extra_tag="v3")
|
||||
stale = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
|
||||
content=payload_v3,
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
body = stale.json()
|
||||
assert body["current_revision"] == 2
|
||||
assert body["current_checksum"] == r2.json()["checksum"]
|
||||
|
||||
|
||||
class TestBatchWrite:
|
||||
def test_batch_put_all_succeed(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "batch_ok")
|
||||
wid = _make_world(client, headers)
|
||||
chunks = [
|
||||
{"cx": cx, "cy": 0, "cz": 0, "base_revision": 0,
|
||||
"payload_b64": base64.b64encode(_build_chunk(wid, "main", cx, 0, 0)).decode()}
|
||||
for cx in (10, 11)
|
||||
]
|
||||
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert len(body["chunks"]) == 2
|
||||
assert all(c["revision"] == 1 for c in body["chunks"])
|
||||
|
||||
idx = client.get(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers)
|
||||
keys = {c["key"] for c in idx.json()["chunks"]}
|
||||
assert f"{wid}:main:10:0:0" in keys
|
||||
assert f"{wid}:main:11:0:0" in keys
|
||||
|
||||
def test_batch_put_is_all_or_nothing_on_conflict(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "batch_conflict")
|
||||
wid = _make_world(client, headers)
|
||||
|
||||
# Pre-create one of the two coordinates so the batch's base_revision=0
|
||||
# for it will conflict.
|
||||
pre = _build_chunk(wid, "main", 20, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/20/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=pre,
|
||||
)
|
||||
|
||||
chunks = [
|
||||
{"cx": 21, "cy": 0, "cz": 0, "base_revision": 0,
|
||||
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 21, 0, 0)).decode()},
|
||||
{"cx": 20, "cy": 0, "cz": 0, "base_revision": 0, # conflicts: already exists at revision 1
|
||||
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 20, 0, 0, extra_tag="v2")).decode()},
|
||||
]
|
||||
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
|
||||
assert r.status_code == 409
|
||||
|
||||
# Nothing from this batch should have been persisted -- (21,0,0)
|
||||
# must NOT exist even though it was valid and came first.
|
||||
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/21/0/0", headers=headers)
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_then_get_is_404(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_delete")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 30, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/30/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
d = client.delete(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
|
||||
assert d.status_code == 204
|
||||
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
class TestRevisionsAndRollback:
|
||||
def test_revisions_then_rollback(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_rollback")
|
||||
wid = _make_world(client, headers)
|
||||
v1 = _build_chunk(wid, "main", 40, 0, 0, revision=1)
|
||||
r1 = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=v1,
|
||||
)
|
||||
v2 = _build_chunk(wid, "main", 40, 0, 0, revision=2, extra_tag="v2")
|
||||
r2 = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
|
||||
content=v2,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
revs = client.get(f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/revisions", headers=headers)
|
||||
assert revs.status_code == 200
|
||||
rev_numbers = [row["revision"] for row in revs.json()]
|
||||
assert 1 in rev_numbers and 2 in rev_numbers
|
||||
|
||||
rb = client.post(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/rollback",
|
||||
headers=headers, json={"revision": 1},
|
||||
)
|
||||
assert rb.status_code == 200, rb.text
|
||||
assert rb.json()["revision"] == 3 # a new revision, restoring v1's content
|
||||
assert rb.json()["checksum"] == r1.json()["checksum"]
|
||||
|
||||
def test_revisions_requires_owner(self):
|
||||
client = TestClient(app)
|
||||
owner_headers, _ = _auth(client, "revisions_owner")
|
||||
wid = _make_world(client, owner_headers)
|
||||
payload = _build_chunk(wid, "main", 41, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/41/0/0",
|
||||
headers={**owner_headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
other_headers, _ = _auth(client, "revisions_other")
|
||||
r = client.get(f"/api/worlds/{wid}/rooms/main/chunks/41/0/0/revisions", headers=other_headers)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestValidationErrors:
|
||||
def test_malformed_payload_is_422(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_malformed")
|
||||
wid = _make_world(client, headers)
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=b"not a real chunk",
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["reason"] == "malformed"
|
||||
|
||||
def test_wrong_grid_size_is_422_layer_dims(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_layerdims")
|
||||
wid = _make_world(client, headers)
|
||||
# Room's default grid is 32x32x1; build a chunk sized for 4x4x1.
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0, grid=(4, 4, 1))
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["reason"] == "layer_dims"
|
||||
|
||||
def test_oversized_payload_is_413(self, monkeypatch):
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "WORLD_CHUNK_MAX_PAYLOAD_BYTES", 10)
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_413")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0)
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
class TestInternalApi:
|
||||
"""docs/architecture/world_persistence_impl.md §7/§8.3. No real Godot
|
||||
process is spawned in this test env (GAME_SERVERS_ENABLED=False); we
|
||||
register a fake in-memory server entry with a known token to exercise
|
||||
require_game_server without needing a live process."""
|
||||
|
||||
@pytest.fixture
|
||||
def fake_server_token(self):
|
||||
# NB: a class body can't read a same-named local from the
|
||||
# enclosing function via `token = token` (class bodies use LOAD_NAME,
|
||||
# which skips enclosing function scopes) -- use a literal instead.
|
||||
world_id_str = "internal-test-world"
|
||||
|
||||
class _FakeSrv:
|
||||
def alive(self_inner):
|
||||
return True
|
||||
token = "test-internal-token-12345"
|
||||
|
||||
supervisor._servers[world_id_str] = _FakeSrv()
|
||||
yield _FakeSrv.token
|
||||
supervisor._servers.pop(world_id_str, None)
|
||||
|
||||
def test_missing_token_is_401(self):
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/internal/worlds/1/manifest")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_wrong_token_is_403(self):
|
||||
client = TestClient(app)
|
||||
r = client.get(
|
||||
"/api/internal/worlds/1/manifest",
|
||||
headers={"Authorization": "Bearer not-the-real-token"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_valid_token_but_wrong_world_id_is_403(self, fake_server_token):
|
||||
# The fake token is registered under world_id "internal-test-world";
|
||||
# a request for a different world_id must not authenticate.
|
||||
client = TestClient(app)
|
||||
r = client.get(
|
||||
"/api/internal/worlds/999999/manifest",
|
||||
headers={"Authorization": f"Bearer {fake_server_token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_valid_token_reaches_manifest_for_real_world(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "internal_manifest")
|
||||
wid = _make_world(client, headers)
|
||||
|
||||
class _FakeSrv:
|
||||
def alive(self_inner):
|
||||
return True
|
||||
token = "another-real-token"
|
||||
|
||||
supervisor._servers[str(wid)] = _FakeSrv()
|
||||
try:
|
||||
r = client.get(
|
||||
f"/api/internal/worlds/{wid}/manifest",
|
||||
headers={"Authorization": "Bearer another-real-token"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["world_id"] == wid
|
||||
assert len(body["rooms"]) == 1
|
||||
assert body["rooms"][0]["slug"] == "main"
|
||||
finally:
|
||||
supervisor._servers.pop(str(wid), None)
|
||||
|
||||
def test_internal_chunk_fetch_returns_binary(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "internal_chunk")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
class _FakeSrv:
|
||||
def alive(self_inner):
|
||||
return True
|
||||
token = "internal-chunk-token"
|
||||
|
||||
supervisor._servers[str(wid)] = _FakeSrv()
|
||||
try:
|
||||
r = client.get(
|
||||
f"/api/internal/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={"Authorization": "Bearer internal-chunk-token"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.content == payload
|
||||
finally:
|
||||
supervisor._servers.pop(str(wid), None)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for POST /api/worlds/{id}/join.
|
||||
|
||||
The happy path spawns a real Godot process, so it's covered by a manual /
|
||||
checkpoint integration test, not here. These cover the envelope: auth,
|
||||
visibility, and the 503 when orchestration is disabled (the default).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _auth(client: TestClient, name: str) -> tuple[dict, int]:
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": name, "email": f"{name}@e.com", "password": "secret123", "date_of_birth": "1990-01-01",
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
return headers, me.json()["id"]
|
||||
|
||||
|
||||
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
|
||||
r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
class TestJoinWorld:
|
||||
def test_requires_auth(self):
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/worlds/1/join").status_code == 401
|
||||
|
||||
def test_unknown_world_is_404(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "join_404")
|
||||
assert client.post("/api/worlds/999999/join", headers=headers).status_code == 404
|
||||
|
||||
def test_private_world_of_another_user_is_404(self):
|
||||
client = TestClient(app)
|
||||
owner_headers, _ = _auth(client, "join_owner")
|
||||
wid = _make_world(client, owner_headers, "private")
|
||||
other_headers, _ = _auth(client, "join_other")
|
||||
assert client.post(f"/api/worlds/{wid}/join", headers=other_headers).status_code == 404
|
||||
|
||||
def test_disabled_orchestration_returns_503(self):
|
||||
# GAME_SERVERS_ENABLED defaults to False in tests.
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "join_503")
|
||||
wid = _make_world(client, headers, "public")
|
||||
r = client.post(f"/api/worlds/{wid}/join", headers=headers)
|
||||
assert r.status_code == 503
|
||||
assert "not enabled" in r.json()["detail"]
|
||||
@@ -449,6 +449,37 @@ The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
|
||||
|
||||
---
|
||||
|
||||
## 9a. Loadout Persistence (implemented, PC3 Phase 1)
|
||||
|
||||
**Storage.** One row per user in `avatar_loadouts` (`user_id` PK/FK,
|
||||
`data` JSONB, timestamps). The entire loadout is stored as a single JSON
|
||||
document in the exact shape the client's `AvatarLoadout.to_json()` emits
|
||||
(`{"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}`) rather than
|
||||
exploded into columns.
|
||||
|
||||
**Why one JSON blob, not columns.** The server has no copy of the asset
|
||||
catalog (`assets/avatars/catalog.json` ships with the client). It therefore
|
||||
cannot meaningfully validate *which* asset belongs in *which* slot, whether
|
||||
an asset exists, or slot-exclusivity rules — that validation stays on the
|
||||
client (`AvatarLoadout.validate()` against the loaded `AvatarCatalog`). The
|
||||
server enforces only the envelope: a JSON object with a non-empty string
|
||||
`base_body`, an object `slots`, and a sane size cap. Keeping the schema
|
||||
opaque to the server also means catalog/loadout-shape changes don't need a
|
||||
migration.
|
||||
|
||||
**Endpoints** (`/api/avatar`, bearer auth):
|
||||
|
||||
| Method | Path | Behaviour |
|
||||
|---|---|---|
|
||||
| `GET` | `/loadout` | The user's saved loadout, or the server default (`{"base_body": "base_human_male_01", "slots": {}}`, `is_default: true`) if they have never saved one. |
|
||||
| `PUT` | `/loadout` | Replace the user's loadout (full document, not a patch). |
|
||||
|
||||
**Defaults.** A brand-new account has no row; `GET` returns
|
||||
`models.avatar_loadout.DEFAULT_LOADOUT` with `is_default: true` so the client
|
||||
can distinguish "never customised" from "customised back to the base look".
|
||||
|
||||
---
|
||||
|
||||
## 10. Summary
|
||||
|
||||
| Concern | Decision |
|
||||
|
||||
@@ -227,18 +227,33 @@ An account has an **age verification status**:
|
||||
|
||||
### 5.2 Tier 1 — Self-Declaration Flow
|
||||
|
||||
> **Implemented, PC3 Phase 3.** `POST /api/auth/register` now takes a real
|
||||
> `date_of_birth` (ISO `YYYY-MM-DD`), not the throwaway `over_18` boolean it
|
||||
> had before. The server computes age (`auth.calculate_age`) and rejects
|
||||
> anyone under `auth.MIN_AGE` (18) with a **403** — this platform is
|
||||
> adults-only, so there is no "minor, restricted" tier here (the 13+ rows
|
||||
> below are the general design, not the current gate). A future-in-the-past
|
||||
> or nonsensical date is a **400**. The DOB is stored on the user row and
|
||||
> surfaced only back to that user (`/api/auth/me`), never to other players.
|
||||
>
|
||||
> **Limitation, stated plainly:** this is still self-attested — a determined
|
||||
> user can enter a false DOB. It is a concrete, stored, legally-meaningful
|
||||
> data point (matching how most platforms gate before adding hard checks)
|
||||
> and the seam for Tier 2. Encryption-at-rest for the DOB column and the
|
||||
> Tier 2 ID-check hook below are **not yet implemented**.
|
||||
|
||||
```
|
||||
User submits { date_of_birth }
|
||||
│
|
||||
▼
|
||||
Server validates format & plausibility (age 13–120, not in future)
|
||||
Server validates format & plausibility (real date, not in future)
|
||||
│
|
||||
▼
|
||||
Compute age from DOB
|
||||
│
|
||||
▼
|
||||
age >= 18 ──► status = self_declared (adult)
|
||||
age >= 13 ──► status = self_declared (minor, restricted)
|
||||
age >= 18 ──► status = self_declared (adult) ← the current gate
|
||||
age >= 13 ──► status = self_declared (minor, restricted) ← design only
|
||||
age < 13 ──► status = rejected (below minimum platform age)
|
||||
```
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
> **Owner:** Platform team
|
||||
> **Related:** World chunk data format & persistence, server authority + client
|
||||
> prediction, content moderation & age verification, avatar component system
|
||||
> **Note:** the recommendation here stands, but edits do **not** ride the
|
||||
> `request_chunk_edit` ENet RPC (§4.2, §7.4). Per
|
||||
> [`world_persistence_impl.md`](world_persistence_impl.md) the editor saves
|
||||
> chunks over HTTP to the FastAPI backend, which is the sole writer; the
|
||||
> validation/moderation boundary moves with it.
|
||||
> **Godot version:** 4.6.2 stable
|
||||
|
||||
## 1. Overview
|
||||
|
||||
@@ -237,6 +237,62 @@ snapshot_state:
|
||||
|
||||
---
|
||||
|
||||
## 7a. Server Orchestration (implemented, PC3 Phase 2)
|
||||
|
||||
**One dedicated server process per world**, spawned on demand.
|
||||
|
||||
```
|
||||
client backend (single worker) host
|
||||
│ POST /api/worlds/{id}/join (bearer JWT) │
|
||||
│ ───────────────────────────▶ GameServerSupervisor │
|
||||
│ ensure_server(world_id): │
|
||||
│ if none running for world ─── spawn:
|
||||
│ godot --headless
|
||||
│ res://scenes/server.tscn --
|
||||
│ --port <p> --world <id>
|
||||
│ --status-file <path>
|
||||
│ ◀─────────────────────────── { host, port, world_id } │
|
||||
│ │
|
||||
│ ENet connect host:port ───────────────────────────────────▶ dedicated server
|
||||
```
|
||||
|
||||
- **Supervisor** (`backend/app/services/game_servers.py`): in-process singleton,
|
||||
so the backend MUST run single-worker. Keys servers by `world_id`. Allocates
|
||||
ports from `GAME_SERVER_PORT_MIN..MAX`. A background reaper kills a world's
|
||||
server once its **status file** (`{players, tick, updated}`, rewritten by the
|
||||
server ~1×/s) reports `players == 0` for `GAME_SERVER_IDLE_TIMEOUT_SECONDS`.
|
||||
No crash-restart — the next join respawns.
|
||||
- **Enable flag:** `GAME_SERVERS_ENABLED` (default off). The checkpoint/playtest
|
||||
launcher (`pm/checkpoint_ops.py`) turns it on, passes `GODOT_BIN`,
|
||||
`GAME_PROJECT_DIR`, and `GAME_SERVER_HOST` (the LAN IP baked into the `.exe`).
|
||||
A backend with it off returns **503** from `/join`, and the client falls back
|
||||
to local single-player.
|
||||
- **Per-world content:** all worlds currently load the same test level (M2/M7
|
||||
work). `world_id` still scopes a distinct process + session.
|
||||
|
||||
### 7b. Client play path
|
||||
|
||||
`world_list.gd` → `POST /join` → `Session.begin(world_id, host, port)` →
|
||||
load `game.tscn`. `game.gd` sees `Session.active` and connects a
|
||||
`NetworkClient`:
|
||||
|
||||
- **Local player** keeps predicting with its own collision-aware
|
||||
`CharacterBody3D` (`player.gd`) — that *is* the prediction. Its input frame
|
||||
(`player.net_input`) is forwarded to the server every physics tick.
|
||||
- **Correction backstop:** if the local position drifts from the server's by
|
||||
more than `CORRECTION_THRESHOLD` (1.5 m — generous; catches teleport / a wall
|
||||
walked through / real desync, not prediction noise) it snaps.
|
||||
- **Remote players:** rendered from snapshots, position lerped. *Appearance sync
|
||||
is a follow-up* — remotes currently show as labelled capsules; the server
|
||||
does not yet replicate avatar loadouts.
|
||||
- **Disconnect** → status message → back to the world list.
|
||||
|
||||
The `NetworkClient`'s own flat-plane prediction + input-replay reconciliation
|
||||
(§5) is exercised by `smoke_test_network_client.gd`; the collision-aware game
|
||||
path above uses the lighter `send_input()` + drift-snap instead.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security & Anti-Cheat
|
||||
|
||||
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# PC2 Test Level & Player Spawn — Design Note
|
||||
|
||||
> **Status:** Decided (PC2)
|
||||
> **Owner:** Architecture
|
||||
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||
> (2) build the player & spawn it. Both must be able to follow this note
|
||||
> without further design decisions.
|
||||
|
||||
## 1. Decision: static scene, not chunk streaming
|
||||
|
||||
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||
scene.** It is **not** built through the chunk streaming system
|
||||
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||
`chunk_data_decoder.gd`).
|
||||
|
||||
This is a deliberate decision, not a default:
|
||||
|
||||
- **The streaming system has never been exercised with real content.** It is a
|
||||
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||
chunk authoring/streaming endpoint to integrate against.
|
||||
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||
streaming is server-authoritative streaming around a moving player across a
|
||||
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||
clearly out of checkpoint scope.
|
||||
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||
|
||||
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||
of the *world data* half.
|
||||
|
||||
## 2. Where things live
|
||||
|
||||
| Artifact | Path | Owned by |
|
||||
|---|---|---|
|
||||
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||
| Player scene | `scenes/player.tscn` | player task |
|
||||
| Player controller script | `scripts/player.gd` | player task |
|
||||
| Game composition scene | `scenes/game.tscn` | player task |
|
||||
|
||||
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||
|
||||
## 3. Player spawn convention
|
||||
|
||||
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||
That node's world transform is the spawn point.
|
||||
|
||||
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||
hint." (Confirmed present in Godot 4.6.2.)
|
||||
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||
player (spawn yaw is the player's concern, default forward is fine).
|
||||
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||
the floor and get popped out by the physics solver.
|
||||
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||
multiple spawns, that is a later convention — not PC2.)
|
||||
|
||||
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||
|
||||
```
|
||||
# in player.gd _ready()
|
||||
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||
if spawn is Node3D:
|
||||
global_position = spawn.global_position
|
||||
else:
|
||||
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||
```
|
||||
|
||||
The lookup runs from the current scene root so it finds the marker whether it is
|
||||
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||
on it (see §6).
|
||||
|
||||
## 4. Composition (who puts level + player together)
|
||||
|
||||
`scenes/game.tscn` is the PC2 playable entry point:
|
||||
|
||||
```
|
||||
Game (Node3D)
|
||||
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||
└── Player (instance of res://scenes/player.tscn)
|
||||
```
|
||||
|
||||
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||
instantiation, not code, so there is nothing to keep in sync.
|
||||
- The **level scene is self-contained and has zero dependency on the player** —
|
||||
it loads and is smoke-testable on its own. The level task does not instance or
|
||||
reference `player.tscn`.
|
||||
- The **player scene is self-contained and has zero dependency on any specific
|
||||
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||
|
||||
## 5. Level content requirements (level task)
|
||||
|
||||
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||
children:
|
||||
|
||||
| Node | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||
|
||||
Notes:
|
||||
|
||||
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||
behavior belongs to the player (or a future game-mode script), not the level.
|
||||
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||
coordinates, no thin walls the capsule can tunnel through.
|
||||
|
||||
## 6. Player contract (player task)
|
||||
|
||||
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||
|
||||
Minimum for PC2:
|
||||
|
||||
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||
the lowest-risk choice; third-person is fine too but is more work.
|
||||
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||
gracefully.
|
||||
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||
may be split into its own task if the controller grows.
|
||||
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||
|
||||
The player must work when instanced under `Game` alongside a level; it must not
|
||||
assume it is the only node in the scene.
|
||||
|
||||
## 7. Entry point / how a human plays it
|
||||
|
||||
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||
`application/run/main_scene` to it.
|
||||
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||
for this note** and out of scope for the level/player tasks. The current menu
|
||||
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||
the test level, that navigation wiring is its own small task — do not fold it
|
||||
into level or player.
|
||||
|
||||
## 8. Validation (both tasks must run this before "done")
|
||||
|
||||
```sh
|
||||
# one-time, first checkout only — builds the class/import cache
|
||||
godot --headless --path . --import
|
||||
|
||||
# then, to catch parse/load errors:
|
||||
godot --headless --path . --quit
|
||||
```
|
||||
|
||||
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||
and asserts the player's global position equals the `PlayerSpawn` position
|
||||
(within a small epsilon). This is the check that proves the spawn convention
|
||||
actually wired through — not just "the scene loads."
|
||||
|
||||
## 9. Explicitly out of scope for PC2
|
||||
|
||||
- Networking / multiplayer / prediction (single-player, local-only).
|
||||
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||
- Avatar customization UI and glTF avatar loading.
|
||||
- Join-a-world navigation from the world list.
|
||||
- Any content moderation / age-gating logic in the level.
|
||||
|
||||
## 10. Summary
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||
| Level location | `scenes/worlds/test_level.tscn` |
|
||||
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||
@@ -1,6 +1,10 @@
|
||||
# World Chunk Data Format & Persistence Architecture
|
||||
|
||||
> **Status:** Proposed
|
||||
> **Status:** Proposed. The chunk format (§2, §3) is normative and implemented
|
||||
> on the client. **§4 (SQLite schema) and §5 (write ownership / write-behind)
|
||||
> are superseded by [`world_persistence_impl.md`](world_persistence_impl.md)**,
|
||||
> which stores chunks in the existing Postgres backend and makes the FastAPI
|
||||
> process — not the per-world Godot server — the sole writer.
|
||||
> **Owner:** Platform team
|
||||
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
|
||||
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
# World Persistence: Server-Side Implementation Architecture
|
||||
|
||||
> **Status:** Accepted (design). Implementation is split across two follow-on
|
||||
> tasks (backend endpoints; editor wiring).
|
||||
> **Owner:** Platform team
|
||||
> **Supersedes:** the *storage engine* and *write ownership* parts of
|
||||
> [`world.md`](world.md) §4 and §5. Everything else in `world.md` — the chunk
|
||||
> logical model, the JSON and binary encodings, checksums, revisions, content
|
||||
> flags — stands unchanged and is normative.
|
||||
> **Related:** [`world.md`](world.md) (chunk format), [`editor.md`](editor.md)
|
||||
> (authoring surface), [`networking.md`](networking.md) (ENet sim transport),
|
||||
> [`content_moderation.md`](content_moderation.md) (flags & gating).
|
||||
|
||||
## 1. Why this document exists
|
||||
|
||||
`world.md` was written during the client-side chunk-streaming work (M2). It
|
||||
assumes **one** server that owns **one** embedded SQLite file and is both the
|
||||
authoritative simulation and the authoritative store. That is not the system we
|
||||
built. What actually exists today is:
|
||||
|
||||
| Process | Lifetime | Owns | Code |
|
||||
|---|---|---|---|
|
||||
| FastAPI backend | always on, single worker | Postgres (users, worlds, assets, avatar loadouts), game-server orchestration | `backend/app/` |
|
||||
| Godot dedicated server, one per world | **spawned on join, reaped ~5 min after the last join when empty** | nothing durable; a 30 Hz in-memory player sim | `scripts/server.gd`, spawned by `backend/app/services/game_servers.py` |
|
||||
| Godot client | player session | local chunk streaming, the in-game editor (planned) | `scripts/chunk_stream_manager.gd`, `scripts/chunk_data_decoder.gd` |
|
||||
|
||||
Nothing server-side has ever stored or served a real chunk. `server.gd`
|
||||
`preload()`s `res://scenes/worlds/test_level.tscn` for **every** world and uses
|
||||
`world_id` only for logging and the status file. `ChunkStreamManager` has a
|
||||
`set_server_rpc()` seam that no code calls. The backend's `World` model is
|
||||
metadata only (`id`, `name`, `description`, `owner_id`, `visibility`).
|
||||
|
||||
This document decides where chunk content lives, who may write it, how the
|
||||
dedicated server gets it, and the exact API surface — so the two implementation
|
||||
tasks that follow have no architectural decisions left to make.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions at a glance
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| D1 | Where does chunk data live at rest? | **Postgres**, in the existing backend database. Not a per-world SQLite file. |
|
||||
| D2 | Who writes it? | **The FastAPI backend, exclusively.** The editor writes through it over HTTP with optimistic concurrency. The dedicated Godot server never writes world content. |
|
||||
| D3 | How does the dedicated server get content? | **HTTP pull from the backend at startup**, over a loopback-only `/api/internal` route authenticated by a per-spawn token the supervisor mints. Lazy fetch as players roam. |
|
||||
| D4 | Edit while a live session exists? | **Out of scope this milestone, with a defined seam.** The editor is a non-joined client mode; writes always succeed and the response flags `live_session` so the UI can say "players see this when the session restarts". |
|
||||
| D5 | Edit granularity on the wire | **Full-chunk PUT with `base_revision`**, not RFC-6902 deltas. |
|
||||
| D6 | Client chunk transport | **HTTP from the backend**, not ENet from the dedicated server. The `set_server_rpc()` seam is satisfied by an HTTP-backed adapter. |
|
||||
|
||||
---
|
||||
|
||||
## 3. D1 — Chunk data lives in Postgres
|
||||
|
||||
### 3.1 Decision
|
||||
|
||||
World content (rooms, chunk payloads, chunk revisions) is stored in the
|
||||
**existing Postgres database**, alongside `worlds`, in new tables created by an
|
||||
Alembic migration. `world.md` §4's `world.db` SQLite file is **not** built.
|
||||
|
||||
The chunk **payload stays exactly what `world.md` §3.6 specifies**: the 48-byte
|
||||
header + sectioned binary body, checksummed with SHA-256. It moves from a
|
||||
SQLite `BLOB` to a Postgres `BYTEA`. Nothing about the format changes, so
|
||||
`scripts/chunk_data_decoder.gd` remains correct and the format spec stays the
|
||||
single contract.
|
||||
|
||||
### 3.2 Reasoning
|
||||
|
||||
1. **SQLite's premise does not hold here.** `world.md` §4 justifies SQLite as
|
||||
"ideal for a server that owns the world store". No process in this system
|
||||
owns a world store: the only process that could plausibly hold the file (the
|
||||
dedicated Godot server) is *ephemeral* — reaped when idle, `SIGTERM`'d on
|
||||
backend shutdown, `SIGKILL`'d 10 s later if it doesn't exit, and orphaned
|
||||
entirely if the backend dies. Meanwhile the editor must write when no such
|
||||
process exists. So the file would have to be shared by two processes with
|
||||
independent lifetimes, which is precisely the workload SQLite's
|
||||
single-writer/file-lock model handles worst.
|
||||
2. **Postgres is already a hard dependency; SQLite would be a second one.** The
|
||||
backend cannot start without Postgres. Adding a per-world SQLite file adds a
|
||||
second storage engine, a second migration system (`world.md` §4.8 specifies a
|
||||
hand-rolled `meta.schema_version` runner *next to* the Alembic we already
|
||||
have), a second backup story, and a second consistency story.
|
||||
3. **Referential integrity with metadata we already have.** `worlds` rows carry
|
||||
`owner_id` and `visibility`, and every authorization rule for content derives
|
||||
from them. In one database this is a join and a foreign key with
|
||||
`ON DELETE CASCADE`; across two engines, deleting a world silently orphans a
|
||||
file on disk and every ownership check becomes a cross-store read.
|
||||
4. **Concurrency is the normal case, not the edge case.** Two creators editing
|
||||
one world, or an editor writing while the backend serves chunks to a running
|
||||
dedicated server, are ordinary. Postgres gives row-level MVCC and real
|
||||
transactions for the read-modify-write in §5.4; SQLite gives a database-level
|
||||
write lock and `SQLITE_BUSY`.
|
||||
5. **The repo already has the precedent and the plumbing.** `avatar_loadouts`
|
||||
(migration `0004`) stores a client-shaped document in Postgres JSONB rather
|
||||
than inventing a side store, and `backend/app/database.py` already provides
|
||||
the async session factory this code will use.
|
||||
|
||||
### 3.3 What we give up, and why it's acceptable
|
||||
|
||||
- **Zero-config embedding.** Irrelevant — the backend already requires a
|
||||
configured Postgres.
|
||||
- **`world.md` §5.5 write-behind + the `dirty` column.** Deliberately dropped
|
||||
(§4.4). It existed to amortize disk I/O for a process that owned the file. Our
|
||||
writes are human-rate editor saves, not sim-rate; each one is a single
|
||||
transaction and is durable when the HTTP response returns. Dropping it also
|
||||
deletes the entire "unclean shutdown recovery" scan in §5.5 — there is no
|
||||
window in which committed content can be lost.
|
||||
- **Sharding by `world_id` (`world.md` §8).** Postgres partitioning by
|
||||
`world_id` remains available on the same table shape if a single table ever
|
||||
becomes a bottleneck. At ~2–8 KB per chunk, 10 000 chunks is under 100 MB.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema (Alembic migration `0005_add_world_content_tables`)
|
||||
|
||||
New model module: `backend/app/models/world_content.py`, registered in
|
||||
`backend/app/models/__init__.py`. `backend/app/models/world.py` is left alone
|
||||
except for one added relationship, so nothing PC1/PC2 depends on moves.
|
||||
|
||||
Conventions follow the existing models: `Column(...)` declarative style,
|
||||
`Integer` surrogate PKs, `DateTime(timezone=True)` with `server_default=func.now()`,
|
||||
JSONB for document-shaped fields (as `avatar_loadout.py` does).
|
||||
|
||||
### 4.1 `world_rooms`
|
||||
|
||||
```python
|
||||
class WorldRoom(Base):
|
||||
__tablename__ = "world_rooms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True)
|
||||
slug = Column(String(64), nullable=False) # url-safe; "main" by default
|
||||
name = Column(String(255), nullable=False)
|
||||
# Chunk grid config. world.md §2.2 puts this on the world; it lives on the
|
||||
# room so a world can later hold rooms with different grids, and so this
|
||||
# migration is purely additive (the existing `worlds` table is untouched).
|
||||
grid = Column(JSONB, nullable=False,
|
||||
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}')
|
||||
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
|
||||
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
|
||||
onupdate=func.now(), nullable=False)
|
||||
|
||||
__table_args__ = (UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),)
|
||||
```
|
||||
|
||||
`World` gains
|
||||
`rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")`.
|
||||
|
||||
Every world gets a `main` room: created in `create_world`, and lazily created on
|
||||
first room read/write for worlds that predate the migration. `grid` defaults
|
||||
match `Chunk.CHUNK_SIZE_*` in `scripts/chunk.gd`, which is the client's source
|
||||
of truth for the same constants.
|
||||
|
||||
### 4.2 `world_chunks`
|
||||
|
||||
```python
|
||||
class WorldChunk(Base):
|
||||
__tablename__ = "world_chunks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True) # denormalized: world-scoped queries
|
||||
room_id = Column(Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True)
|
||||
chunk_key = Column(String(160), nullable=False, unique=True) # see §4.5
|
||||
cx = Column(Integer, nullable=False)
|
||||
cy = Column(Integer, nullable=False)
|
||||
cz = Column(Integer, nullable=False, server_default="0")
|
||||
|
||||
format_version = Column(Integer, nullable=False, server_default="1")
|
||||
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes, BYTEA
|
||||
payload_len = Column(Integer, nullable=False)
|
||||
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
|
||||
revision = Column(Integer, nullable=False, server_default="1")
|
||||
|
||||
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
|
||||
is_mature = Column(Boolean, nullable=False, server_default="false")
|
||||
|
||||
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
|
||||
onupdate=func.now(), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
|
||||
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
|
||||
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
|
||||
)
|
||||
```
|
||||
|
||||
Deltas from `world.md` §4.4:
|
||||
|
||||
- **Surrogate `id` PK + unique coordinate constraint**, with `chunk_key` kept as
|
||||
a unique denormalized column. The key string is the client-facing contract
|
||||
(`Chunk.build_key`) and is embedded in the payload, so storing it gives a
|
||||
single-index lookup by key; the coordinate constraint is what actually
|
||||
enforces one chunk per cell.
|
||||
- **`dirty` removed** — see §3.3.
|
||||
- **Content flags folded into the chunk row as JSONB + an indexed `is_mature`
|
||||
boolean**, instead of `world.md` §4.6's separate 11-column `content_flags`
|
||||
table. The flags are a document the client already produces in exactly this
|
||||
shape (`ChunkDataDecoder.ContentFlags.to_dict()`), the only query we anticipate
|
||||
is "is this mature", and folding them in removes a second write per save and
|
||||
the `object_id = ''` sentinel hack that §4.6 needed to work around SQLite's
|
||||
NULL-in-primary-key behaviour (Postgres has the same NULL semantics, so the
|
||||
hack would have carried over).
|
||||
- **`world.md` §4.5 `chunk_objects` is deferred, not dropped.** It is a derived
|
||||
index that exists to answer queries ("which chunks contain a door?") that no
|
||||
code asks yet, and maintaining it means a delete-and-reinsert of every object
|
||||
row on every save. It is fully rebuildable from `payload`, so it can be added
|
||||
in its own migration the day a query needs it, with a backfill. Do **not**
|
||||
build it in `0005`.
|
||||
|
||||
### 4.3 `world_chunk_revisions`
|
||||
|
||||
```python
|
||||
class WorldChunkRevision(Base):
|
||||
__tablename__ = "world_chunk_revisions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
chunk_key = Column(String(160), nullable=False, index=True) # deliberately NOT a FK
|
||||
world_id = Column(Integer, nullable=False, index=True) # also not a FK, same reason
|
||||
revision = Column(Integer, nullable=False)
|
||||
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
|
||||
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
checksum = Column(String(64), nullable=False)
|
||||
payload = Column(LargeBinary, nullable=True) # full payload at this revision
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
```
|
||||
|
||||
Kept from `world.md` §4.7 including its two design points: the full payload is
|
||||
stored (rollback needs it) and `chunk_key`/`world_id` are **not** foreign keys,
|
||||
so deleting a chunk or a world preserves the audit trail.
|
||||
|
||||
**Retention (new, required):** this table grows without bound — a creator
|
||||
dragging a brush can produce hundreds of revisions per minute. The
|
||||
implementation must ship a retention policy from day one: keep the newest
|
||||
`WORLD_CHUNK_REVISION_KEEP` (default 20) payloads per `chunk_key` and null out
|
||||
`payload` on older rows (keeping the metadata row for the audit trail), pruned
|
||||
in the same transaction as the write. A `created_at` index supports a later
|
||||
time-based prune.
|
||||
|
||||
### 4.4 Config additions (`backend/app/config.py`)
|
||||
|
||||
```
|
||||
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB; a dense 32×32 uint16 layer is 2 KiB
|
||||
WORLD_CHUNK_MAX_OBJECTS: int = 512
|
||||
WORLD_CHUNK_MAX_BATCH: int = 64
|
||||
WORLD_CHUNK_REVISION_KEEP: int = 20
|
||||
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
|
||||
```
|
||||
|
||||
### 4.5 Chunk key format
|
||||
|
||||
`world.md` §2.3 specifies `"{world_id}:{room_id}:{cx}:{cy}:{cz}"` and illustrates
|
||||
it with `w_0001:r_0001`. Those ids were illustrative. The real key uses the
|
||||
backend's identifiers:
|
||||
|
||||
```
|
||||
"{world.id}:{room.slug}:{cx}:{cy}:{cz}" e.g. "12:main:3:-1:0"
|
||||
```
|
||||
|
||||
The room **slug** rather than its numeric id, so a key is human-readable in logs
|
||||
and stable if rooms are ever re-imported. `Chunk.build_key()` in
|
||||
`scripts/chunk.gd` already produces this shape unmodified.
|
||||
|
||||
---
|
||||
|
||||
## 5. D2 — The FastAPI backend is the only writer
|
||||
|
||||
### 5.1 Decision
|
||||
|
||||
The in-game editor (`editor.md` §7.2) submits edits over **HTTP to the FastAPI
|
||||
backend**, which validates, versions, and persists them. The dedicated Godot
|
||||
server process **never writes world content** and does not sit in the edit path.
|
||||
|
||||
This preserves `world.md`'s and `editor.md`'s actual invariant — *clients never
|
||||
touch storage; they submit edits to a server-authoritative tier that validates
|
||||
them* — while identifying that tier as the always-on backend rather than the
|
||||
ephemeral sim process. `editor.md` §4.2's claim that edits ride the same
|
||||
`request_chunk_edit` RPC as gameplay is the one part of that doc this design
|
||||
changes; the moderation and validation properties it derives from that claim are
|
||||
unaffected, because the validation boundary moves with the writer.
|
||||
|
||||
### 5.2 Reasoning
|
||||
|
||||
1. **Nothing that can vanish in 300 seconds may own the write path.** The reaper
|
||||
in `game_servers.py` `stop()`s an idle server with `SIGTERM` and escalates to
|
||||
`SIGKILL` after 10 s; `supervisor.shutdown()` does the same to every server on
|
||||
backend shutdown; and if the backend dies uncleanly the servers are orphaned
|
||||
with no one to flush them. Because the sim process holds no unsaved world
|
||||
content in this design, **no reap, crash, or kill can ever lose world data** —
|
||||
that is the single strongest practical reason for this split.
|
||||
2. **Editing must work with no dedicated server running.** A creator opening
|
||||
their world to build should not have to spin up a 30 Hz sim process, burn a
|
||||
port from the 100-port range, and hold it for 5 minutes past their last save.
|
||||
3. **`server.gd` has no database access, no credentials, and no HTTP client.**
|
||||
Routing writes through it means either shipping Postgres credentials into a
|
||||
process spawned per join, or building an ENet→HTTP proxy inside the sim loop.
|
||||
Both are strictly worse than the editor calling the backend directly.
|
||||
4. **Authorization already lives in the backend.** `CurrentUser`, the
|
||||
`owner_id` check, and `WorldVisibility` are all there. The dedicated server
|
||||
has no user identities at all — it knows peer ids, not accounts.
|
||||
|
||||
### 5.3 Authorization
|
||||
|
||||
- **Read** a chunk: the same rule as `GET /api/worlds/{id}` — the world is
|
||||
`PUBLIC`, or the caller owns it. Otherwise 404 (not 403), matching the
|
||||
existing routers' deliberate non-disclosure.
|
||||
- **Write** a chunk: caller must be `world.owner_id`, matching `update_world`;
|
||||
403 otherwise. Collaborator/role-based editing is future work and slots in at
|
||||
this one check.
|
||||
- **Mature gating hook:** `is_mature` chunks are where `content_moderation.md`'s
|
||||
age gate attaches on the read path. Specify the column and the index now; the
|
||||
gate itself lands with that doc's work.
|
||||
|
||||
### 5.4 Concurrency
|
||||
|
||||
Optimistic concurrency exactly as `world.md` §5.4, expressed over HTTP:
|
||||
|
||||
1. Reader gets `revision` (header `X-Chunk-Revision`, or the JSON field).
|
||||
2. Writer sends `base_revision`.
|
||||
3. The service does `SELECT ... FOR UPDATE` on the chunk row, compares
|
||||
`base_revision` to `revision`, and either commits at `revision + 1` or returns
|
||||
**409** with `current_revision` and `current_checksum` so the editor can
|
||||
re-fetch and re-apply.
|
||||
4. `base_revision: 0` means "create; fail with 409 if it already exists".
|
||||
|
||||
### 5.5 Validation (the backend must not trust the client)
|
||||
|
||||
The write path decodes the submitted payload server-side before storing it. This
|
||||
requires a **Python port of the `world.md` §3.6 codec** —
|
||||
`backend/app/services/chunk_codec.py`, mirroring `scripts/chunk_data_decoder.gd`.
|
||||
It checks: magic `AGPC`, `format_version <= 1`, declared vs. actual
|
||||
`payload_len`, **recomputed** SHA-256 (the header's checksum is advisory), tile
|
||||
layer dims equal the room's `grid.size`, tile indices in palette range, object
|
||||
count ≤ `WORLD_CHUNK_MAX_OBJECTS`, object positions inside the chunk's bounds,
|
||||
`chunk_key` in the payload matching the URL, and string-field length limits
|
||||
(`world.md` §7). `content_flags` and `is_mature` are **recomputed** from the
|
||||
payload's flag sections, never taken from the request body.
|
||||
|
||||
**Drift control (required):** the two codecs must be pinned by golden files —
|
||||
fixture chunks under `tests/fixtures/chunks/` encoded by one implementation and
|
||||
decoded byte-identically by the other, asserted in both the pytest suite and the
|
||||
Godot headless suite. Without this the format has two implementations and no
|
||||
contract test, which is exactly the failure `world.md` §5.2 was trying to avoid
|
||||
by having one shared implementation.
|
||||
|
||||
---
|
||||
|
||||
## 6. D4 — Editing versus a live session (scope boundary)
|
||||
|
||||
**In scope for this milestone:**
|
||||
|
||||
- The editor is entered as **"Edit world" from the world browser**: a non-joined
|
||||
client mode. It does **not** call `POST /api/worlds/{id}/join`, does not open
|
||||
an ENet connection, and does not spawn a dedicated server. It loads chunks over
|
||||
HTTP, edits them locally, and saves them over HTTP.
|
||||
- Writes always succeed regardless of whether a session is live. The write
|
||||
response carries `live_session: bool` (from `supervisor.debug_state()`), so the
|
||||
editor can surface *"this world has players in it right now — they'll see your
|
||||
changes when the session restarts."*
|
||||
- A dedicated server started **after** a write sees the new content, because it
|
||||
loads from the store at startup (§7).
|
||||
|
||||
**Explicitly out of scope, deferred:**
|
||||
|
||||
- Build-in-place while joined to a live session.
|
||||
- `chunk_invalidated` push to connected clients (`world.md` §5.3) and hot-reload
|
||||
of the dedicated server's collision world.
|
||||
- Live collaborative editing / cursors (`editor.md` §9).
|
||||
|
||||
**The seam that makes the deferred work cheap:** the manifest endpoint (§7.2)
|
||||
returns a monotonic `content_revision` for the world, bumped on every chunk
|
||||
write. Hot reload is then: the dedicated server polls the manifest on a timer
|
||||
(or the backend pushes to it over the same internal channel), re-fetches the
|
||||
chunks whose revision changed, and rebuilds those collision bodies. Nothing in
|
||||
this design has to change to add it.
|
||||
|
||||
**Why the editor must not join:** if it joined, the sim process would enforce
|
||||
collision against content it loaded at spawn while the creator edits a diverging
|
||||
local copy — players would walk through new walls and bounce off deleted ones,
|
||||
with no error anywhere. Making build mode non-joined turns a silent correctness
|
||||
bug into an explicit product boundary.
|
||||
|
||||
---
|
||||
|
||||
## 7. D3 — What the dedicated server does at startup
|
||||
|
||||
### 7.1 Decision
|
||||
|
||||
`server.gd` pulls its world's content from the backend over **HTTP** at startup,
|
||||
authenticated by a **per-spawn token** the supervisor mints, and **does not bind
|
||||
the ENet port until content is loaded**.
|
||||
|
||||
Rejected alternative — *supervisor writes a whole-world snapshot file at spawn
|
||||
and passes `--world-file`*: attractive (no new auth surface, no HTTP client in
|
||||
the sim process), but it makes spawn latency proportional to world size, gives
|
||||
the server no way to fetch a chunk lazily as players roam beyond the preloaded
|
||||
set, and still needs a separate channel for the hot-reload work above. Rejected —
|
||||
*give the Godot process a Postgres connection*: there is no PG driver in
|
||||
GDScript, and it would put database credentials in a process spawned per join.
|
||||
|
||||
### 7.2 Startup sequence
|
||||
|
||||
1. `_parse_args()` gains `--api-url` and `--server-token` (§7.3).
|
||||
2. `GET /api/internal/worlds/{world_id}/manifest` → rooms, per-room `grid`,
|
||||
`bounds`, `spawn`, `chunk_count`, and the world's `content_revision`.
|
||||
3. `GET /api/internal/worlds/{world_id}/rooms/{slug}/chunks?` bounds query for
|
||||
the spawn neighbourhood (radius from the spawn point, default 3 chunks), then
|
||||
fetch each payload; decode with the existing `ChunkDataDecoder` and build
|
||||
static collision from the tile layers and objects.
|
||||
4. **Then** `network_peer.create_server(port)`, then write the status file.
|
||||
5. **Fallback:** if the backend is unreachable, the world has no rooms, or the
|
||||
room has zero chunks, load `test_level.tscn` as today and record
|
||||
`"content": "fallback"` in the status file. A content outage must never make
|
||||
a world un-joinable, and this keeps every PC3 flow working before any world
|
||||
has authored content.
|
||||
|
||||
During the session the server may lazily fetch chunks as players approach
|
||||
unloaded coordinates, through the same internal route. It **never writes**.
|
||||
Dynamic entity runtime state (positions, health) stays in memory and out of
|
||||
chunks, exactly as `world.md` §3.3 already specifies. Shutdown flushes nothing.
|
||||
|
||||
### 7.3 Internal authentication
|
||||
|
||||
`GameServerSupervisor._spawn()` generates `token = secrets.token_urlsafe(32)`,
|
||||
stores it on the `_WorldServer` dataclass, and passes it on the command line
|
||||
alongside the existing `--port/--world/--status-file`. A FastAPI dependency
|
||||
`require_game_server` resolves `Authorization: Bearer <token>` against
|
||||
`supervisor.validate_token(world_id, token)` — a dict lookup, since the
|
||||
supervisor is an in-process singleton. Properties this gives us:
|
||||
|
||||
- A token grants access to **one** world and **dies with its process** (the
|
||||
reaper's `del self._servers[wid]` invalidates it).
|
||||
- No long-lived shared secret in config or on disk.
|
||||
- It reuses the **already documented** single-worker constraint in
|
||||
`game_servers.py`; it does not add a new one.
|
||||
|
||||
Rejected alternative — *let the dedicated server use the public routes with a
|
||||
service user account*: the public routes enforce per-user visibility and
|
||||
ownership, which a service identity would have to be exempted from, and it would
|
||||
pollute `updated_by_id`/audit rows with a fake user. A separate `/api/internal`
|
||||
prefix keeps the two auth models apart and can be blocked at the reverse proxy.
|
||||
|
||||
### 7.4 Lifecycle changes this requires in `game_servers.py`
|
||||
|
||||
These are real, load-bearing findings from the current implementation:
|
||||
|
||||
- **`_await_port()` is the wrong readiness signal now.** It returns as soon as
|
||||
the UDP port is bound, and the port is bound before content exists today. With
|
||||
content loading it must await a `"ready": true` field that `server.gd` writes
|
||||
to the status file *after* content load + `create_server`. Otherwise the
|
||||
backend hands a client an address for a server that is still fetching chunks.
|
||||
- **`_await_port()` fails open.** After its 8 s deadline it returns normally,
|
||||
with no error — `ensure_server` then reports success for a server that may
|
||||
never have started. Content loading makes a slow start more likely, so the
|
||||
readiness wait needs a real timeout error (503 to the joining client) and a
|
||||
longer, configurable deadline.
|
||||
- **The status file is the only server→backend channel**, and `_spawn` currently
|
||||
passes no credentials at all — hence §7.3.
|
||||
- **`player_count()` returns 0 when the status file is missing or unparseable**,
|
||||
so a live server whose status write fails can be reaped after the idle timeout.
|
||||
Harmless for data (the sim owns nothing durable — the point of D2) but worth a
|
||||
comment where the fallback lives.
|
||||
- **No crash restart** (documented in the module docstring): if a server dies,
|
||||
the next join respawns it and it reloads content from the store. With D2 this
|
||||
is now genuinely safe rather than merely tolerated.
|
||||
|
||||
---
|
||||
|
||||
## 8. API surface
|
||||
|
||||
All paths are under the existing `worlds` router prefix (`/api/worlds`) unless
|
||||
noted. New router module: `backend/app/routers/world_content.py`, mounted at
|
||||
`/api/worlds`; internal routes in `backend/app/routers/internal.py` at
|
||||
`/api/internal`.
|
||||
|
||||
### 8.1 Public (editor & player client; JWT `CurrentUser`)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/worlds/{world_id}/rooms` | List rooms + grid config |
|
||||
| `POST` | `/api/worlds/{world_id}/rooms` | Create a room (owner) |
|
||||
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Chunk **index** over a bounds query |
|
||||
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Fetch one chunk |
|
||||
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Write one chunk |
|
||||
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Write up to `WORLD_CHUNK_MAX_BATCH` chunks atomically |
|
||||
| `DELETE` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Delete one chunk |
|
||||
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions` | Revision history (owner) |
|
||||
| `POST` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback` | Restore a revision (owner) |
|
||||
|
||||
**Room list** — `GET .../rooms` → `200`
|
||||
|
||||
```json
|
||||
[{ "id": 7, "slug": "main", "name": "Main",
|
||||
"grid": { "size": [32, 32, 1], "origin": [0, 0, 0] },
|
||||
"bounds": null,
|
||||
"spawn": { "pos": [0, 1.5, 0], "yaw": 0.0 },
|
||||
"chunk_count": 41 }]
|
||||
```
|
||||
|
||||
**Chunk index** — `GET .../chunks?cx_min=&cx_max=&cy_min=&cy_max=&cz=0` → `200`.
|
||||
Payloads are *not* included; this is `world.md` §5.1's `list_chunks(bounds)` and
|
||||
lets a streamer decide what to fetch and skip anything already cached by
|
||||
checksum.
|
||||
|
||||
```json
|
||||
{ "room": "main", "content_revision": 118,
|
||||
"chunks": [{ "key": "12:main:0:0:0", "cx": 0, "cy": 0, "cz": 0,
|
||||
"revision": 4, "checksum": "9f86…", "payload_len": 2344,
|
||||
"is_mature": false, "updated_at": "2026-09-14T10:02:11Z" }] }
|
||||
```
|
||||
|
||||
Bounds are capped (default 32×32 chunks per query) and return `422` beyond that.
|
||||
|
||||
**Chunk fetch** — `GET .../chunks/{cx}/{cy}/{cz}`, content-negotiated:
|
||||
|
||||
- `Accept: application/octet-stream` (default for the game client) → `200` with
|
||||
the raw `world.md` §3.6 bytes, `ETag: "<checksum>"`,
|
||||
`X-Chunk-Revision: <n>`, `X-Chunk-Key: <key>`.
|
||||
- `Accept: application/json` (tooling, tests, the editor's debug view) → the
|
||||
§3.5 JSON encoding, decoded server-side from the payload. JSON is derived, never
|
||||
stored, so there is no dual-write drift (`world.md` §4.4).
|
||||
- `If-None-Match: "<checksum>"` → `304`. This is what makes the client's
|
||||
`user://chunk_cache/` cache cheap and is why the checksum is the ETag.
|
||||
- `404` if the chunk does not exist (an unwritten coordinate is not an error to
|
||||
the streamer — it renders empty).
|
||||
|
||||
**Chunk write** — `PUT .../chunks/{cx}/{cy}/{cz}`
|
||||
|
||||
```
|
||||
Content-Type: application/octet-stream
|
||||
X-Base-Revision: 4 # 0 = create
|
||||
<binary payload>
|
||||
```
|
||||
|
||||
or, for clients that prefer JSON:
|
||||
`{"base_revision": 4, "payload_b64": "…"}`.
|
||||
|
||||
Responses:
|
||||
|
||||
| Code | Body / meaning |
|
||||
|---|---|
|
||||
| `200` | `{"key", "revision", "checksum", "payload_len", "updated_at", "is_mature", "live_session"}` |
|
||||
| `409` | `{"detail": "revision conflict", "current_revision": 6, "current_checksum": "…"}` |
|
||||
| `403` | not the world owner |
|
||||
| `404` | world or room not found / not visible |
|
||||
| `413` | payload over `WORLD_CHUNK_MAX_PAYLOAD_BYTES` |
|
||||
| `422` | decode/validation failure (`{"detail", "reason"}`, e.g. `checksum_mismatch`, `layer_dims`, `object_out_of_bounds`) |
|
||||
|
||||
**Batch write** — `PUT .../chunks` with
|
||||
`{"chunks": [{"cx","cy","cz","base_revision","payload_b64"}, …]}`. Applied in
|
||||
**one transaction, all-or-nothing**: if any entry conflicts, nothing is written
|
||||
and the response is `409` naming the offending coordinate. Justification: an
|
||||
editor brush stroke or an object drag routinely straddles a chunk boundary, and
|
||||
without a batch the editor either issues N round trips or leaves half of a user
|
||||
action persisted. Capped at `WORLD_CHUNK_MAX_BATCH` chunks and 8 MB.
|
||||
|
||||
### 8.2 D5 — Full-chunk PUT, not JSON-Patch deltas
|
||||
|
||||
`world.md` §5.4 specifies RFC-6902 deltas against `base_revision`. This design
|
||||
sends **whole chunks** instead.
|
||||
|
||||
- A 32×32×1 chunk is ~2 KB of dense tile indices plus its objects — a few KB.
|
||||
The bandwidth a patch saves is negligible at editor rates.
|
||||
- The stored form is an opaque binary blob. Patching it means the backend must
|
||||
decode → apply a JSON-Patch → re-encode → re-checksum, i.e. implement and test
|
||||
a *second* mutation format on top of the codec, with its own failure modes, and
|
||||
keep it in sync with the client's patch generator. That is the same dual-path
|
||||
drift risk `world.md` §5.2 exists to prevent.
|
||||
- Lost-update protection comes from `base_revision`, not from the patch, so
|
||||
nothing is given up on correctness.
|
||||
|
||||
Delta encoding stays available as a pure bandwidth optimization later —
|
||||
`world.md` §8 already files it as future work.
|
||||
|
||||
### 8.3 Internal (dedicated server; `require_game_server` token)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/internal/worlds/{world_id}/manifest` | Rooms, grids, spawn, `content_revision`, chunk counts |
|
||||
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks` | Bounds index (same shape as §8.1) |
|
||||
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Binary payload |
|
||||
|
||||
Read-only by construction — there is no internal write route, and adding one
|
||||
would violate D2. The token is world-scoped, so a path whose `world_id` differs
|
||||
from the token's world is `403`. These routes skip the per-user visibility check
|
||||
(the sim process has no user) and apply no mature gating (gating is a client/user
|
||||
concern; the server still needs collision).
|
||||
|
||||
### 8.4 D6 — The client streams chunks over HTTP, not over ENet
|
||||
|
||||
`world.md` §5.3 routes `request_chunk` / `chunk_data` over the game's ENet
|
||||
connection. This design has the playing client fetch chunks from the **backend
|
||||
over HTTP**, the same routes the editor uses.
|
||||
|
||||
- The dedicated server has no content the backend doesn't have; proxying bytes
|
||||
through it adds a hop and puts multi-megabyte reliable transfers in contention
|
||||
with the 30 Hz snapshot broadcast on the same ENet peer.
|
||||
- HTTP gives caching for free — `ETag`/`If-None-Match` on the checksum, and a
|
||||
local `user://chunk_cache/<checksum>` that survives sessions.
|
||||
- `ApiClient` (`scripts/api_client.gd`) already carries the JWT and the result
|
||||
contract; no new transport code.
|
||||
|
||||
**This does not require rewriting `ChunkStreamManager`.** It already calls
|
||||
through a `set_server_rpc()` seam that only needs an object with
|
||||
`request_chunk(key)`. The editor-wiring task supplies an `HttpChunkSource` that
|
||||
implements that method against `ApiClient` and feeds
|
||||
`_on_chunk_data_received(key, bytes)` — which also finally gives that dormant
|
||||
seam a caller. Note the existing `_load_chunk()` builds its key with empty
|
||||
world/room ids (`Chunk.build_key("", "", coord)`); the adapter must supply the
|
||||
real world id and room slug.
|
||||
|
||||
---
|
||||
|
||||
## 9. What each follow-on task owns
|
||||
|
||||
**Backend task**
|
||||
- `backend/app/models/world_content.py` + `0005_add_world_content_tables`
|
||||
- `backend/app/services/chunk_codec.py` (Python port of `world.md` §3.6) and the
|
||||
golden-file fixtures shared with the Godot suite
|
||||
- `backend/app/services/world_store.py` (the `world.md` §5.1 service interface,
|
||||
minus `flush_dirty`/`rebuild_index`, over Postgres)
|
||||
- `backend/app/routers/world_content.py`, `backend/app/routers/internal.py`
|
||||
- `game_servers.py`: per-spawn token, `--api-url`/`--server-token` args,
|
||||
readiness-from-status-file instead of `_await_port` (§7.4)
|
||||
- `create_world` creates the `main` room
|
||||
|
||||
**Client/editor task**
|
||||
- `HttpChunkSource` adapter → `ChunkStreamManager.set_server_rpc()`
|
||||
- Editor save path (single + batch PUT), 409 conflict re-fetch-and-reapply,
|
||||
`live_session` warning UI
|
||||
- "Edit world" entry from the world browser that does **not** join
|
||||
- `server.gd`: arg parsing, manifest + chunk fetch before `create_server`,
|
||||
collision build from decoded chunks, `test_level.tscn` fallback,
|
||||
`"ready"`/`"content"` fields in the status file
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions (deliberately not decided here)
|
||||
|
||||
- **Hot reload / build-in-place** while a session is live (§6) — the
|
||||
`content_revision` seam is in place for it.
|
||||
- **Collaborator permissions** beyond `owner_id` — one check in §5.3.
|
||||
- **Mature-content gating** on the read path — column and index exist; the
|
||||
policy belongs to `content_moderation.md`.
|
||||
- **`world_chunk_objects`** derived index — spec'd in `world.md` §4.5, build it
|
||||
when a query needs it (§4.2).
|
||||
- **Payload compression** — the §3.6 header reserves a flags bitmask; Postgres
|
||||
TOAST already compresses `BYTEA`, so measure before adding a second layer.
|
||||
- **Chunk streaming for very large worlds** — the bounds-query cap in §8.1 is a
|
||||
guardrail, not a scaling plan.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Package Checkpoint 1 (PC1)
|
||||
|
||||
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
|
||||
clone (not a reused agent workspace).
|
||||
|
||||
## Automated validation
|
||||
|
||||
Run with `scripts/run_checkpoint_tests.py` — see
|
||||
`docs/testing/test_inventory.md` for exactly what each suite covers.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Alembic migrations, fresh DB | PASS |
|
||||
| Backend pytest | 68/68 PASS |
|
||||
| Godot smoke tests (4 files) | ALL PASS |
|
||||
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
|
||||
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
|
||||
|
||||
## Problems found during this checkpoint (fixed, tracked as tasks)
|
||||
|
||||
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
|
||||
done — a historical data gap (a review-feedback follow-up task's
|
||||
supersession link was never recorded, predating when that field started
|
||||
being tracked). Backfilled in the PM database; confirmed green.
|
||||
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
|
||||
actually 68; said no integration tests existed, 4 real ones had landed).
|
||||
Fixed via task `398bf1a22a2d` / PR #40.
|
||||
|
||||
## What's actually testable right now
|
||||
|
||||
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
|
||||
the real scope of PC1; don't expect more when playtesting.
|
||||
|
||||
- Register a new account (18+ only, rejected otherwise)
|
||||
- Log out, log back in with the same credentials
|
||||
- Play → Browse Worlds — loads a (currently empty) world list with no errors
|
||||
|
||||
## Manual playtest: backend on cloudbison, client on pinkbison
|
||||
|
||||
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
|
||||
env vars, no editing files. Foolproof enough to hand to someone else.
|
||||
|
||||
### 1. On cloudbison — the AGP dashboard
|
||||
|
||||
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
|
||||
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
|
||||
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
|
||||
the API server, all in one step. The page shows the LAN address it's
|
||||
reachable at once it's up (under a minute).
|
||||
|
||||
When you're done playtesting, click **Stop & Clean Up** on the same page —
|
||||
it tears down everything that step started (API server, Postgres
|
||||
container, its volume and network). Nothing is left running or lying
|
||||
around afterward, even if the dashboard itself gets restarted in between.
|
||||
|
||||
### 2. On pinkbison — download and run the .exe
|
||||
|
||||
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
|
||||
pick up newer changes) — this pulls the latest `main` and exports a Windows
|
||||
binary with cloudbison's backend address already baked in. Click
|
||||
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
|
||||
environment variable, no config file, no build step on pinkbison at all.
|
||||
|
||||
### 3. Tear down when done
|
||||
|
||||
**Stop & Clean Up** on the dashboard page. That's it.
|
||||
|
||||
## Status
|
||||
|
||||
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
|
||||
restricts agent claiming to the 5 PC1-scope features. Agents will not move
|
||||
on to post-PC1 work until that's widened.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Package Checkpoint 3 (PC3)
|
||||
|
||||
> **Status: partial.** PC3's planned scope was M4-avatars + M5-multiplayer +
|
||||
> Foundation Hardening + M6–M10 (see `docs/build-plan-v1.md`). As of
|
||||
> 2026-09-05, **M4, M5, and most of Foundation Hardening are done and
|
||||
> checkpoint-verified**; M6–M10 have not been started. This doc records what
|
||||
> is real. Whether to call this "PC3" (redefined scope, M6–M10 → PC4) or keep
|
||||
> PC3 open is a user decision.
|
||||
|
||||
Built by Claude directly (fleet offline), branch `pc3` → `main`.
|
||||
|
||||
## What's real and verified (`scripts/run_checkpoint_tests.py`, all green)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Alembic migrations, fresh DB | PASS |
|
||||
| Backend `pytest` | 77/77 PASS (hermetic — auto-starts an ephemeral Postgres) |
|
||||
| Godot smoke tests (~16 files) | ALL PASS |
|
||||
| `smoke_test_network_client` (real dedicated server) | 4/4 PASS |
|
||||
| 2-player multiplayer (`run_multiplayer_2p_test.sh`) | PASS |
|
||||
| Fresh-clone boot (`verify_fresh_clone.sh`) | PASS |
|
||||
| Linux + Windows exports | Build as real ELF64 / PE32+ binaries |
|
||||
|
||||
### M4 — Avatars in the game
|
||||
|
||||
- Placeholder avatar pack (14 GLBs, rigged 24-bone base body + attachments)
|
||||
generated by `scripts/tools/gen_placeholder_avatars.gd`. Real art is a
|
||||
content swap — catalog paths are stable.
|
||||
- The avatar **loader was fixed** — it had never run against a real skeleton
|
||||
(`Skeleton3D.has_bone()` isn't a Godot 4 method; the bone-copy dropped the
|
||||
skin binding; attachments were parsed then discarded).
|
||||
- `avatar_visualizer.gd` **rewritten** — the old one referenced a loader API
|
||||
that never existed and didn't compile.
|
||||
- Persistence: `avatar_loadouts` table + `GET/PUT /api/avatar/loadout`
|
||||
(loadout stored as one JSON doc; server validates the envelope only).
|
||||
- Wired into gameplay: the in-world player renders as the assembled avatar
|
||||
(not a capsule); the loadout is fetched at login; "Customize Avatar" from
|
||||
the main menu saves through to the backend.
|
||||
- **Follow-up:** camera is still first-person, so the local player doesn't
|
||||
see their own avatar much (third-person is a polish item); remote players'
|
||||
avatar appearance is not replicated yet (they show as labelled capsules).
|
||||
|
||||
### M5 — Real multiplayer
|
||||
|
||||
- `server.gd` **actually runs a sim now** — loads the world level, spawns a
|
||||
server player per peer, processes each peer's input queue in seq order at a
|
||||
fixed 30 Hz, broadcasts full snapshots with a per-peer `last_seq`. (The
|
||||
input queue was a no-op before.)
|
||||
- `network_client.gd` reconciliation **replays in-flight inputs** from the
|
||||
authoritative state (drops acked ones), snaps only on real drift.
|
||||
- **Server orchestration** (`backend/app/services/game_servers.py`): one
|
||||
headless Godot dedicated-server process per world, spawned on demand by
|
||||
`POST /api/worlds/{id}/join`, port-allocated, with a status-file idle
|
||||
reaper. Off by default; the playtest launcher (`checkpoint_ops.py`)
|
||||
enables it.
|
||||
- **Client play path**: world browser → `/join` → `Session` → `game.tscn`
|
||||
connects → local player predicts with collision, input forwarded, server
|
||||
position is a drift backstop, remote players render from snapshots,
|
||||
disconnect returns to the world list. Falls back to local single-player
|
||||
when the backend has no orchestration.
|
||||
- **Verified**: two real client processes each `register → /join → play`,
|
||||
hosted on an orchestrated server, see and move relative to each other
|
||||
(`run_game_multiplayer_test.sh`).
|
||||
|
||||
### Foundation Hardening
|
||||
|
||||
- **Hermetic backend tests** — `cd backend && pytest` works from a clean
|
||||
checkout (auto ephemeral Postgres + migrations, torn down after); skips
|
||||
cleanly with a message if there's no Docker.
|
||||
- **Fresh-clone boot check** — `verify_fresh_clone.sh` exports a pristine
|
||||
tree, imports from scratch, boots headless, fails on any script error.
|
||||
Also fixed the root cause: a stale `.godot/global_script_class_cache.cfg`
|
||||
was committed to the repo.
|
||||
- **Real age verification** — `/register` takes a date of birth (not a
|
||||
checkbox), server computes age, 403 under 18. Still self-attested; the
|
||||
limitation and the ID-check hook are documented
|
||||
(`content_moderation.md` 5.2).
|
||||
- **Auth rate limiting** — 10 attempts / min / IP on `/register` + `/login`.
|
||||
- **CI workflow** — `.forgejo/workflows/checkpoint.yml` written, **not
|
||||
running yet**: needs the operator to enable Forgejo Actions + register a
|
||||
`host` runner (one-time; see `docs/ci.md`). Interim gate is
|
||||
`run_checkpoint_tests.py` by hand / the dashboard.
|
||||
|
||||
## Not in PC3 (planned but not started)
|
||||
|
||||
- **M2** server-side world persistence
|
||||
- **M6** scripting sandbox
|
||||
- **M7** in-game editor
|
||||
- **M8** social / chat
|
||||
- **M9** economy
|
||||
- **M10** discovery
|
||||
|
||||
## Playtest
|
||||
|
||||
Same as PC2 — grab the exe from the dashboard's `/checkpoints`, backend
|
||||
pointed at it. New: with multiple people in the same world you now actually
|
||||
see each other move.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# CI
|
||||
|
||||
`.gitea/workflows/checkpoint.yml` runs the full checkpoint suite
|
||||
(`scripts/run_checkpoint_tests.py`: migrations on a fresh DB, backend
|
||||
`pytest`, all Godot smoke tests, the 2-player multiplayer test, and the
|
||||
fresh-clone boot check) on **every push to `main` and every PR**.
|
||||
|
||||
## Status: live
|
||||
|
||||
Set up 2026-09-05:
|
||||
|
||||
- Gitea Actions enabled (`[actions] ENABLED = true` in
|
||||
`/DATA/AppData/forgejo/gitea/conf/app.ini`, container restarted; a
|
||||
timestamped `.bak` is next to it).
|
||||
- `act_runner` v0.2.13 registered as **`agp-host`** with label **`host`**
|
||||
(host execution mode — jobs run directly on the AGP box, which already
|
||||
has Docker, `~/godot/godot`, and Python). Lives in `~/act-runner/`.
|
||||
- Green on the first successful run (`d76bf4d`).
|
||||
|
||||
The workflow uses **no JavaScript actions** (no `actions/checkout`), so the
|
||||
host needs no Node: checkout is a shallow `git fetch` from the local Gitea
|
||||
using the injected `${{ github.token }}`, and the suite runs in a venv the
|
||||
job builds (host Python is PEP-668 externally-managed).
|
||||
|
||||
## Make the runner permanent (one command, needs sudo)
|
||||
|
||||
The runner currently runs as a plain background process — it won't survive a
|
||||
reboot. A unit file is ready at `~/act-runner/act-runner.service`:
|
||||
|
||||
```bash
|
||||
sudo cp ~/act-runner/act-runner.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
pkill -f 'act_runner daemon' # stop the temporary background one
|
||||
sudo systemctl enable --now act-runner
|
||||
```
|
||||
|
||||
## Interim / local
|
||||
|
||||
```bash
|
||||
python scripts/run_checkpoint_tests.py
|
||||
```
|
||||
|
||||
— also what `/checkpoint/<PC>` on the dashboard runs before a playtest build.
|
||||
|
||||
## Security note
|
||||
|
||||
A host-mode runner executes whatever a workflow says, directly on the box,
|
||||
as `daddy` (in the `docker` group). Fine for a single-maintainer private
|
||||
repo; revisit if the repo gets outside contributors (switch to a
|
||||
docker-mode runner or an isolated VM).
|
||||
@@ -0,0 +1,41 @@
|
||||
# MVP Vertical Slice: Client-Server Wiring — Plan
|
||||
|
||||
## Goal
|
||||
Prove the real Godot client and the real backend can talk: **a user can register/log in
|
||||
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
|
||||
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
|
||||
explicitly out of scope.
|
||||
|
||||
## Verified backend contract (as of this writing)
|
||||
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
|
||||
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
|
||||
Rejects under-18 with `400`.
|
||||
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
|
||||
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
|
||||
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
|
||||
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
|
||||
public-worlds path, so the worlds list screen can only be shown after login/register.
|
||||
- `GET /api/health` — unauthenticated, `{status, app}`.
|
||||
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
|
||||
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
|
||||
|
||||
## Task breakdown
|
||||
| # | Type | Task | Depends on |
|
||||
|---|------|------|-----------|
|
||||
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
|
||||
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
|
||||
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
|
||||
|
||||
## Design decisions captured (resolved in task 1)
|
||||
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||
open but not required yet (no re-login on restart in this slice).
|
||||
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
|
||||
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
||||
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
||||
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
||||
|
||||
## Notes
|
||||
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
|
||||
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
|
||||
- The world-list screen also needs to be **built** (a new scene), not just wired.
|
||||
@@ -0,0 +1,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,50 @@
|
||||
# Testing at Package Checkpoints
|
||||
|
||||
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
|
||||
real milestones, the client and server builds get packaged together and
|
||||
independently reviewed before anyone calls anything actually done. See
|
||||
`project_agp` in Claude's memory, or ask, for the full definition; this
|
||||
doc is just the mechanical "how do I run the tests" part of that review.
|
||||
|
||||
## Running the suite
|
||||
|
||||
```
|
||||
python scripts/run_checkpoint_tests.py
|
||||
```
|
||||
|
||||
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
|
||||
backend pytest suite, starts the backend server, and runs every Godot
|
||||
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
|
||||
Postgres container down afterward (`--keep-postgres` to skip that if you
|
||||
want to poke at the DB after a failure).
|
||||
|
||||
If port 5432 is already taken on the machine, pass `--postgres-port` with
|
||||
something else.
|
||||
|
||||
Exit code is 0 only if everything that actually ran passed — suites with
|
||||
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
|
||||
reported SKIPPED, not treated as a failure.
|
||||
|
||||
## What this does and doesn't prove
|
||||
|
||||
Passing this script means: migrations apply cleanly to a genuinely empty
|
||||
database, the backend's unit-level logic is correct, and whatever Godot
|
||||
smoke tests exist actually round-trip against a live backend. It does
|
||||
**not** mean full endpoint/integration coverage exists — see
|
||||
`test_inventory.md` for the current, honest list of what's tested and what
|
||||
isn't. A checkpoint review should still involve reading the actual diffs
|
||||
for anything this script's current coverage doesn't reach, not just running
|
||||
green and calling it done.
|
||||
|
||||
## Growing this over time
|
||||
|
||||
As real integration tests get added (endpoint tests hitting the FastAPI app,
|
||||
more Godot smoke coverage beyond `health_check`), they should land under
|
||||
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
|
||||
picks up new files automatically, nothing to register by hand. Update
|
||||
`test_inventory.md`'s tables when you do, so the inventory stays a source
|
||||
of truth instead of quietly going stale.
|
||||
|
||||
Eventual goal (not implemented yet): gate merges to `main` on this script
|
||||
passing, the same way a real CI pipeline would. For now it's a manual step
|
||||
run at checkpoint review time.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Test Inventory
|
||||
|
||||
What automated coverage actually exists, what it actually verifies, and what
|
||||
doesn't have coverage yet. Keep this current as tests are added — it's the
|
||||
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
|
||||
to know what "run the tests" actually checked.
|
||||
|
||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||
|
||||
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
|
||||
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||
playable world + character + movement) landed 2026-08-20/21 — see
|
||||
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||
`smoke_test_enter_world.gd`) for what's actually verified. The avatar
|
||||
loader/customization system (`smoke_test_avatar_system.gd`) also landed
|
||||
2026-08-21 — real glTF-based loading with graceful failure on missing
|
||||
assets, verified since `assets/avatars/*.glb` model files don't exist yet.
|
||||
|
||||
## Backend — `backend/tests/` (pytest)
|
||||
|
||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||
reachable Postgres — see the runner script).
|
||||
|
||||
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
||||
|
||||
| File | Tests | What it covers |
|
||||
|---|---|---|
|
||||
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
|
||||
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
||||
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||
|
||||
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
|
||||
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
|
||||
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
|
||||
that hit the FastAPI app against a real Postgres database via `TestClient`.
|
||||
The integration tests exercise the full stack: Pydantic validation → DB →
|
||||
JWT → response. The suite is also re-runnable without manual cleanup —
|
||||
`backend/tests/conftest.py` has a session-scoped autouse fixture that
|
||||
truncates all app tables once before the suite runs.
|
||||
|
||||
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||
these yet):
|
||||
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
|
||||
world/asset CRUD routes — only auth endpoints have integration coverage
|
||||
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
|
||||
- No tests for the moderation schema/migration beyond "the migration runs."
|
||||
- No tests for the `GET /api/auth/me` endpoint's response body (only its
|
||||
auth-header validation is covered via expired-token tests).
|
||||
- No tests for session management (create/validate/revoke sessions).
|
||||
|
||||
## Godot client — `scripts/smoke_test_*.gd`
|
||||
|
||||
**12 smoke tests merged into `main`.** Ten need a live backend, one
|
||||
(`smoke_test_network_client.gd`) needs a live dedicated server instead,
|
||||
and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises
|
||||
local catalog/loadout/loader logic. Backend-dependent tests read
|
||||
`AGP_API_BASE_URL` env var, falling back to the
|
||||
`application/agp/api_base_url` project setting (default
|
||||
`http://127.0.0.1:8000`) if unset.
|
||||
|
||||
| File | What it covers |
|
||||
|---|---|
|
||||
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
|
||||
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
|
||||
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
|
||||
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
|
||||
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
|
||||
|
||||
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||
then for each smoke test: `godot --headless --path . --script
|
||||
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||
their awaited HTTP calls finish.
|
||||
|
||||
**Known gaps:**
|
||||
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||
- No smoke tests for dynamic/static object nodes.
|
||||
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
|
||||
ones that exercise a real `.tscn` scene instantiation — others only test
|
||||
the `ApiClient` autoload directly.
|
||||
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
|
||||
has no smoke coverage yet.
|
||||
- The Login path (as opposed to Register) through `main_menu.gd` isn't
|
||||
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
|
||||
`_on_auth_complete()` code path already exercised for Register, but that's
|
||||
inference, not a direct check.
|
||||
|
||||
## Client export
|
||||
|
||||
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
|
||||
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
|
||||
exports complete clean from a fresh checkout and produce valid binaries
|
||||
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
|
||||
export is exercised by an automated GUI test (headless Godot can't drive a
|
||||
window) — actually launching and clicking through the exported binary is a
|
||||
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
|
||||
|
||||
## What "full coverage" should eventually mean here
|
||||
|
||||
Not defined precisely yet — the honest target as of 2026-08-20 is
|
||||
"integration tests for every endpoint + smoke tests for every major scene."
|
||||
As real integration/endpoint tests get added, update the tables above
|
||||
rather than letting this doc drift stale; a wrong inventory is worse than
|
||||
no inventory.
|
||||
@@ -0,0 +1,74 @@
|
||||
[preset.0]
|
||||
|
||||
name="Linux"
|
||||
platform="Linux/X11"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="*.json"
|
||||
exclude_filter=""
|
||||
export_path="export/linux/agp-game.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.0.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
ssh_remote_deploy/enabled=false
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Windows Desktop"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="*.json"
|
||||
exclude_filter=""
|
||||
export_path="export/windows/agp-game.exe"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
codesign/enable=false
|
||||
application/icon=""
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version=""
|
||||
application/product_version=""
|
||||
application/company_name=""
|
||||
application/product_name=""
|
||||
application/file_description=""
|
||||
application/copyright=""
|
||||
application/trademarks=""
|
||||
application/export_angle=0
|
||||
application/export_d3d12=0
|
||||
d3d12/agility_sdk_multizip=0
|
||||
@@ -9,15 +9,54 @@ config_version=5
|
||||
config/name="AGP-Game"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6.2")
|
||||
agp/api_base_url="http://127.0.0.1:8000"
|
||||
|
||||
[autoload]
|
||||
|
||||
ApiClient="*res://scripts/api_client.gd"
|
||||
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||
Session="*res://scripts/session.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/stretch/mode="canvas_items"
|
||||
|
||||
[input]
|
||||
|
||||
move_forward={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_left={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_right={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_down={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
interact={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[physics]
|
||||
|
||||
; Fixed 30 Hz sim -- the multiplayer server, client prediction, and snapshot
|
||||
; rate all key off this. See docs/architecture/networking.md 3.1.
|
||||
common/physics_ticks_per_second=30
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://avatar_customization"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/avatar_customization_screen.gd" id="1"]
|
||||
|
||||
[node name="AvatarCustomization" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user