14 Commits
Author SHA1 Message Date
admin 94ffe6e7b7 Add versioned legal consent to authentication
Deploy Promiscuity Auth API / deploy (push) Successful in 1m29s
k8s smoke test / test (push) Successful in 20s
2026-07-20 02:16:02 -05:00
admin 4121c59484 Reduce auth image restore disk usage
k8s smoke test / test (push) Failing after 20s
Deploy Promiscuity Auth API / deploy (push) Successful in 1m57s
2026-07-20 01:17:48 -05:00
admin c267b62afa Prevent deploy runner disk exhaustion
Deploy Promiscuity Auth API / deploy (push) Has been cancelled
k8s smoke test / test (push) Has been cancelled
2026-07-20 01:15:13 -05:00
admin cde0e28e44 Add Steam OpenID authentication
Deploy Promiscuity Auth API / deploy (push) Failing after 20s
Deploy Promiscuity Character API / deploy (push) Failing after 19s
Deploy Promiscuity Crafting API / deploy (push) Failing after 20s
Deploy Promiscuity Inventory API / deploy (push) Failing after 19s
Deploy Promiscuity Locations API / deploy (push) Failing after 19s
Deploy Promiscuity Mail API / deploy (push) Failing after 20s
Deploy Promiscuity World API / deploy (push) Failing after 20s
k8s smoke test / test (push) Failing after 20s
2026-07-20 00:30:06 -05:00
admin 18c3d6fc5d Add self-service profile and account deletion APIs
Deploy Promiscuity Auth API / deploy (push) Successful in 1m28s
Deploy Promiscuity Character API / deploy (push) Successful in 1m24s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m20s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m24s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m25s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m20s
Deploy Promiscuity World API / deploy (push) Successful in 1m25s
k8s smoke test / test (push) Successful in 20s
2026-07-19 13:39:24 -05:00
admin 942cd59db8 Implmented first/third person toggle via V
Deploy Promiscuity Auth API / deploy (push) Successful in 1m25s
Deploy Promiscuity Character API / deploy (push) Successful in 1m23s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m19s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m25s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m26s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m21s
Deploy Promiscuity World API / deploy (push) Successful in 1m21s
k8s smoke test / test (push) Successful in 20s
2026-07-19 12:23:23 -05:00
admin 7faef2e976 Harden public account registration
Deploy Promiscuity Auth API / deploy (push) Successful in 1m13s
Deploy Promiscuity Character API / deploy (push) Successful in 59s
Deploy Promiscuity Crafting API / deploy (push) Successful in 58s
Deploy Promiscuity Inventory API / deploy (push) Successful in 58s
Deploy Promiscuity Locations API / deploy (push) Successful in 59s
Deploy Promiscuity Mail API / deploy (push) Successful in 59s
Deploy Promiscuity World API / deploy (push) Successful in 59s
k8s smoke test / test (push) Successful in 20s
2026-07-11 12:39:02 -05:00
null 7f21b40d93 Fix scroll wheel zoom, persist zoom, consolidate player scripts, and migrate GEMINI.md to .agents/AGENTS.md
Deploy Promiscuity Auth API / deploy (push) Successful in 1m23s
Deploy Promiscuity Character API / deploy (push) Successful in 1m1s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m0s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m1s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m2s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m2s
Deploy Promiscuity World API / deploy (push) Successful in 59s
k8s smoke test / test (push) Successful in 20s
2026-06-22 20:07:39 -05:00
admin 3b21cf3182 Adding generated sounds to car
Deploy Promiscuity Auth API / deploy (push) Successful in 1m14s
Deploy Promiscuity Character API / deploy (push) Successful in 1m14s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m14s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m13s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m13s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m13s
Deploy Promiscuity World API / deploy (push) Successful in 1m13s
k8s smoke test / test (push) Successful in 35s
2026-06-19 06:25:16 -05:00
pillboxstyx 9e3df6c691 Stealing a cooler car
Deploy Promiscuity Auth API / deploy (push) Successful in 1m36s
Deploy Promiscuity Character API / deploy (push) Successful in 1m15s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m14s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m14s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m16s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m30s
Deploy Promiscuity World API / deploy (push) Successful in 2m6s
k8s smoke test / test (push) Successful in 38s
2026-06-12 21:33:19 -05:00
null 2cb8a7fb8f Update mushroom pickup sound to use pop.ogg
Deploy Promiscuity Auth API / deploy (push) Successful in 59s
Deploy Promiscuity Character API / deploy (push) Successful in 1m0s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m0s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m0s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m0s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m0s
Deploy Promiscuity World API / deploy (push) Successful in 59s
k8s smoke test / test (push) Successful in 21s
2026-05-28 18:35:57 -05:00
null 09e6b4cc90 Add interactive mushroom pickup and highlight mechanics with pop sound
Deploy Promiscuity Auth API / deploy (push) Successful in 1m1s
Deploy Promiscuity Character API / deploy (push) Has been cancelled
Deploy Promiscuity Crafting API / deploy (push) Has been cancelled
Deploy Promiscuity Inventory API / deploy (push) Has been cancelled
Deploy Promiscuity Locations API / deploy (push) Has been cancelled
Deploy Promiscuity Mail API / deploy (push) Has been cancelled
Deploy Promiscuity World API / deploy (push) Has been cancelled
k8s smoke test / test (push) Has been cancelled
2026-05-28 18:34:35 -05:00
admin e6a442c98b Merge branch 'main' of https://git.ranaze.com/null/promiscuity into main
Deploy Promiscuity Auth API / deploy (push) Successful in 1m12s
Deploy Promiscuity Character API / deploy (push) Successful in 1m27s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m23s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m28s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m12s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m22s
Deploy Promiscuity World API / deploy (push) Successful in 1m22s
k8s smoke test / test (push) Successful in 22s
2026-05-21 18:31:00 -05:00
admin cce8fd3280 Adding ember character model to art directory 2026-05-21 18:30:32 -05:00
84 changed files with 2219 additions and 388 deletions
View File
+28 -1
View File
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
paths:
- "microservices/AuthApi/**"
- ".gitea/workflows/deploy-auth.yml"
workflow_dispatch: {}
jobs:
@@ -20,6 +23,16 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v4
- name: Reclaim Docker build space
run: |
rm -f "${IMAGE_TAR}"
docker container prune -f
docker image prune -f
docker image prune -af --filter "until=24h"
docker builder prune -af || true
docker system df
df -h
# -----------------------------
# Build Docker image
# -----------------------------
@@ -59,18 +72,22 @@ jobs:
# CLEANUP: delete TAR from nodes
# -----------------------------
- name: Clean TAR from nodes
if: always()
run: |
for node in ${NODES}; do
echo "Removing image tar on $node ..."
ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-auth.tar"
ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-auth.tar" || true
done
# -----------------------------
# CLEANUP: delete TAR from runner
# -----------------------------
- name: Clean TAR on runner
if: always()
run: |
rm -f "${IMAGE_TAR}"
docker image rm "${IMAGE_NAME}" 2>/dev/null || true
docker image prune -f
# -----------------------------
# Write kubeconfig from secret
@@ -91,6 +108,16 @@ jobs:
run: |
kubectl create namespace promiscuity-auth --dry-run=client -o yaml | kubectl apply -f -
- name: Configure internal API secret
env:
KUBECONFIG: /tmp/kube/config
INTERNAL_PURGE_SECRET: ${{ secrets.INTERNAL_PURGE_SECRET }}
run: |
test -n "$INTERNAL_PURGE_SECRET"
kubectl create secret generic medmind-internal-api \
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
--dry-run=client -o yaml | kubectl apply -n promiscuity-auth -f -
# -----------------------------
# Apply Kubernetes manifests
# (You create these files in your repo)
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- "microservices/CharacterApi/**"
workflow_dispatch: {}
jobs:
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- "microservices/CraftingApi/**"
workflow_dispatch: {}
jobs:
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- "microservices/InventoryApi/**"
workflow_dispatch: {}
jobs:
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- "microservices/LocationsApi/**"
workflow_dispatch: {}
jobs:
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- "microservices/MailApi/**"
workflow_dispatch: {}
jobs:
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- "microservices/WorldApi/**"
workflow_dispatch: {}
jobs:
+2
View File
@@ -22,3 +22,5 @@
- Fireball: Shift + B
- Phone: Tab
- Pause menu: Esc
- Toggle first/third person: V
- Zoom (in/out): Scroll Wheel (snaps to First Person on zoom in)
Binary file not shown.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dgt3nox7yak72"
path="res://.godot/imported/pop.ogg-e15d202808be009bef5e04746dba8616.oggvorbisstr"
[deps]
source_file="res://assets/audio/pop.ogg"
dest_files=["res://.godot/imported/pop.ogg-e15d202808be009bef5e04746dba8616.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://b2e0ls6ymw45i"
path="res://.godot/imported/car_brake.wav-359538a9b1ba4240ae07e54477629dbb.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_brake.wav"
dest_files=["res://.godot/imported/car_brake.wav-359538a9b1ba4240ae07e54477629dbb.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dlg100ft5f2jy"
path="res://.godot/imported/car_engine_accel_loop.wav-25b55bfc6e1d41b17513ac3dd914f463.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_engine_accel_loop.wav"
dest_files=["res://.godot/imported/car_engine_accel_loop.wav-25b55bfc6e1d41b17513ac3dd914f463.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=1
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cljhbkwcswalc"
path="res://.godot/imported/car_engine_drive_loop.wav-be0d54cca48b174deaff89a90d5364c3.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_engine_drive_loop.wav"
dest_files=["res://.godot/imported/car_engine_drive_loop.wav-be0d54cca48b174deaff89a90d5364c3.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=1
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c28v6t3txgn8w"
path="res://.godot/imported/car_engine_idle_loop.wav-56323a2fbc32742f6519b3630f97c275.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_engine_idle_loop.wav"
dest_files=["res://.godot/imported/car_engine_idle_loop.wav-56323a2fbc32742f6519b3630f97c275.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=1
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://bhmyl3ie10u53"
path="res://.godot/imported/car_enter.wav-aeff3918a465be3f557bb484cf84ed62.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_enter.wav"
dest_files=["res://.godot/imported/car_enter.wav-aeff3918a465be3f557bb484cf84ed62.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://bngm3s2yq2xwi"
path="res://.godot/imported/car_exit.wav-f78ac0b868ae38aeb13d15881f6c3491.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_exit.wav"
dest_files=["res://.godot/imported/car_exit.wav-f78ac0b868ae38aeb13d15881f6c3491.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://bq2d35mekg4il"
path="res://.godot/imported/car_hit.wav-5f5ca7b335ab69bea356887a03a1eacc.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_hit.wav"
dest_files=["res://.godot/imported/car_hit.wav-5f5ca7b335ab69bea356887a03a1eacc.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://y38ybkt3uxik"
path="res://.godot/imported/car_horn.wav-4f978ca1d198f5c6c4a5fcfab9ca1ccc.sample"
[deps]
source_file="res://assets/audio/vehicles/car/car_horn.wav"
dest_files=["res://.godot/imported/car_horn.wav-4f978ca1d198f5c6c4a5fcfab9ca1ccc.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
@@ -0,0 +1,21 @@
Copyright (c) 2023 Manik Sharma.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Car Demo by Manik Sharma
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://civr7hlbf2kqb"
path="res://.godot/imported/Wheel.glb-dddddd96691989c3e912750c9a47a858.scn"
[deps]
source_file="res://assets/models/vehicles/doge/Wheel.glb"
dest_files=["res://.godot/imported/Wheel.glb-dddddd96691989c3e912750c9a47a858.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.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bpklxkc61avg6"
path.s3tc="res://.godot/imported/Wheel_RIMMUSCLE_01_DIF.png-5bb5174c5332f74f16abee062d1ed24a.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "cd5730d572d3e9053a526f83b6971226"
}
[deps]
source_file="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_DIF.png"
dest_files=["res://.godot/imported/Wheel_RIMMUSCLE_01_DIF.png-5bb5174c5332f74f16abee062d1ed24a.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://djs3o5hanxb70"
path.s3tc="res://.godot/imported/Wheel_RIMMUSCLE_01_MET-RIMMUSCLE_01_ROUGH.png-bf036ae2ba8ef4208853e0caa49ac9e7.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "ee91568e12f40d7fce962016fe4791c0"
}
[deps]
source_file="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_MET-RIMMUSCLE_01_ROUGH.png"
dest_files=["res://.godot/imported/Wheel_RIMMUSCLE_01_MET-RIMMUSCLE_01_ROUGH.png-bf036ae2ba8ef4208853e0caa49ac9e7.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cxufd33f0bqsx"
path.s3tc="res://.godot/imported/Wheel_RIMMUSCLE_01_NRM.png-3149169313dea5a53591f1b5ae2411f6.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "7d86721466688c1cd90634fe6d2f953a"
}
[deps]
source_file="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_NRM.png"
dest_files=["res://.godot/imported/Wheel_RIMMUSCLE_01_NRM.png-3149169313dea5a53591f1b5ae2411f6.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=1
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=1
roughness/src_normal="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_NRM.png"
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dvgiooankbnw3"
path="res://.godot/imported/doge-body.glb-d066dc7fd60eda6d05736bc65e3c5ffe.scn"
[deps]
source_file="res://assets/models/vehicles/doge/doge-body.glb"
dest_files=["res://.godot/imported/doge-body.glb-d066dc7fd60eda6d05736bc65e3c5ffe.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.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dx401inrlplo1"
path.s3tc="res://.godot/imported/doge-body_GEN4DR_INTERIOR_dif.png-b5a1b7fcdb7b3160f7a0ef7e265e4505.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "b3caf55dd0d9e1e05294308101435166"
}
[deps]
source_file="res://assets/models/vehicles/doge/doge-body_GEN4DR_INTERIOR_dif.png"
dest_files=["res://.godot/imported/doge-body_GEN4DR_INTERIOR_dif.png-b5a1b7fcdb7b3160f7a0ef7e265e4505.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c63xvtkbodiob"
path.s3tc="res://.godot/imported/doge-body_GENERIC_BADGES_DIF.png-796b015ef1e73a8329f18564cd4000af.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "f8fce006f08ef57c4bb09b493a6126d8"
}
[deps]
source_file="res://assets/models/vehicles/doge/doge-body_GENERIC_BADGES_DIF.png"
dest_files=["res://.godot/imported/doge-body_GENERIC_BADGES_DIF.png-796b015ef1e73a8329f18564cd4000af.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://e5okom0atmrt"
path.s3tc="res://.godot/imported/doge-body_Numberplates_dif.png-c03cf9c73e5d679f02921a9bb6c29d63.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "da3da05f00d3caef6299f64ac091b52f"
}
[deps]
source_file="res://assets/models/vehicles/doge/doge-body_Numberplates_dif.png"
dest_files=["res://.godot/imported/doge-body_Numberplates_dif.png-c03cf9c73e5d679f02921a9bb6c29d63.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2sf47hajdi3w"
path.s3tc="res://.godot/imported/doge-body_Riverside88_lights_dif.png-05ac8d7af58447f0ddebfe66c3707ac9.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "c3cf8ceff55bcc14da25561e2939d3be"
}
[deps]
source_file="res://assets/models/vehicles/doge/doge-body_Riverside88_lights_dif.png"
dest_files=["res://.godot/imported/doge-body_Riverside88_lights_dif.png-05ac8d7af58447f0ddebfe66c3707ac9.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c83x8syn3olne"
path.s3tc="res://.godot/imported/doge-body_UCB_BOTTOM_DIF.png-cfc19977dbe1d864173f653d08cb0672.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "0eabb927a9e9ac2e0de40610dbadc3a6"
}
[deps]
source_file="res://assets/models/vehicles/doge/doge-body_UCB_BOTTOM_DIF.png"
dest_files=["res://.godot/imported/doge-body_UCB_BOTTOM_DIF.png-cfc19977dbe1d864173f653d08cb0672.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://crxpnsmfsorf5"
path.s3tc="res://.godot/imported/doge-body_UCB_BOTTOM_nrm.png-b5e3eff64de1f5b644e5d70c8b7bfe37.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "ce8201694c51a8565f01b6f7688262bf"
}
[deps]
source_file="res://assets/models/vehicles/doge/doge-body_UCB_BOTTOM_nrm.png"
dest_files=["res://.godot/imported/doge-body_UCB_BOTTOM_nrm.png-b5e3eff64de1f5b644e5d70c8b7bfe37.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=1
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=1
roughness/src_normal="res://assets/models/vehicles/doge/doge-body_UCB_BOTTOM_nrm.png"
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dxcjtnnch0sxr"
path.s3tc="res://.godot/imported/doge-body_UCB_GLASS_CLEAN_dif.png-423a718e8cd643fb68b5ef6cfd665641.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "f8a5958ba2b852aaf7e4733c7ca90a83"
}
[deps]
source_file="res://assets/models/vehicles/doge/doge-body_UCB_GLASS_CLEAN_dif.png"
dest_files=["res://.godot/imported/doge-body_UCB_GLASS_CLEAN_dif.png-423a718e8cd643fb68b5ef6cfd665641.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
+5
View File
@@ -116,6 +116,11 @@ player_phone={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
player_toggle_perspective={
"deadzone": 0.2,
"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":86,"key_label":0,"unicode":118,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.2,
"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)
-273
View File
@@ -1,273 +0,0 @@
extends RigidBody3D
# Initially I used a CharacterBody3D, however, I wanted the player to bounce off
# other objects in the environment and that would have required manual handling
# of collisions. So that's why we're using a RigidBody3D instead.
signal vehicle_entered(vehicle: Node)
signal vehicle_exited(vehicle: Node)
const MOVE_SPEED := 8.0
const SPRINT_MOVE_SPEED :=13
const ACCELLERATION := 30.0
const DECELLERATION := 40.0
const JUMP_SPEED := 4.0
const MAX_NUMBER_OF_JUMPS := 2
const MIN_FOV := 10.0
const MAX_FOV := 179.0
const ZOOM_FACTOR := 1.1 # Zoom out when >1, in when < 1
const FIREBALL_SPAWN_FORWARD_OFFSET := 1.5
const FIREBALL_SPAWN_UP_OFFSET := 1.2
var mouse_sensitivity := 0.005
var rotation_x := 0.0
var rotation_y := 0.0
var current_number_of_jumps := 0
var _pending_mouse_delta := Vector2.ZERO
var _last_move_forward := Vector3(0, 0, 1)
var _last_move_right := Vector3(1, 0, 0)
var _camera_offset_local := Vector3.ZERO
var _camera_yaw := 0.0
var _camera_pitch := 0.0
var _in_vehicle := false
var _vehicle_collision_layer := 0
var _vehicle_collision_mask := 0
var _vehicle_original_parent: Node = null
var _light_was_on := false
var _jump_triggered := false
@onready var _flashlight: SpotLight3D = $SpotLight3D
@onready var _anim_player: AnimationPlayer = find_child("AnimationPlayer", true, false) as AnimationPlayer
@onready var _anim_tree: AnimationTree = find_child("AnimationTree", true, false) as AnimationTree
@onready var _model_root: Node3D = find_child("TestCharAnimated", true, false) as Node3D
@export var camera_follow_speed := 10.0
@export var anim_idle_name := "Idle"
@export var anim_walk_name := "Walk"
@export var anim_jump_name := "Jump"
@export var anim_run_name := "Run"
@export var anim_walk_speed_threshold := 0.25
@export var anim_sprint_speed_threshold := 10.0
var jump_sound = preload("res://assets/audio/jump.ogg")
var fireball_scene = preload("res://scenes/Projectiles/fireball.tscn")
var audio_player = AudioStreamPlayer.new()
@export var camera_path: NodePath
@onready var cam: Camera3D = get_node(camera_path) if camera_path != NodePath("") else null
@export var phone_path: NodePath
@onready var phone: CanvasLayer = get_node(phone_path) if phone_path != NodePath("") else null
var phone_visible := false
func _ready() -> void:
add_to_group("player")
if _anim_tree:
_anim_tree.active = false
axis_lock_angular_x = true
axis_lock_angular_z = true
angular_damp = 6.0
contact_monitor = true
max_contacts_reported = 4
add_child(audio_player)
audio_player.stream = jump_sound
audio_player.volume_db = -20
if cam:
_camera_offset_local = cam.transform.origin
_camera_pitch = cam.rotation.x
_camera_yaw = global_transform.basis.get_euler().y
cam.set_as_top_level(true)
cam.global_position = global_position + (Basis(Vector3.UP, _camera_yaw) * _camera_offset_local)
cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
var move_basis := cam.global_transform.basis if cam else global_transform.basis
var forward := move_basis.z
var right := move_basis.x
forward.y = 0.0
right.y = 0.0
if forward.length() > 0.0001:
_last_move_forward = forward.normalized()
if right.length() > 0.0001:
_last_move_right = right.normalized()
_vehicle_collision_layer = collision_layer
_vehicle_collision_mask = collision_mask
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _integrate_forces(state):
if _in_vehicle:
linear_velocity = Vector3.ZERO
return
if _pending_mouse_delta != Vector2.ZERO:
rotation_x -= _pending_mouse_delta.y * mouse_sensitivity
rotation_y -= _pending_mouse_delta.x * mouse_sensitivity
rotation_x = clamp(rotation_x, deg_to_rad(-90), deg_to_rad(90))
_camera_pitch = rotation_x
rotation.y = rotation_y
_pending_mouse_delta = Vector2.ZERO
var input2v := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var forward := Vector3.FORWARD * -1.0
var right := Vector3.RIGHT
if cam:
forward = cam.global_transform.basis.z
right = cam.global_transform.basis.x
forward.y = 0.0
right.y = 0.0
if forward.length() > 0.0001:
forward = forward.normalized()
_last_move_forward = forward
else:
forward = _last_move_forward
if right.length() > 0.0001:
right = right.normalized()
_last_move_right = right
else:
right = _last_move_right
var dir := (right * input2v.x + forward * input2v.y).normalized()
var target_v := dir * MOVE_SPEED
if Input.is_key_pressed(KEY_SHIFT):
target_v = dir * SPRINT_MOVE_SPEED
var ax := ACCELLERATION if dir != Vector3.ZERO else DECELLERATION
linear_velocity.x = move_toward(linear_velocity.x, target_v.x, ax * state.step)
linear_velocity.z = move_toward(linear_velocity.z, target_v.z, ax * state.step)
var on_floor = false
for i in state.get_contact_count():
var normal = state.get_contact_local_normal(i)
if normal.y > 0.5:
on_floor = true
break
if Input.is_action_just_pressed("ui_accept") and (on_floor or current_number_of_jumps == 1):
current_number_of_jumps = (current_number_of_jumps + 1) % 2
linear_velocity.y = JUMP_SPEED
audio_player.play()
_jump_triggered = true
if cam:
var target_yaw := global_transform.basis.get_euler().y
_camera_yaw = lerp_angle(_camera_yaw, target_yaw, camera_follow_speed * state.step)
var target_basis := Basis(Vector3.UP, _camera_yaw)
var target_pos := global_position + (target_basis * _camera_offset_local)
cam.global_position = cam.global_position.lerp(target_pos, camera_follow_speed * state.step)
cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
_update_animation(on_floor, state.linear_velocity)
_jump_triggered = false
func _input(event):
if event.is_action_pressed("player_phone"):
phone_visible = !phone_visible
if phone:
phone.visible = phone_visible
if phone_visible:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
return
if _in_vehicle:
return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
_pending_mouse_delta += event.relative
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom_camera(1.0 / ZOOM_FACTOR)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom_camera(ZOOM_FACTOR)
if event.is_action_pressed("player_fireball"):
shoot_fireball()
return
if event.is_action_pressed("player_light"):
_flashlight.visible = !_flashlight.visible
func zoom_camera(factor):
if cam == null:
return
var new_fov = cam.fov * factor
cam.fov = clamp(new_fov, MIN_FOV, MAX_FOV)
func shoot_fireball() -> void:
var direction := get_look_direction()
var origin := get_look_origin() + direction * FIREBALL_SPAWN_FORWARD_OFFSET
var fireball := fireball_scene.instantiate()
get_tree().current_scene.add_child(fireball)
fireball.launch(origin, direction)
func _update_animation(on_floor: bool, velocity: Vector3) -> void:
if _anim_player == null:
return
var horizontal_speed := Vector3(velocity.x, 0.0, velocity.z).length()
if _jump_triggered and _anim_player.has_animation(anim_jump_name):
if _anim_player.current_animation != anim_jump_name:
_anim_player.play(anim_jump_name)
return
if not on_floor and _anim_player.has_animation(anim_jump_name):
if _anim_player.current_animation != anim_jump_name:
_anim_player.play(anim_jump_name)
return
if on_floor and horizontal_speed > anim_sprint_speed_threshold and _anim_player.has_animation(anim_run_name):
if _anim_player.current_animation != anim_run_name:
_anim_player.play(anim_run_name)
return
if horizontal_speed > anim_walk_speed_threshold and _anim_player.has_animation(anim_walk_name):
if _anim_player.current_animation != anim_walk_name:
_anim_player.play(anim_walk_name)
return
if _anim_player.has_animation(anim_idle_name):
if _anim_player.current_animation != anim_idle_name:
_anim_player.play(anim_idle_name)
func enter_vehicle(_vehicle: Node, seat: Node3D, vehicle_camera: Camera3D) -> void:
_in_vehicle = true
freeze = true
sleeping = true
collision_layer = 0
collision_mask = 0
_vehicle_original_parent = get_parent()
_light_was_on = _flashlight.visible
_flashlight.visible = false
if _model_root:
_model_root.visible = false
if seat:
reparent(seat, true)
global_transform = seat.global_transform
if cam:
cam.current = false
if vehicle_camera:
vehicle_camera.current = true
vehicle_entered.emit(_vehicle)
func exit_vehicle(exit_point: Node3D, vehicle_camera: Camera3D) -> void:
_in_vehicle = false
freeze = false
sleeping = false
collision_layer = _vehicle_collision_layer
collision_mask = _vehicle_collision_mask
if _vehicle_original_parent:
reparent(_vehicle_original_parent, true)
_vehicle_original_parent = null
_flashlight.visible = _light_was_on
if _model_root:
_model_root.visible = true
if exit_point:
global_transform = exit_point.global_transform
if vehicle_camera:
vehicle_camera.current = false
if cam:
cam.current = true
vehicle_exited.emit(null)
func is_in_vehicle() -> bool:
return _in_vehicle
func get_look_origin() -> Vector3:
return global_position + Vector3.UP * FIREBALL_SPAWN_UP_OFFSET
func get_look_direction() -> Vector3:
if cam:
return (-cam.global_transform.basis.z).normalized()
return (-global_basis.z).normalized()
@@ -1 +0,0 @@
uid://kgqaeqappow3
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scenes/Characters/location_player.gd" id="1_player_script"]
[ext_resource type="Script" path="res://scenes/player.gd" id="1_player_script"]
[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="2_model"]
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
+114
View File
@@ -6,11 +6,20 @@ extends Node3D
@export var max_mushrooms_in_radius := 3
@export var check_radius := 1.5
@export var max_total_mushrooms := 50
@export var interact_radius := 2.0
@export var look_at_threshold := 0.95
static var total_mushrooms := 0
@onready var spread_timer: Timer = $SpreadTimer
@onready var ground_ray: RayCast3D = $GroundRay
@onready var cap: MeshInstance3D = $Cap
@onready var stem: MeshInstance3D = $Stem
var _player_in_range := false
var _is_highlighted := false
var highlight_mat: StandardMaterial3D
var _audio_player: AudioStreamPlayer3D
func _ready() -> void:
total_mushrooms += 1
@@ -20,6 +29,111 @@ func _ready() -> void:
scale = Vector3.ONE * randf_range(0.8, 1.2)
rotation.y = randf_range(0, TAU)
# Setup interaction area
var area = Area3D.new()
add_child(area)
var shape = CollisionShape3D.new()
var sphere = SphereShape3D.new()
sphere.radius = interact_radius
shape.shape = sphere
area.add_child(shape)
area.body_entered.connect(_on_body_entered)
area.body_exited.connect(_on_body_exited)
highlight_mat = StandardMaterial3D.new()
highlight_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
highlight_mat.albedo_color = Color(1.0, 1.0, 1.0, 0.3)
highlight_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_audio_player = AudioStreamPlayer3D.new()
_audio_player.stream = preload("res://assets/audio/pop.ogg")
add_child(_audio_player)
func _process(_delta: float) -> void:
if not _player_in_range:
_set_highlight(false)
return
var cam = get_viewport().get_camera_3d()
if not cam:
_set_highlight(false)
return
var dir_to_mushroom = (global_position - cam.global_position).normalized()
var forward = -cam.global_transform.basis.z
var my_dot = forward.dot(dir_to_mushroom)
if my_dot > look_at_threshold:
# Check if we are the most directly looked-at mushroom in range
var is_best = true
for mushroom in get_tree().get_nodes_in_group("mushrooms"):
if mushroom == self or not mushroom.get("_player_in_range"):
continue
var m_dir = (mushroom.global_position - cam.global_position).normalized()
var m_dot = forward.dot(m_dir)
if m_dot > my_dot:
is_best = false
break
elif m_dot == my_dot and mushroom.get_instance_id() > get_instance_id():
# Tie breaker
is_best = false
break
_set_highlight(is_best)
else:
_set_highlight(false)
func _set_highlight(value: bool) -> void:
if _is_highlighted == value: return
_is_highlighted = value
if value:
if cap: cap.material_overlay = highlight_mat
if stem: stem.material_overlay = highlight_mat
else:
if cap: cap.material_overlay = null
if stem: stem.material_overlay = null
func _on_body_entered(body: Node3D) -> void:
if body.is_in_group("player"):
_player_in_range = true
func _on_body_exited(body: Node3D) -> void:
if body.is_in_group("player"):
_player_in_range = false
_set_highlight(false)
func _unhandled_input(event: InputEvent) -> void:
if _is_highlighted and event.is_action_pressed("interact"):
get_viewport().set_input_as_handled()
_collect_mushroom()
func _collect_mushroom() -> void:
# Disable interaction and visuals
_is_highlighted = false
_set_highlight(false)
_player_in_range = false
if cap: cap.visible = false
if stem: stem.visible = false
# Stop spreading logic
spread_timer.stop()
# Disable processing so it can't be interacted with again
set_process_unhandled_input(false)
set_process(false)
# Play sound
_audio_player.play()
# Wait for sound to finish, then free
await _audio_player.finished
queue_free()
func _setup_timer() -> void:
spread_timer.wait_time = randf_range(spread_interval_min, spread_interval_max)
spread_timer.start()
@@ -1,6 +1,9 @@
extends Node
var _requested_spawn_name: StringName = &""
var camera_zoom_dist: float = 3.0
var third_person_camera_zoom_dist: float = 3.0
var camera_is_first_person: bool = false
func request_spawn(spawn_name: StringName) -> void:
+31 -32
View File
@@ -1,8 +1,8 @@
[gd_scene load_steps=15 format=3 uid="uid://b7p7k1i4t0m2l"]
[gd_scene format=3 uid="uid://b7p7k1i4t0m2l"]
[ext_resource type="Script" path="res://scenes/Levels/transportation_level.gd" id="1_6y4q1"]
[ext_resource type="Script" path="res://scenes/player.gd" id="2_player"]
[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
[ext_resource type="Script" uid="uid://c2vm651r4nepy" path="res://scenes/Levels/transportation_level.gd" id="1_6y4q1"]
[ext_resource type="Script" uid="uid://bpxggc8nr6tf6" path="res://scenes/player.gd" id="2_player"]
[ext_resource type="PackedScene" uid="uid://bnqaqbgynoyys" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
[ext_resource type="PackedScene" path="res://scenes/Interaction/prototype_gateway.tscn" id="4_teleporter"]
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_ground_green.tres" id="5_ground_mat"]
[ext_resource type="PackedScene" uid="uid://dp6jk0k3o4v1u" path="res://scenes/UI/pause_menu.tscn" id="6_pause_menu"]
@@ -11,8 +11,6 @@
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_prop_red.tres" id="9_checkpoint_mat"]
[ext_resource type="PackedScene" uid="uid://bnwpu7p8sbsfa" path="res://scenes/Interaction/RadialCommandMenu.tscn" id="10_radial_menu"]
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
size = Vector3(1080, 2, 1080)
@@ -20,6 +18,8 @@ size = Vector3(1080, 2, 1080)
material = ExtResource("5_ground_mat")
size = Vector3(1080, 2, 1080)
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
[sub_resource type="SphereShape3D" id="SphereShape3D_checkpoint"]
[sub_resource type="SphereMesh" id="SphereMesh_checkpoint"]
@@ -27,83 +27,83 @@ material = ExtResource("9_checkpoint_mat")
radius = 1.2
height = 2.4
[node name="TransportationLevel" type="Node3D"]
[node name="TransportationLevel" type="Node3D" unique_id=81192309]
script = ExtResource("1_6y4q1")
[node name="Ground" type="StaticBody3D" parent="."]
[node name="Ground" type="StaticBody3D" parent="." unique_id=1298491765]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground" unique_id=626099188]
shape = SubResource("BoxShape3D_ground")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground" unique_id=1132270788]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00053596497, 0.0075991154, -0.0019865036)
mesh = SubResource("BoxMesh_ground")
[node name="Player" type="RigidBody3D" parent="."]
[node name="Player" type="RigidBody3D" parent="." unique_id=1788131730]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
script = ExtResource("2_player")
camera_path = NodePath("Camera3D")
phone_path = NodePath("../PhoneUI")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1895789906]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
shape = SubResource("SphereShape3D_player")
[node name="TestCharAnimated" parent="Player" instance=ExtResource("3_model")]
[node name="TestCharAnimated" parent="Player" unique_id=557318212 instance=ExtResource("3_model")]
transform = Transform3D(-0.9998549, 0, 0.01703362, 0, 1, 0, -0.01703362, 0, -0.9998549, 0, 0, 0)
[node name="Camera3D" type="Camera3D" parent="Player"]
[node name="Camera3D" type="Camera3D" parent="Player" unique_id=1290959549]
transform = Transform3D(0.9989785, -4.651856e-10, -0.045188628, 0.006969331, 0.9880354, 0.15407, 0.044647958, -0.15422754, 0.9870261, 0.22036135, 1.8988357, 0.64972365)
current = true
fov = 49.0
[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
[node name="SpotLight3D" type="SpotLight3D" parent="Player" unique_id=1804564926]
transform = Transform3D(1, 0, 0, 0, 0.906308, -0.422618, 0, 0.422618, 0.906308, 0, 1.7, -0.35)
visible = false
spot_range = 30.0
spot_angle = 25.0
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1364437059]
transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
shadow_enabled = true
[node name="EntrySpawn" type="Marker3D" parent="."]
[node name="EntrySpawn" type="Marker3D" parent="." unique_id=1816177911]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2)
[node name="Car" parent="." instance=ExtResource("7_car")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, -3)
[node name="Car" parent="." unique_id=441668490 instance=ExtResource("7_car")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0.078942776, -5.401241)
[node name="QuestCheckpoint" type="Area3D" parent="."]
[node name="QuestCheckpoint" type="Area3D" parent="." unique_id=1536655109]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0, -10)
script = ExtResource("8_qtrigger")
event_name = &"reach_checkpoint"
target_group = &"vehicle"
quest_id_filter = "first_drive"
[node name="CollisionShape3D" type="CollisionShape3D" parent="QuestCheckpoint"]
[node name="CollisionShape3D" type="CollisionShape3D" parent="QuestCheckpoint" unique_id=529997293]
shape = SubResource("SphereShape3D_checkpoint")
[node name="Visual" type="MeshInstance3D" parent="QuestCheckpoint"]
[node name="Visual" type="MeshInstance3D" parent="QuestCheckpoint" unique_id=1896410895]
mesh = SubResource("SphereMesh_checkpoint")
[node name="ReturnTeleporter" parent="." instance=ExtResource("4_teleporter")]
[node name="ReturnTeleporter" parent="." unique_id=1452006361 instance=ExtResource("4_teleporter")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5, 0, 0)
target_scene_path = "res://scenes/Levels/level.tscn"
target_spawn_name = &"CarReturnSpawn"
[node name="Label3D" type="Label3D" parent="ReturnTeleporter"]
[node name="Label3D" type="Label3D" parent="ReturnTeleporter" unique_id=1075823264]
transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
pixel_size = 0.012
text = "PLAYGROUND"
[node name="PauseMenu" parent="." instance=ExtResource("6_pause_menu")]
[node name="PauseMenu" parent="." unique_id=471208565 instance=ExtResource("6_pause_menu")]
[node name="PhoneUI" type="CanvasLayer" parent="."]
[node name="PhoneUI" type="CanvasLayer" parent="." unique_id=201860923]
layer = 5
visible = false
[node name="Control" type="Control" parent="PhoneUI"]
[node name="Control" type="Control" parent="PhoneUI" unique_id=1674232100]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -111,7 +111,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control"]
[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control" unique_id=1215420900]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
@@ -126,7 +126,7 @@ grow_horizontal = 2
grow_vertical = 2
color = Color(0.08, 0.08, 0.1, 1)
[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame"]
[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame" unique_id=1337344808]
layout_mode = 0
offset_left = 18.0
offset_top = 18.0
@@ -134,7 +134,7 @@ offset_right = 150.0
offset_bottom = 41.0
text = "Quest Log"
[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame"]
[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame" unique_id=188206396]
layout_mode = 0
offset_left = 18.0
offset_top = 52.0
@@ -144,5 +144,4 @@ bbcode_enabled = true
text = "No active quest."
scroll_active = false
[node name="RadialCommandMenu" parent="PhoneUI/Control" instance=ExtResource("10_radial_menu")]
layout_mode = 1
[node name="RadialCommandMenu" parent="PhoneUI/Control" unique_id=67282635 instance=ExtResource("10_radial_menu")]
+85 -2
View File
@@ -3,7 +3,7 @@ extends RigidBody3D
@export var drive_speed := 18.0
@export var drive_accel := 20.0
@export var brake_strength := 28.0
@export var turn_speed := 2.0
@export var turn_speed := 4.0
@export var turn_accel := 8.0
@export var lateral_damp := 10.0
@export var launch_impulse := 28.0
@@ -12,14 +12,28 @@ extends RigidBody3D
@export var exit_path: NodePath
@export var camera_path: NodePath
@export var interact_area_path: NodePath
@export var engine_audio_fade_speed: float = 8.0
@export var hit_sound_cooldown: float = 0.25
@onready var seat: Node3D = get_node(seat_path) if seat_path != NodePath("") else null
@onready var exit_point: Node3D = get_node(exit_path) if exit_path != NodePath("") else null
@onready var car_camera: Camera3D = get_node(camera_path) if camera_path != NodePath("") else null
@onready var interact_area: Area3D = get_node(interact_area_path) if interact_area_path != NodePath("") else null
@onready var engine_idle_audio: AudioStreamPlayer3D = get_node_or_null("Audio/EngineIdle")
@onready var engine_drive_audio: AudioStreamPlayer3D = get_node_or_null("Audio/EngineDrive")
@onready var engine_accel_audio: AudioStreamPlayer3D = get_node_or_null("Audio/EngineAccel")
@onready var enter_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Enter")
@onready var exit_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Exit")
@onready var brake_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Brake")
@onready var hit_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Hit")
var _nearby_driver: Node = null
var _driver: Node = null
var _speed_factor: float = 0.0
var _throttle_input: float = 0.0
var _braking: bool = false
var _was_braking: bool = false
var _hit_sound_time_left: float = 0.0
func _ready() -> void:
add_to_group("vehicle")
@@ -33,17 +47,23 @@ func _ready() -> void:
contact_monitor = true
max_contacts_reported = 8
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
if _driver == null and _nearby_driver != null and Input.is_action_just_pressed("interact"):
_enter_vehicle(_nearby_driver)
elif _driver != null and Input.is_action_just_pressed("interact"):
_exit_vehicle()
_update_audio(delta)
_hit_sound_time_left = maxf(0.0, _hit_sound_time_left - delta)
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
if _driver == null:
_speed_factor = 0.0
_throttle_input = 0.0
_braking = false
return
var input2v := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
_throttle_input = input2v.y
var forward := global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
@@ -51,6 +71,9 @@ func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
var current_speed := linear_velocity.dot(forward)
var target_speed := input2v.y * drive_speed
var accel := drive_accel if abs(input2v.y) > 0.01 else brake_strength
var is_coasting_brake: bool = abs(input2v.y) <= 0.01 and abs(current_speed) > drive_speed * 0.35
var is_reverse_brake: bool = abs(input2v.y) > 0.01 and signf(input2v.y) != signf(current_speed) and abs(current_speed) > drive_speed * 0.25
_braking = is_coasting_brake or is_reverse_brake
var new_forward_speed := move_toward(current_speed, target_speed, accel * state.step)
var forward_vel := forward * new_forward_speed
var lateral_vel := linear_velocity - (forward * current_speed)
@@ -58,6 +81,7 @@ func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
linear_velocity = forward_vel + lateral_vel
var speed_factor: float = clamp(abs(new_forward_speed) / drive_speed, 0.0, 1.0)
_speed_factor = speed_factor
var turn_input := input2v.x
if new_forward_speed < -0.1:
turn_input = -turn_input
@@ -80,12 +104,15 @@ func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
launch_dir = normal_world if normal_world.length() > 0.001 else forward.normalized()
var impulse := launch_dir * (launch_impulse * speed_factor) + Vector3.UP * launch_up_impulse
(collider as RigidBody3D).apply_central_impulse(impulse)
_play_hit_audio(speed_factor)
func _enter_vehicle(player: Node) -> void:
if seat == null:
return
_driver = player
player.call("enter_vehicle", self, seat, car_camera)
_play_one_shot(enter_audio)
_start_engine_audio()
if car_camera:
car_camera.current = true
@@ -94,6 +121,10 @@ func _exit_vehicle() -> void:
return
_driver.call("exit_vehicle", exit_point, car_camera)
_driver = null
_speed_factor = 0.0
_throttle_input = 0.0
_braking = false
_play_one_shot(exit_audio)
if car_camera:
car_camera.current = false
@@ -104,3 +135,55 @@ func _on_interact_body_entered(body: Node) -> void:
func _on_interact_body_exited(body: Node) -> void:
if body == _nearby_driver:
_nearby_driver = null
func _start_engine_audio() -> void:
_start_engine_player(engine_idle_audio)
_start_engine_player(engine_drive_audio)
_start_engine_player(engine_accel_audio)
func _update_audio(delta: float) -> void:
if _driver != null:
_start_engine_audio()
var active: bool = _driver != null
var throttle_weight: float = clamp(abs(_throttle_input), 0.0, 1.0)
var accel_weight: float = throttle_weight * clamp(_speed_factor * 1.25, 0.0, 1.0)
var drive_weight: float = clamp(_speed_factor * 1.25, 0.0, 1.0) * (1.0 - accel_weight * 0.45)
var idle_weight: float = 0.9 - clamp(_speed_factor * 1.35, 0.0, 0.72)
_set_engine_player(engine_idle_audio, idle_weight if active else 0.0, 0.86 + _speed_factor * 0.18, delta)
_set_engine_player(engine_drive_audio, drive_weight if active else 0.0, 0.92 + _speed_factor * 0.35, delta)
_set_engine_player(engine_accel_audio, accel_weight if active else 0.0, 0.96 + _speed_factor * 0.5, delta)
if _braking and not _was_braking:
_play_one_shot(brake_audio)
_was_braking = _braking
func _set_engine_player(player: AudioStreamPlayer3D, weight: float, pitch: float, delta: float) -> void:
if player == null:
return
var target_volume: float = linear_to_db(maxf(weight, 0.0001)) - 2.0
if weight <= 0.001:
target_volume = -80.0
player.volume_db = move_toward(player.volume_db, target_volume, engine_audio_fade_speed * 10.0 * delta)
player.pitch_scale = move_toward(player.pitch_scale, pitch, engine_audio_fade_speed * delta)
func _start_engine_player(player: AudioStreamPlayer3D) -> void:
if player == null or player.playing:
return
player.play()
func _play_one_shot(player: AudioStreamPlayer3D) -> void:
if player == null:
return
player.stop()
player.play()
func _play_hit_audio(speed_factor: float) -> void:
if hit_audio == null or _hit_sound_time_left > 0.0:
return
hit_audio.pitch_scale = randf_range(0.92, 1.08)
var hit_volume: float = clamp(speed_factor, 0.25, 1.0)
hit_audio.volume_db = linear_to_db(hit_volume) - 3.0
hit_audio.play()
_hit_sound_time_left = hit_sound_cooldown
+114 -24
View File
@@ -1,41 +1,131 @@
[gd_scene load_steps=6 format=3]
[gd_scene format=3 uid="uid://uj3g2h3yfv4f"]
[ext_resource type="Script" path="res://scenes/Vehicles/car.gd" id="1_kbd20"]
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_prop_red.tres" id="2_car_mat"]
[ext_resource type="Script" uid="uid://4qf5yinepytc" path="res://scenes/Vehicles/car.gd" id="1_kbd20"]
[ext_resource type="PackedScene" uid="uid://dvgiooankbnw3" path="res://assets/models/vehicles/doge/doge-body.glb" id="2_doge_body"]
[ext_resource type="PackedScene" uid="uid://civr7hlbf2kqb" path="res://assets/models/vehicles/doge/Wheel.glb" id="3_doge_wheel"]
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_engine_idle_loop.wav" id="4_engine_idle"]
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_engine_drive_loop.wav" id="5_engine_drive"]
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_engine_accel_loop.wav" id="6_engine_accel"]
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_enter.wav" id="7_enter"]
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_exit.wav" id="8_exit"]
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_brake.wav" id="9_brake"]
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_hit.wav" id="10_hit"]
[sub_resource type="BoxShape3D" id="BoxShape3D_7r1j6"]
size = Vector3(1.4, 0.9, 2.6)
[sub_resource type="BoxShape3D" id="BoxShape3D_body_center"]
size = Vector3(1.9717, 1, 2.08592)
[sub_resource type="BoxShape3D" id="BoxShape3D_jk0m1"]
size = Vector3(2.2, 2.0, 3.8)
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body_rear"]
radius = 0.291194
[sub_resource type="BoxMesh" id="BoxMesh_4y8xk"]
size = Vector3(1.4, 0.9, 2.6)
[sub_resource type="BoxShape3D" id="BoxShape3D_body_mid"]
size = Vector3(0.542114, 1.7092, 1.53265)
[node name="Car" type="RigidBody3D"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body_front"]
radius = 0.363529
[sub_resource type="BoxShape3D" id="BoxShape3D_interact"]
size = Vector3(3.2, 2.4, 6)
[node name="Car" type="RigidBody3D" unique_id=347281788]
script = ExtResource("1_kbd20")
seat_path = NodePath("Seat")
exit_path = NodePath("Exit")
camera_path = NodePath("CarCamera")
interact_area_path = NodePath("InteractArea")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_7r1j6")
[node name="CollisionCenter" type="CollisionShape3D" parent="." unique_id=1615707894]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.45, -0.062)
shape = SubResource("BoxShape3D_body_center")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("BoxMesh_4y8xk")
surface_material_override/0 = ExtResource("2_car_mat")
[node name="CollisionRear" type="CollisionShape3D" parent="." unique_id=1572737985]
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, 0, 0.45, -2.08)
shape = SubResource("CapsuleShape3D_body_rear")
[node name="InteractArea" type="Area3D" parent="."]
[node name="CollisionMid" type="CollisionShape3D" parent="." unique_id=1115594476]
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, -0.001, 0.45, -1.11)
shape = SubResource("BoxShape3D_body_mid")
[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractArea"]
shape = SubResource("BoxShape3D_jk0m1")
[node name="CollisionFront" type="CollisionShape3D" parent="." unique_id=601157615]
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, -0.001, 0.45, 1.998)
shape = SubResource("CapsuleShape3D_body_front")
[node name="Seat" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0, 0.6, 0.0)
[node name="DogeBody" parent="." unique_id=1839362199 instance=ExtResource("2_doge_body")]
transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0, 0)
[node name="Exit" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3, 0.0, 0.0)
[node name="WheelFrontRight" parent="." unique_id=2135146556 instance=ExtResource("3_doge_wheel")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.854765, 0.340338, -1.50006)
[node name="CarCamera" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0, 2.0, 4.2)
[node name="WheelFrontLeft" parent="." unique_id=595413685 instance=ExtResource("3_doge_wheel")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.949166, 0.340338, -1.50006)
[node name="WheelRearRight" parent="." unique_id=521166039 instance=ExtResource("3_doge_wheel")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.854765, 0.286814, 1.26411)
[node name="WheelRearLeft" parent="." unique_id=859253276 instance=ExtResource("3_doge_wheel")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.949166, 0.286814, 1.26411)
[node name="InteractArea" type="Area3D" parent="." unique_id=1982997129]
[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractArea" unique_id=2089774080]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, 0)
shape = SubResource("BoxShape3D_interact")
[node name="Seat" type="Node3D" parent="." unique_id=1298631680]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
[node name="Exit" type="Node3D" parent="." unique_id=1734274203]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3, 0, 0)
[node name="CarCamera" type="Camera3D" parent="." unique_id=1783366397]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 4.2)
[node name="Audio" type="Node3D" parent="."]
[node name="EngineIdle" type="AudioStreamPlayer3D" parent="Audio"]
stream = ExtResource("4_engine_idle")
volume_db = -80.0
bus = &"SFX"
unit_size = 6.0
max_distance = 45.0
[node name="EngineDrive" type="AudioStreamPlayer3D" parent="Audio"]
stream = ExtResource("5_engine_drive")
volume_db = -80.0
bus = &"SFX"
unit_size = 6.0
max_distance = 55.0
[node name="EngineAccel" type="AudioStreamPlayer3D" parent="Audio"]
stream = ExtResource("6_engine_accel")
volume_db = -80.0
bus = &"SFX"
unit_size = 6.0
max_distance = 60.0
[node name="Enter" type="AudioStreamPlayer3D" parent="Audio"]
stream = ExtResource("7_enter")
volume_db = -7.0
bus = &"SFX"
unit_size = 5.0
max_distance = 25.0
[node name="Exit" type="AudioStreamPlayer3D" parent="Audio"]
stream = ExtResource("8_exit")
volume_db = -7.0
bus = &"SFX"
unit_size = 5.0
max_distance = 25.0
[node name="Brake" type="AudioStreamPlayer3D" parent="Audio"]
stream = ExtResource("9_brake")
volume_db = -5.0
bus = &"SFX"
unit_size = 5.0
max_distance = 35.0
[node name="Hit" type="AudioStreamPlayer3D" parent="Audio"]
stream = ExtResource("10_hit")
volume_db = -6.0
bus = &"SFX"
unit_size = 7.0
max_distance = 45.0
+84 -11
View File
@@ -11,9 +11,15 @@ const ACCELLERATION := 30.0
const DECELLERATION := 40.0
const JUMP_SPEED := 4.0
const MAX_NUMBER_OF_JUMPS := 2
const MIN_FOV := 10.0
const MAX_FOV := 179.0
const ZOOM_FACTOR := 1.1 # Zoom out when >1, in when < 1
const DEFAULT_ZOOM_DIST := 3.0
const MIN_ZOOM_DIST := 1.5
const MAX_ZOOM_DIST := 8.0
const ZOOM_STEP := 0.5
const FIRST_PERSON_HEIGHT := 1.6
const MODEL_SHOW_CAMERA_DISTANCE := 0.75
var _zoom_dist := DEFAULT_ZOOM_DIST
var _third_person_zoom_dist := DEFAULT_ZOOM_DIST
var _is_first_person := false
const FIREBALL_SPAWN_FORWARD_OFFSET := 1.5
const FIREBALL_SPAWN_UP_OFFSET := 1.2
var mouse_sensitivity := 0.005
@@ -68,10 +74,18 @@ func _ready() -> void:
audio_player.stream = jump_sound
audio_player.volume_db = -20
if cam:
_camera_offset_local = cam.transform.origin
if TeleportState != null:
_third_person_zoom_dist = clampf(
TeleportState.third_person_camera_zoom_dist,
MIN_ZOOM_DIST,
MAX_ZOOM_DIST
)
_is_first_person = TeleportState.camera_is_first_person or TeleportState.camera_zoom_dist == 0.0
_zoom_dist = 0.0 if _is_first_person else _third_person_zoom_dist
_camera_pitch = cam.rotation.x
_camera_yaw = global_transform.basis.get_euler().y
cam.set_as_top_level(true)
update_camera_offset()
cam.global_position = global_position + (Basis(Vector3.UP, _camera_yaw) * _camera_offset_local)
cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
var move_basis := cam.global_transform.basis if cam else global_transform.basis
@@ -156,6 +170,8 @@ func _integrate_forces(state):
cam.global_position = cam.global_position.lerp(target_pos, camera_follow_speed * state.step)
cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
_update_model_visibility()
_update_animation(on_floor, state.linear_velocity)
_jump_triggered = false
@@ -173,14 +189,18 @@ func _input(event):
if _in_vehicle:
return
if event.is_action_pressed("player_toggle_perspective"):
set_first_person(not _is_first_person)
return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
_pending_mouse_delta += event.relative
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom_camera(1.0 / ZOOM_FACTOR) # Zoom in
zoom_camera(true) # Zoom in
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom_camera(ZOOM_FACTOR) # Zoom out
zoom_camera(false) # Zoom out
if event.is_action_pressed("player_fireball"):
shoot_fireball()
@@ -189,11 +209,64 @@ func _input(event):
if event.is_action_pressed("player_light"):
_flashlight.visible = !_flashlight.visible
func zoom_camera(factor):
func zoom_camera(zoom_in: bool) -> void:
if cam == null:
return
var new_fov = cam.fov * factor
cam.fov = clamp(new_fov, MIN_FOV, MAX_FOV)
if zoom_in:
if _is_first_person:
return
elif _zoom_dist <= MIN_ZOOM_DIST:
set_first_person(true)
return
else:
_zoom_dist = max(MIN_ZOOM_DIST, _zoom_dist - ZOOM_STEP)
else:
if _is_first_person:
set_first_person(false)
return
else:
_zoom_dist = min(MAX_ZOOM_DIST, _zoom_dist + ZOOM_STEP)
_third_person_zoom_dist = _zoom_dist
_persist_camera_state()
update_camera_offset()
func set_first_person(enabled: bool) -> void:
if enabled == _is_first_person:
return
if enabled and _zoom_dist > 0.0:
_third_person_zoom_dist = _zoom_dist
_is_first_person = enabled
_zoom_dist = 0.0 if enabled else _third_person_zoom_dist
_persist_camera_state()
update_camera_offset()
_update_model_visibility()
func _persist_camera_state() -> void:
if TeleportState == null:
return
TeleportState.camera_is_first_person = _is_first_person
TeleportState.camera_zoom_dist = _zoom_dist
TeleportState.third_person_camera_zoom_dist = _third_person_zoom_dist
func update_camera_offset() -> void:
if _is_first_person:
_camera_offset_local = Vector3(0.0, FIRST_PERSON_HEIGHT, 0.0)
else:
var height_offset := 0.25 * _zoom_dist
_camera_offset_local = Vector3(0.5, FIRST_PERSON_HEIGHT + height_offset, _zoom_dist)
func _update_model_visibility() -> void:
if _model_root == null:
return
if _in_vehicle or _is_first_person:
_model_root.visible = false
return
if cam == null:
_model_root.visible = true
return
var eye_position := global_position + Vector3.UP * FIRST_PERSON_HEIGHT
_model_root.visible = cam.global_position.distance_to(eye_position) >= MODEL_SHOW_CAMERA_DISTANCE
func shoot_fireball() -> void:
var direction := get_look_direction()
@@ -257,7 +330,7 @@ func exit_vehicle(exit_point: Node3D, vehicle_camera: Camera3D) -> void:
_vehicle_original_parent = null
_flashlight.visible = _light_was_on
if _model_root:
_model_root.visible = true
_model_root.visible = not _is_first_person
if exit_point:
global_transform = exit_point.global_transform
if vehicle_camera:
@@ -284,7 +357,7 @@ func is_in_vehicle() -> bool:
func get_look_origin() -> Vector3:
return global_position + Vector3.UP * 1.2
return global_position + Vector3.UP * FIREBALL_SPAWN_UP_OFFSET
func get_look_direction() -> Vector3:
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AuthApi\AuthApi.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,61 @@
using AuthApi.Models;
using AuthApi.Services;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace AuthApi.Tests;
public sealed class LegalPolicyTests
{
private static LegalPolicy Policy(string version = "2026-07-20") => new(
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Legal:TermsVersion"] = version,
["Legal:PrivacyVersion"] = version,
["Legal:EffectiveUtc"] = "2026-07-20T00:00:00Z"
}).Build());
[Fact]
public void ExistingUserWithoutAcceptanceRequiresAgreement()
{
Assert.True(Policy().Status(new User()).RequiresLegalAcceptance);
}
[Fact]
public void AcceptRecordsVersionsTimestampAndSource()
{
var user = new User();
var acceptedUtc = new DateTime(2026, 7, 20, 12, 0, 0, DateTimeKind.Utc);
Policy().Accept(user, "password_registration", acceptedUtc);
Assert.Equal("2026-07-20", user.TermsAcceptedVersion);
Assert.Equal("2026-07-20", user.PrivacyAcceptedVersion);
Assert.Equal(acceptedUtc, user.LegalAcceptedUtc);
var acceptance = Assert.Single(user.LegalAcceptances);
Assert.Equal("password_registration", acceptance.Source);
}
[Fact]
public void AcceptIsIdempotentForCurrentVersions()
{
var user = new User();
var policy = Policy();
var first = new DateTime(2026, 7, 20, 12, 0, 0, DateTimeKind.Utc);
policy.Accept(user, "registration", first);
policy.Accept(user, "prompt", first.AddHours(1));
Assert.Single(user.LegalAcceptances);
Assert.Equal(first, user.LegalAcceptedUtc);
}
[Fact]
public void NewPolicyVersionRequiresAcceptanceAgain()
{
var user = new User();
Policy().Accept(user, "registration");
Assert.True(Policy("2026-08-01").Status(user).RequiresLegalAcceptance);
}
}
@@ -0,0 +1,39 @@
using AuthApi.Services;
using Xunit;
namespace AuthApi.Tests;
public class SteamOpenIdServiceTests
{
[Fact]
public void BuildAuthenticationUrl_UsesSteamEndpointAndBindsReturnUrl()
{
const string callback = "https://pauth.ranaze.com/api/Auth/steam/callback?transactionId=abc-123";
var result = SteamOpenIdService.BuildAuthenticationUrl("https://pauth.ranaze.com", callback);
Assert.StartsWith(SteamOpenIdService.Endpoint + "?", result);
Assert.Contains("openid.mode=checkid_setup", result);
Assert.Contains(Uri.EscapeDataString(callback), result);
Assert.Contains(Uri.EscapeDataString("https://pauth.ranaze.com"), result);
}
[Theory]
[InlineData("https://steamcommunity.com/openid/id/76561198000000000", "76561198000000000")]
[InlineData("http://steamcommunity.com/openid/id/76561198000000000", "76561198000000000")]
[InlineData("https://example.com/openid/id/76561198000000000", null)]
[InlineData("https://steamcommunity.com/openid/id/not-a-steamid", null)]
public void ExtractSteamId_OnlyAcceptsSteamClaimedIds(string claimedId, string? expected)
{
Assert.Equal(expected, SteamOpenIdService.ExtractSteamId(claimedId));
}
[Theory]
[InlineData("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n", true)]
[InlineData("is_valid:false\n", false)]
[InlineData("is_valid:true-ish\n", false)]
public void IsValidAuthenticationResponse_RequiresExactValidLine(string response, bool expected)
{
Assert.Equal(expected, SteamOpenIdService.IsValidAuthenticationResponse(response));
}
}
+4
View File
@@ -14,4 +14,8 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AuthApi.Tests" />
</ItemGroup>
</Project>
@@ -2,10 +2,13 @@ using AuthApi.Models;
using AuthApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Driver;
using MongoDB.Bson;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Mail;
using System.Security.Claims;
using System.Text;
using System.Text.RegularExpressions;
using System.Net.Http.Json;
namespace AuthApi.Controllers;
@@ -16,40 +19,69 @@ public class AuthController : ControllerBase
private readonly UserService _users;
private readonly IConfiguration _cfg;
private readonly BlacklistService _blacklist;
private readonly IHttpClientFactory _httpClients;
private readonly JwtTokenService _tokens;
private readonly LegalPolicy _legal;
public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist)
public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist,
IHttpClientFactory httpClients, JwtTokenService tokens, LegalPolicy legal)
{
_users = users; _cfg = cfg; _blacklist = blacklist;
_users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _tokens = tokens; _legal = legal;
}
[HttpGet("legal")]
[AllowAnonymous]
public IActionResult Legal() => Ok(_legal.Status());
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest req)
{
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return BadRequest("Username and password required");
var username = req.Username?.Trim() ?? "";
var password = req.Password ?? "";
var email = req.Email?.Trim() ?? "";
if (await _users.GetByUsernameAsync(req.Username) != null)
return BadRequest("User already exists");
if (!req.AcceptLegal)
return BadRequest("You must agree to the Terms of Service and acknowledge the Privacy Policy");
var hash = BCrypt.Net.BCrypt.HashPassword(req.Password);
var user = new User { Username = req.Username, PasswordHash = hash, Role = "USER", Email = req.Email };
await _users.CreateAsync(user);
if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$"))
return BadRequest("Username must be 3-24 characters using only letters, numbers, or underscores");
if (password.Length < 8)
return BadRequest("Password must be at least 8 characters");
var emailDomain = email.Split('@').LastOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(email) || !MailAddress.TryCreate(email, out _) || !emailDomain.Contains('.'))
return BadRequest("A valid email address is required");
if (await _users.GetByUsernameAsync(username) != null)
return Conflict("Username already exists");
var hash = BCrypt.Net.BCrypt.HashPassword(password);
var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow };
_legal.Accept(user, "password_registration");
try
{
await _users.CreateAsync(user);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Conflict("Username already exists");
}
return Ok("User created");
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
var user = await _users.GetByUsernameAsync(req.Username);
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
var candidates = await _users.GetByLoginCandidatesAsync(req.Username);
var user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(req.Password, candidate.PasswordHash));
if (user == null)
return Unauthorized();
var (accessToken, jti, expUtc) = GenerateJwtToken(user);
var (accessToken, jti, expUtc) = _tokens.Generate(user);
user.RefreshToken = Guid.NewGuid().ToString("N");
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await _users.UpdateAsync(user);
return Ok(new { accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp = expUtc });
return Ok(SessionResponse(user, accessToken, expUtc, jti, user.RefreshToken));
}
[HttpPost("refresh")]
@@ -59,8 +91,8 @@ public class AuthController : ControllerBase
if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow)
return Unauthorized("Invalid or expired refresh token");
var (accessToken, _, expUtc) = GenerateJwtToken(user);
return Ok(new { accessToken, exp = expUtc });
var (accessToken, _, expUtc) = _tokens.Generate(user);
return Ok(SessionResponse(user, accessToken, expUtc));
}
[HttpPost("logout")]
@@ -74,6 +106,92 @@ public class AuthController : ControllerBase
return Ok("Logged out.");
}
[HttpGet("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Me()
{
var user = await CurrentUserAsync();
return user is null ? Unauthorized() : Ok(ProfileResponse(user));
}
[HttpPost("legal/accept")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> AcceptLegal([FromBody] AcceptLegalRequest req)
{
if (!req.Accept) return BadRequest("Legal acceptance is required.");
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
_legal.Accept(user, "existing_user_prompt");
await _users.UpdateAsync(user);
return Ok(_legal.Status(user));
}
[HttpPatch("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest req)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
var displayName = req.DisplayName?.Trim();
var email = req.Email?.Trim().ToLowerInvariant();
if (!string.IsNullOrEmpty(displayName) && displayName.Length > 80)
return BadRequest("Display name must be 80 characters or fewer.");
if (!string.IsNullOrEmpty(email) && !ValidEmail(email))
return BadRequest("A valid email address is required.");
if (!string.IsNullOrEmpty(email))
{
var existing = await _users.GetByEmailAsync(email);
if (existing is not null && existing.Id != user.Id) return Conflict("Email address is already in use.");
}
user.DisplayName = string.IsNullOrEmpty(displayName) ? null : displayName;
user.Email = string.IsNullOrEmpty(email) ? null : email;
user.CreatedUtc ??= CreationDate(user);
await _users.UpdateAsync(user);
return Ok(ProfileResponse(user));
}
[HttpPost("me/change-password")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest req)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash)) return BadRequest("Current password is incorrect.");
if (req.NewPassword.Length < 8) return BadRequest("Password must be at least 8 characters.");
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
user.RefreshToken = null;
user.RefreshTokenExpiry = null;
await _users.UpdateAsync(user);
await RevokeCurrentTokenAsync();
return NoContent();
}
[HttpDelete("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> DeleteAccount([FromBody] DeleteAccountRequest req)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
if (req.Confirmation != "DELETE") return BadRequest("Type DELETE to confirm permanent deletion.");
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return BadRequest("Password is incorrect.");
var baseUrl = _cfg["QuestionsApi:BaseUrl"]?.TrimEnd('/');
var secret = _cfg["InternalPurge:Secret"];
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(secret))
return StatusCode(503, "Account deletion is temporarily unavailable.");
var client = _httpClients.CreateClient();
client.Timeout = TimeSpan.FromSeconds(30);
using var purge = new HttpRequestMessage(HttpMethod.Delete, $"{baseUrl}/api/profile/internal/users/{Uri.EscapeDataString(user.Id)}");
purge.Headers.Add("X-MedMind-Internal-Key", secret);
using var response = await client.SendAsync(purge);
if (!response.IsSuccessStatusCode)
return StatusCode(503, "Study data could not be removed. The account was not deleted; please try again.");
await RevokeCurrentTokenAsync();
await _users.DeleteAsync(user.Id);
return NoContent();
}
[HttpPost("role")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> ChangeUserRole([FromBody] ChangeRoleRequest req)
@@ -90,24 +208,51 @@ public class AuthController : ControllerBase
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> GetAllUsers() => Ok(await _users.GetAllAsync());
private (string token, string jti, DateTime expUtc) GenerateJwtToken(User user)
private async Task<User?> CurrentUserAsync()
{
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!);
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
var audience = _cfg["Jwt:Audience"] ?? issuer;
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
return string.IsNullOrWhiteSpace(id) ? null : await _users.GetByIdAsync(id);
}
var creds = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
var jti = Guid.NewGuid().ToString("N");
var claims = new[]
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, jti)
};
private static bool ValidEmail(string email)
{
var domain = email.Split('@').LastOrDefault() ?? "";
return MailAddress.TryCreate(email, out _) && domain.Contains('.');
}
var exp = DateTime.UtcNow.AddMinutes(15);
var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds);
return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp);
private static DateTime CreationDate(User user)
{
return ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow;
}
private object ProfileResponse(User user) => new
{
userId = user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Role,
createdUtc = user.CreatedUtc ?? CreationDate(user),
legal = _legal.Status(user)
};
private object SessionResponse(User user, string accessToken, DateTime expUtc,
string? jti = null, string? refreshToken = null) => new
{
accessToken,
refreshToken,
user.Username,
user.Role,
jti,
exp = expUtc,
legal = _legal.Status(user)
};
private async Task RevokeCurrentTokenAsync()
{
var token = HttpContext.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", "");
if (string.IsNullOrWhiteSpace(token)) return;
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo);
}
}
@@ -0,0 +1,251 @@
using AuthApi.Models;
using AuthApi.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Driver;
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace AuthApi.Controllers;
[ApiController]
[Route("api/Auth/steam")]
public class SteamAuthController : ControllerBase
{
private readonly UserService _users;
private readonly SteamAuthTransactionService _transactions;
private readonly SteamOpenIdService _openId;
private readonly JwtTokenService _tokens;
private readonly IConfiguration _cfg;
private readonly ILogger<SteamAuthController> _logger;
private readonly LegalPolicy _legal;
public SteamAuthController(UserService users, SteamAuthTransactionService transactions,
SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg,
ILogger<SteamAuthController> logger, LegalPolicy legal)
{
_users = users;
_transactions = transactions;
_openId = openId;
_tokens = tokens;
_cfg = cfg;
_logger = logger;
_legal = legal;
}
[HttpPost("start")]
[EnableRateLimiting("steam-start")]
public async Task<IActionResult> Start()
{
var realm = ConfiguredRealm();
var transactionId = Base64Url(RandomNumberGenerator.GetBytes(32));
var lifetime = Math.Clamp(_cfg.GetValue("Steam:TransactionTtlMinutes", 5), 2, 15);
var expiresUtc = DateTime.UtcNow.AddMinutes(lifetime);
await _transactions.CreateAsync(new SteamAuthTransaction
{
Id = transactionId,
CreatedUtc = DateTime.UtcNow,
ExpiresUtc = expiresUtc
});
var returnUrl = ReturnUrl(transactionId);
return Ok(new
{
transactionId,
authUrl = SteamOpenIdService.BuildAuthenticationUrl(realm, returnUrl),
expiresUtc
});
}
[HttpGet("callback")]
[EnableRateLimiting("steam-complete")]
public async Task<IActionResult> Callback([FromQuery] string transactionId, CancellationToken cancellationToken)
{
var transaction = await ActiveTransactionAsync(transactionId);
if (transaction is null || transaction.Status != "pending")
return CallbackPage(false, "This Steam sign-in request has expired. Return to RanaZe and try again.");
var steamId = await _openId.VerifyCallbackAsync(Request.Query, ReturnUrl(transactionId), cancellationToken);
if (steamId is null)
{
_logger.LogWarning("Steam OpenID verification failed");
return CallbackPage(false, "Steam could not verify this sign-in. Return to RanaZe and try again.");
}
var user = await _users.GetBySteamIdAsync(steamId);
var status = user is null ? "requires_account" : "complete";
var changed = await _transactions.MarkVerifiedAsync(transactionId, steamId, status, user?.Id);
if (!changed)
return CallbackPage(false, "This Steam sign-in request has already been used.");
if (user is not null)
{
user.RefreshToken = NewRefreshToken();
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await _users.UpdateAsync(user);
}
return CallbackPage(true, "Steam verified your identity. You can return to RanaZe.");
}
[HttpGet("status/{transactionId}")]
[EnableRateLimiting("steam-poll")]
public async Task<IActionResult> Status(string transactionId)
{
Response.Headers.CacheControl = "no-store";
var transaction = await ActiveTransactionAsync(transactionId);
if (transaction is null)
return NotFound(new { message = "Steam sign-in expired." });
if (transaction.Status != "complete")
return Ok(new { status = transaction.Status });
if (string.IsNullOrWhiteSpace(transaction.UserId))
return Conflict(new { message = "Steam sign-in is incomplete." });
var user = await _users.GetByIdAsync(transaction.UserId);
if (user is null || user.SteamId != transaction.SteamId)
return Conflict(new { message = "Steam account link is no longer valid." });
if (string.IsNullOrWhiteSpace(user.RefreshToken) || user.RefreshTokenExpiry <= DateTime.UtcNow)
{
user.RefreshToken = NewRefreshToken();
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await _users.UpdateAsync(user);
}
var (accessToken, jti, exp) = _tokens.Generate(user);
return Ok(new
{
status = "complete",
accessToken,
refreshToken = user.RefreshToken,
user.Username,
user.Role,
jti,
exp,
legal = _legal.Status(user)
});
}
[HttpPost("complete")]
[EnableRateLimiting("steam-complete")]
public async Task<IActionResult> Complete([FromBody] CompleteSteamRequest request)
{
var transaction = await ActiveTransactionAsync(request.TransactionId);
if (transaction?.Status == "complete")
return Ok(new { status = "complete" });
if (transaction is null || transaction.Status != "requires_account" || string.IsNullOrWhiteSpace(transaction.SteamId))
return BadRequest(new { message = "Steam sign-in is not ready or has expired." });
User? user;
var createdUser = false;
if (request.Action == "create")
{
if (!request.AcceptLegal)
return BadRequest(new { message = "You must agree to the Terms of Service and acknowledge the Privacy Policy." });
var validation = ValidateNewAccount(request);
if (validation is not null)
return BadRequest(new { message = validation });
if (await _users.GetByUsernameAsync(request.Username.Trim()) is not null)
return Conflict(new { message = "Username already exists." });
user = new User
{
Username = request.Username.Trim(),
Email = request.Email!.Trim().ToLowerInvariant(),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
Role = "USER",
CreatedUtc = DateTime.UtcNow,
SteamId = transaction.SteamId,
RefreshToken = NewRefreshToken(),
RefreshTokenExpiry = DateTime.UtcNow.AddDays(7)
};
_legal.Accept(user, "steam_registration");
try
{
await _users.CreateAsync(user);
createdUser = true;
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Conflict(new { message = "That username or Steam account is already connected." });
}
}
else if (request.Action == "link")
{
var candidates = await _users.GetByLoginCandidatesAsync(request.Username);
user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash));
if (user is null)
return Unauthorized(new { message = "The account credentials are incorrect." });
if (!string.IsNullOrWhiteSpace(user.SteamId) && user.SteamId != transaction.SteamId)
return Conflict(new { message = "That RanaZe account is already connected to another Steam account." });
try
{
var linked = await _users.TryLinkSteamAsync(user.Id, transaction.SteamId,
NewRefreshToken(), DateTime.UtcNow.AddDays(7));
if (!linked)
return Conflict(new { message = "That account could not be connected to Steam." });
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Conflict(new { message = "That Steam account is already connected." });
}
}
else
{
return BadRequest(new { message = "Action must be create or link." });
}
if (!await _transactions.MarkCompleteAsync(request.TransactionId, transaction.SteamId, user.Id))
{
if (createdUser)
await _users.DeleteAsync(user.Id);
return Conflict(new { message = "This Steam sign-in was already completed." });
}
return Ok(new { status = "complete" });
}
private async Task<SteamAuthTransaction?> ActiveTransactionAsync(string id)
{
if (string.IsNullOrWhiteSpace(id) || id.Length > 128)
return null;
var transaction = await _transactions.GetAsync(id);
return transaction is not null && transaction.ExpiresUtc > DateTime.UtcNow ? transaction : null;
}
private string ConfiguredRealm() =>
(_cfg["Steam:Realm"] ?? "https://pauth.ranaze.com").TrimEnd('/');
private string ReturnUrl(string transactionId)
{
var callback = _cfg["Steam:ReturnUrl"] ?? $"{ConfiguredRealm()}/api/Auth/steam/callback";
return $"{callback}?transactionId={Uri.EscapeDataString(transactionId)}";
}
private ContentResult CallbackPage(bool success, string message)
{
Response.Headers.CacheControl = "no-store";
Response.Headers.ContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'";
Response.Headers.XFrameOptions = "DENY";
Response.Headers["Referrer-Policy"] = "no-referrer";
var color = success ? "#22c55e" : "#ef4444";
var html = $$"""
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>RanaZe Steam sign-in</title><style>body{font-family:system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}h1{color:{{color}};font-size:1.4rem}</style></head>
<body><main class="card"><h1>{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}</h1><p>{{System.Net.WebUtility.HtmlEncode(message)}}</p></main>
<script>if(window.opener){setTimeout(()=>window.close(),1200);}</script></body></html>
""";
return Content(html, "text/html; charset=utf-8");
}
private static string? ValidateNewAccount(CompleteSteamRequest request)
{
var username = request.Username.Trim();
var email = request.Email?.Trim() ?? "";
if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$"))
return "Username must be 3-24 characters using only letters, numbers, or underscores.";
if (request.Password.Length < 8)
return "Password must be at least 8 characters.";
var domain = email.Split('@').LastOrDefault() ?? "";
if (!MailAddress.TryCreate(email, out _) || !domain.Contains('.'))
return "A valid email address is required.";
return null;
}
private static string NewRefreshToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
+5 -2
View File
@@ -9,9 +9,12 @@ Inbound JSON documents
{
"username": "string",
"password": "string",
"email": "string (optional)"
"email": "string"
}
```
Usernames must be 3-24 characters containing only letters, numbers, or
underscores. Passwords must contain at least 8 characters, and email is
required with a valid address format.
- LoginRequest (`POST /api/auth/login`)
```json
{
@@ -42,7 +45,7 @@ Stored documents (MongoDB)
"username": "string",
"passwordHash": "string",
"role": "USER | SUPER",
"email": "string (optional)",
"email": "string",
"refreshToken": "string (optional)",
"refreshTokenExpiry": "string (optional, ISO-8601 datetime)"
}
+2 -2
View File
@@ -3,11 +3,11 @@ WORKDIR /src
# Copy project file first to take advantage of Docker layer caching
COPY ["AuthApi.csproj", "./"]
RUN dotnet restore "AuthApi.csproj"
RUN dotnet restore "AuthApi.csproj" --disable-parallel
# Copy the remaining source and publish
COPY . .
RUN dotnet publish "AuthApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
RUN dotnet publish "AuthApi.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
+14 -1
View File
@@ -1,6 +1,19 @@
namespace AuthApi.Models;
public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } }
public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } public bool AcceptLegal { get; set; } }
public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; }
public class ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; }
public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { get; set; } = ""; }
public class UpdateProfileRequest { public string? DisplayName { get; set; } public string? Email { get; set; } }
public class ChangePasswordRequest { public string CurrentPassword { get; set; } = ""; public string NewPassword { get; set; } = ""; }
public class DeleteAccountRequest { public string Password { get; set; } = ""; public string Confirmation { get; set; } = ""; }
public class CompleteSteamRequest
{
public string TransactionId { get; set; } = "";
public string Action { get; set; } = "";
public string Username { get; set; } = "";
public string? Email { get; set; }
public string Password { get; set; } = "";
public bool AcceptLegal { get; set; }
}
public class AcceptLegalRequest { public bool Accept { get; set; } }
@@ -0,0 +1,14 @@
using MongoDB.Bson.Serialization.Attributes;
namespace AuthApi.Models;
public class SteamAuthTransaction
{
[BsonId]
public string Id { get; set; } = "";
public string Status { get; set; } = "pending";
public string? SteamId { get; set; }
public string? UserId { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
}
+15
View File
@@ -11,6 +11,21 @@ public class User
public string PasswordHash { get; set; } = default!;
public string Role { get; set; } = "USER";
public string? Email { get; set; }
public string? DisplayName { get; set; }
public DateTime? CreatedUtc { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; }
public string? SteamId { get; set; }
public string? TermsAcceptedVersion { get; set; }
public string? PrivacyAcceptedVersion { get; set; }
public DateTime? LegalAcceptedUtc { get; set; }
public List<LegalAcceptanceRecord> LegalAcceptances { get; set; } = [];
}
public sealed class LegalAcceptanceRecord
{
public string TermsVersion { get; set; } = "";
public string PrivacyVersion { get; set; } = "";
public DateTime AcceptedUtc { get; set; } = DateTime.UtcNow;
public string Source { get; set; } = "";
}
+24
View File
@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Security.Claims;
using System.Text;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
@@ -11,6 +12,27 @@ builder.Services.AddControllers();
// DI
builder.Services.AddSingleton<UserService>();
builder.Services.AddSingleton<BlacklistService>();
builder.Services.AddSingleton<SteamAuthTransactionService>();
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<SteamOpenIdService>();
builder.Services.AddSingleton<LegalPolicy>();
builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options =>
{
static RateLimitPartition<string> Policy(HttpContext context, int permits) =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = permits,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
options.AddPolicy("steam-start", context => Policy(context, 10));
options.AddPolicy("steam-poll", context => Policy(context, 120));
options.AddPolicy("steam-complete", context => Policy(context, 20));
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Swagger + JWT auth in Swagger
builder.Services.AddEndpointsApiExplorer();
@@ -80,6 +102,8 @@ app.UseSwaggerUI(o =>
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1");
o.RoutePrefix = "swagger";
});
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
+1 -1
View File
@@ -4,7 +4,7 @@
See `DOCUMENTS.md` for request payloads and stored document shapes.
## Endpoints
- `POST /api/auth/register` Register a new user.
- `POST /api/auth/register` Register a USER account with a username, email, and password.
- `POST /api/auth/login` Issue access and refresh tokens.
- `POST /api/auth/refresh` Refresh an access token.
- `POST /api/auth/logout` Revoke the current access token.
@@ -0,0 +1,33 @@
using AuthApi.Models;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace AuthApi.Services;
public class JwtTokenService
{
private readonly IConfiguration _cfg;
public JwtTokenService(IConfiguration cfg) => _cfg = cfg;
public (string Token, string Jti, DateTime ExpiresUtc) Generate(User user)
{
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key missing"));
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
var audience = _cfg["Jwt:Audience"] ?? issuer;
var credentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
var jti = Guid.NewGuid().ToString("N");
var claims = new[]
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, jti)
};
var expiresUtc = DateTime.UtcNow.AddMinutes(15);
var token = new JwtSecurityToken(issuer, audience, claims, expires: expiresUtc, signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(token), jti, expiresUtc);
}
}
@@ -0,0 +1,57 @@
using AuthApi.Models;
namespace AuthApi.Services;
public sealed record LegalPolicyStatus(
string TermsVersion,
string PrivacyVersion,
DateTime EffectiveUtc,
string TermsUrl,
string PrivacyUrl,
bool RequiresLegalAcceptance);
public sealed class LegalPolicy
{
public const string DefaultVersion = "2026-07-20";
private readonly IConfiguration _configuration;
public LegalPolicy(IConfiguration configuration) => _configuration = configuration;
public string TermsVersion => _configuration["Legal:TermsVersion"] ?? DefaultVersion;
public string PrivacyVersion => _configuration["Legal:PrivacyVersion"] ?? DefaultVersion;
public string TermsUrl => _configuration["Legal:TermsUrl"] ?? "https://doctor.ranaze.com/terms.html";
public string PrivacyUrl => _configuration["Legal:PrivacyUrl"] ?? "https://doctor.ranaze.com/privacy.html";
public DateTime EffectiveUtc => DateTime.TryParse(_configuration["Legal:EffectiveUtc"], out var value)
? value.ToUniversalTime()
: new DateTime(2026, 7, 20, 0, 0, 0, DateTimeKind.Utc);
public bool IsCurrent(User user) =>
user.TermsAcceptedVersion == TermsVersion && user.PrivacyAcceptedVersion == PrivacyVersion;
public LegalPolicyStatus Status(User? user = null) => new(
TermsVersion,
PrivacyVersion,
EffectiveUtc,
TermsUrl,
PrivacyUrl,
user is not null && !IsCurrent(user));
public void Accept(User user, string source, DateTime? acceptedUtc = null)
{
var timestamp = acceptedUtc ?? DateTime.UtcNow;
user.LegalAcceptances ??= [];
if (IsCurrent(user) && user.LegalAcceptances.Any(value =>
value.TermsVersion == TermsVersion && value.PrivacyVersion == PrivacyVersion))
return;
user.TermsAcceptedVersion = TermsVersion;
user.PrivacyAcceptedVersion = PrivacyVersion;
user.LegalAcceptedUtc = timestamp;
user.LegalAcceptances.Add(new LegalAcceptanceRecord
{
TermsVersion = TermsVersion,
PrivacyVersion = PrivacyVersion,
AcceptedUtc = timestamp,
Source = source
});
}
}
@@ -0,0 +1,49 @@
using AuthApi.Models;
using MongoDB.Driver;
namespace AuthApi.Services;
public class SteamAuthTransactionService
{
private readonly IMongoCollection<SteamAuthTransaction> _transactions;
public SteamAuthTransactionService(IConfiguration cfg)
{
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
var database = new MongoClient(connectionString).GetDatabase(databaseName);
_transactions = database.GetCollection<SteamAuthTransaction>("SteamAuthTransactions");
var ttl = Builders<SteamAuthTransaction>.IndexKeys.Ascending(x => x.ExpiresUtc);
_transactions.Indexes.CreateOne(new CreateIndexModel<SteamAuthTransaction>(ttl,
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
}
public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
public async Task<SteamAuthTransaction?> GetAsync(string id) =>
await _transactions.Find(x => x.Id == id).FirstOrDefaultAsync();
public async Task<bool> MarkVerifiedAsync(string id, string steamId, string status, string? userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "pending") &
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.Update
.Set(x => x.SteamId, steamId)
.Set(x => x.Status, status)
.Set(x => x.UserId, userId);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<bool> MarkCompleteAsync(string id, string steamId, string userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "requires_account") &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.SteamId, steamId) &
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.Update
.Set(x => x.Status, "complete")
.Set(x => x.UserId, userId);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
}
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
namespace AuthApi.Services;
public class SteamOpenIdService
{
public const string Endpoint = "https://steamcommunity.com/openid/login";
private static readonly Regex ClaimedIdPattern = new(
"^https?://steamcommunity\\.com/openid/id/(?<id>[0-9]{17})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IHttpClientFactory _httpClients;
public SteamOpenIdService(IHttpClientFactory httpClients) => _httpClients = httpClients;
public static string BuildAuthenticationUrl(string realm, string returnUrl)
{
var parameters = new Dictionary<string, string>
{
["openid.ns"] = "http://specs.openid.net/auth/2.0",
["openid.mode"] = "checkid_setup",
["openid.return_to"] = returnUrl,
["openid.realm"] = realm,
["openid.identity"] = "http://specs.openid.net/auth/2.0/identifier_select",
["openid.claimed_id"] = "http://specs.openid.net/auth/2.0/identifier_select"
};
return Endpoint + "?" + string.Join("&", parameters.Select(pair =>
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
}
public async Task<string?> VerifyCallbackAsync(IQueryCollection query, string expectedReturnUrl, CancellationToken cancellationToken)
{
if (!string.Equals(query["openid.mode"], "id_res", StringComparison.Ordinal) ||
!string.Equals(query["openid.op_endpoint"].ToString().TrimEnd('/'), Endpoint.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) ||
!string.Equals(query["openid.return_to"], expectedReturnUrl, StringComparison.Ordinal))
return null;
var claimedId = query["openid.claimed_id"].ToString();
if (!string.Equals(claimedId, query["openid.identity"], StringComparison.Ordinal))
return null;
var match = ClaimedIdPattern.Match(claimedId);
if (!match.Success)
return null;
var fields = query
.Where(pair => pair.Key.StartsWith("openid.", StringComparison.Ordinal))
.ToDictionary(pair => pair.Key, pair => pair.Value.ToString());
fields["openid.mode"] = "check_authentication";
var client = _httpClients.CreateClient(nameof(SteamOpenIdService));
client.Timeout = TimeSpan.FromSeconds(15);
using var response = await client.PostAsync(Endpoint, new FormUrlEncodedContent(fields), cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var validation = await response.Content.ReadAsStringAsync(cancellationToken);
return IsValidAuthenticationResponse(validation) ? match.Groups["id"].Value : null;
}
internal static bool IsValidAuthenticationResponse(string response) =>
response.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(line => string.Equals(line, "is_valid:true", StringComparison.Ordinal));
internal static string? ExtractSteamId(string claimedId)
{
var match = ClaimedIdPattern.Match(claimedId);
return match.Success ? match.Groups["id"].Value : null;
}
}
+43 -2
View File
@@ -1,5 +1,7 @@
using AuthApi.Models;
using MongoDB.Bson;
using MongoDB.Driver;
using System.Text.RegularExpressions;
namespace AuthApi.Services;
@@ -17,16 +19,55 @@ public class UserService
var keys = Builders<User>.IndexKeys.Ascending(u => u.Username);
_col.Indexes.CreateOne(new CreateIndexModel<User>(keys, new CreateIndexOptions { Unique = true }));
var steamKeys = Builders<User>.IndexKeys.Ascending(u => u.SteamId);
_col.Indexes.CreateOne(new CreateIndexModel<User>(steamKeys, new CreateIndexOptions<User>
{
Unique = true,
PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string"))
}));
}
public Task<User?> GetByUsernameAsync(string username) =>
_col.Find(u => u.Username == username).FirstOrDefaultAsync();
public async Task<User?> GetByUsernameAsync(string username) =>
await _col.Find(u => u.Username == username).FirstOrDefaultAsync();
public async Task<User?> GetByIdAsync(string id) =>
await _col.Find(u => u.Id == id).FirstOrDefaultAsync();
public async Task<User?> GetByEmailAsync(string email) =>
await _col.Find(u => u.Email == email).FirstOrDefaultAsync();
public async Task<List<User>> GetByLoginCandidatesAsync(string login)
{
var normalized = login.Trim();
var usernameMatch = await GetByUsernameAsync(normalized);
if (usernameMatch is not null)
return [usernameMatch];
var emailPattern = new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i");
return await _col.Find(Builders<User>.Filter.Regex(u => u.Email, emailPattern)).ToListAsync();
}
public async Task<User?> GetBySteamIdAsync(string steamId) =>
await _col.Find(u => u.SteamId == steamId).FirstOrDefaultAsync();
public Task CreateAsync(User user) => _col.InsertOneAsync(user);
public Task UpdateAsync(User user) =>
_col.ReplaceOneAsync(u => u.Id == user.Id, user);
public async Task<bool> TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry)
{
var filter = Builders<User>.Filter.Eq(u => u.Id, userId) &
(Builders<User>.Filter.Eq(u => u.SteamId, null) | Builders<User>.Filter.Eq(u => u.SteamId, steamId));
var update = Builders<User>.Update
.Set(u => u.SteamId, steamId)
.Set(u => u.RefreshToken, refreshToken)
.Set(u => u.RefreshTokenExpiry, refreshExpiry);
var result = await _col.UpdateOneAsync(filter, update);
return result.MatchedCount == 1;
}
public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id);
public Task<List<User>> GetAllAsync() =>
_col.Find(FilterDefinition<User>.Empty).ToListAsync();
}
+2
View File
@@ -2,6 +2,8 @@
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5000" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Steam": { "Realm": "https://pauth.ranaze.com", "ReturnUrl": "https://pauth.ranaze.com/api/Auth/steam/callback", "TransactionTtlMinutes": 5 },
"Legal": { "TermsVersion": "2026-07-20", "PrivacyVersion": "2026-07-20", "EffectiveUtc": "2026-07-20T00:00:00Z", "TermsUrl": "https://doctor.ranaze.com/terms.html", "PrivacyUrl": "https://doctor.ranaze.com/privacy.html" },
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}
+20
View File
@@ -20,6 +20,26 @@ spec:
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5000
env:
- name: QuestionsApi__BaseUrl
value: http://step3-questions.step3-questions.svc.cluster.local
- name: InternalPurge__Secret
valueFrom:
secretKeyRef:
name: medmind-internal-api
key: purge-secret
- name: Steam__Realm
value: https://pauth.ranaze.com
- name: Steam__ReturnUrl
value: https://pauth.ranaze.com/api/Auth/steam/callback
- name: Steam__TransactionTtlMinutes
value: "5"
- name: Legal__TermsVersion
value: "2026-07-20"
- name: Legal__PrivacyVersion
value: "2026-07-20"
- name: Legal__EffectiveUtc
value: "2026-07-20T00:00:00Z"
readinessProbe:
httpGet:
path: /healthz
+75
View File
@@ -1,3 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
@@ -16,40 +17,114 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldApi", "WorldApi\WorldApi.csproj", "{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthApi.Tests", "AuthApi.Tests\AuthApi.Tests.csproj", "{E5FE4B7B-6535-4D11-883A-63859E1B48CF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|x64.ActiveCfg = Debug|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|x64.Build.0 = Debug|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|x86.ActiveCfg = Debug|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|x86.Build.0 = Debug|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.Build.0 = Release|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x64.ActiveCfg = Release|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x64.Build.0 = Release|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x86.ActiveCfg = Release|Any CPU
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x86.Build.0 = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|x64.ActiveCfg = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|x64.Build.0 = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|x86.ActiveCfg = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|x86.Build.0 = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|Any CPU.Build.0 = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x64.ActiveCfg = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x64.Build.0 = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x86.ActiveCfg = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x86.Build.0 = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|x64.ActiveCfg = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|x64.Build.0 = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|x86.ActiveCfg = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|x86.Build.0 = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.Build.0 = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x64.ActiveCfg = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x64.Build.0 = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x86.ActiveCfg = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x86.Build.0 = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x64.ActiveCfg = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x64.Build.0 = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x86.ActiveCfg = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x86.Build.0 = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.Build.0 = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x64.ActiveCfg = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x64.Build.0 = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x86.ActiveCfg = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x86.Build.0 = Release|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x64.ActiveCfg = Debug|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x64.Build.0 = Debug|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x86.ActiveCfg = Debug|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x86.Build.0 = Debug|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.Build.0 = Release|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x64.ActiveCfg = Release|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x64.Build.0 = Release|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x86.ActiveCfg = Release|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x86.Build.0 = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x64.ActiveCfg = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x64.Build.0 = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x86.ActiveCfg = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x86.Build.0 = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.Build.0 = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x64.ActiveCfg = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x64.Build.0 = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x86.ActiveCfg = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x86.Build.0 = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x64.ActiveCfg = Debug|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x64.Build.0 = Debug|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x86.ActiveCfg = Debug|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x86.Build.0 = Debug|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.Build.0 = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x64.ActiveCfg = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x64.Build.0 = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.ActiveCfg = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.Build.0 = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x64.ActiveCfg = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x64.Build.0 = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x86.ActiveCfg = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x86.Build.0 = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|Any CPU.Build.0 = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x64.ActiveCfg = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x64.Build.0 = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x86.ActiveCfg = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE