mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
Compare commits
552 Commits
8a55c33666
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c02aad66a0 | |||
| 076d27a564 | |||
| 00b900d864 | |||
| fe453ab607 | |||
| 52cc478b38 | |||
| 4ea72be581 | |||
| 4560d2de8a | |||
| 4912837dd7 | |||
| aac00f148e | |||
| 52ee181042 | |||
| 4f48f0a6a8 | |||
| 0de97381b7 | |||
| ca70568fad | |||
| 8aa4af3a3a | |||
| 9ab1bec89a | |||
| 14da286e11 | |||
| fc2f5c8669 | |||
| 432e5a11e8 | |||
| 707aea5898 | |||
| 8ba045063d | |||
| 654f20e28f | |||
| 1becfb4f3f | |||
| 61a073099d | |||
| a1f30f6af9 | |||
| 8033d52db3 | |||
| 2702e53068 | |||
| 8b9ae35b43 | |||
| a4b362cb01 | |||
| 0a8f3924d0 | |||
| ccf7d0fbfe | |||
| f628ccfd35 | |||
| d40344a2c0 | |||
| 801fca7cb0 | |||
| 5765532409 | |||
| b8bcc1f3c1 | |||
| 5453e19761 | |||
| 129b0c7ef0 | |||
| 4248e51c60 | |||
| 320ec46ba2 | |||
| f6a87463c5 | |||
| 1dd05c75f1 | |||
| 2c648514ba | |||
| 6983ddd7df | |||
| 7d247bc516 | |||
| 089c127cc3 | |||
| b43ad207c1 | |||
| de263f30e8 | |||
| ad9289fb21 | |||
| ae4a6f937f | |||
| 7d50612abb | |||
| 24620be5c1 | |||
| 4c61b1e28d | |||
| 8810bf7d8f | |||
| 91b3fc938c | |||
| 79ab0d1404 | |||
| 5190cded56 | |||
| f09ef7da8f | |||
| ce17a45afb | |||
| 817572a6ce | |||
| a5cbba8ac9 | |||
| 482d4b0985 | |||
| f7958f3102 | |||
| d64920b0f9 | |||
| e6733bd6cb | |||
| 0acf144f45 | |||
| 61daf139c5 | |||
| 4e1dd0d24e | |||
| 3f3dada35f | |||
| 9a724bf562 | |||
| ea1c65acfb | |||
| 8507472635 | |||
| 759dbe2b65 | |||
| f50264dae6 | |||
| 6632cdace7 | |||
| b5b6bdea95 | |||
| 2050acd63d | |||
| 3b523cf525 | |||
| 854d160f27 | |||
| 25cf1e0cfa | |||
| addcea9fed | |||
| bcc2639a33 | |||
| a4de140424 | |||
| 947fefc95c | |||
| bd93a2657e | |||
| 2463713cde | |||
| aac81c89b6 | |||
| 3e0022ce9c | |||
| 2e9da3032c | |||
| 8c28374eb4 | |||
| cee0163eac | |||
| 4519f2db82 | |||
| 0f1a7403e4 | |||
| bef71e1dcf | |||
| a15368ed29 | |||
| 1976c6eac6 | |||
| eaf7ea8748 | |||
| f8af212e3f | |||
| aa446cfbfe | |||
| 781cbc35aa | |||
| 51f8008a38 | |||
| 1bce603c33 | |||
| cbefa86c5c | |||
| 6ebd6e59c1 | |||
| d59e0017f7 | |||
| 0bca441ca1 | |||
| 670466dbd7 | |||
| 55b88a3aa5 | |||
| 91658fbc13 | |||
| 84d27d82da | |||
| 3e9f3f2c4e | |||
| 4353abfd97 | |||
| f5fd3bb207 | |||
| 5dc659007e | |||
| 19ef84c3cb | |||
| fcb38b3d57 | |||
| 510138eb0a | |||
| b883338396 | |||
| a9b8f53ef1 | |||
| b15d19eec2 | |||
| 9b14109727 | |||
| 870b89d279 | |||
| bff71bfc2a | |||
| bfaf5d40ff | |||
| 6a9b269798 | |||
| badd0b1b47 | |||
| f7ab77dec5 | |||
| fc3a7ea359 | |||
| ffc7856993 | |||
| 807aaa4478 | |||
| 3bd2387dc3 | |||
| edd78d2548 | |||
| bda3fc5aff | |||
| e9ed8923c9 | |||
| 56e8e2554c | |||
| 75f9026ea1 | |||
| e376e1e80d | |||
| 2bdf876b47 | |||
| dac414274e | |||
| c580e46125 | |||
| 03bc723b70 | |||
| a10c6d47f9 | |||
| 3985b74bcf | |||
| ec0bc362cd | |||
| d819658ace | |||
| 4d8638e62f | |||
| 82691eaf80 | |||
| 07fd0fde44 | |||
| 889e30a434 | |||
| 27017043f9 | |||
| 429fb87c08 | |||
| fd1a4d9577 | |||
| 17e6a9bc20 | |||
| 87d43302e1 | |||
| 73e5d64ba2 | |||
| 7534d8436c | |||
| 1f05f6d524 | |||
| cfc82bcea5 | |||
| 1e5825b096 | |||
| c08c761af3 | |||
| 0c4ad6a5aa | |||
| 277ad4bf98 | |||
| 0ce2a49419 | |||
| a60c1a097e | |||
| f491725144 | |||
| 0f1864f8bc | |||
| 30a5a89164 | |||
| 6ac2d0fbb1 | |||
| fa2c93ec06 | |||
| 47aa196b59 | |||
| 51f6e19339 | |||
| 9240cd4b27 | |||
| 950e879861 | |||
| d3a457d8d0 | |||
| eb3b685af0 | |||
| 3151e54ab3 | |||
| 96f311c129 | |||
| 836cedec3c | |||
| 614b87f7e1 | |||
| 30a22c366b | |||
| 3eb47bb9d3 | |||
| 701d7a9a2e | |||
| bfeb822279 | |||
| 2ff348adf0 | |||
| d4bde67e0f | |||
| 0666d8d308 | |||
| e83f84f527 | |||
| b01db32908 | |||
| caa7e3e793 | |||
| bd617dbf06 | |||
| ff80ab46b7 | |||
| 84372204fd | |||
| 5630c5c8dc | |||
| 52e3d73678 | |||
| ad59c5f567 | |||
| 957bb65a26 | |||
| 455055c67c | |||
| da92be73ef | |||
| 515b06d97c | |||
| b110bfc5f7 | |||
| b4d2dd8a9f | |||
| bb9f25ee8d | |||
| 81346ac149 | |||
| e1abac1271 | |||
| aa97259165 | |||
| 9cc68d7707 | |||
| 6366b5e1f6 | |||
| d04523accd | |||
| 05e8a1b398 | |||
| e56850a236 | |||
| 066aee96cc | |||
| e78f805c92 | |||
| 34cc994598 | |||
| 398d6ade61 | |||
| 2e1c010fa2 | |||
| 95e82cc719 | |||
| 9a72dd5eab | |||
| fc000e71d5 | |||
| 4a1a6f6697 | |||
| 72e8d27633 | |||
| 55706ba9ea | |||
| 71935e61b5 | |||
| 25236cc0a1 | |||
| 4b243f1a47 | |||
| 181a928c87 | |||
| c4c2ada1f6 | |||
| 76c1c3d600 | |||
| 7c044b7094 | |||
| e3fad7064b | |||
| c65cd2d17e | |||
| 3317574bf2 | |||
| bf396afcaf | |||
| b72a7cf843 | |||
| 063dff463d | |||
| 00d6b1edd3 | |||
| a57b582cf1 | |||
| b33f681ffa | |||
| b1783abdce | |||
| 88eb510dc3 | |||
| 8129bb2571 | |||
| 6042c3b154 | |||
| 9b76d47c52 | |||
| e934bbfe44 | |||
| e376675fa6 | |||
| 7170400f49 | |||
| 4c1ed87344 | |||
| 7b2f9c26f4 | |||
| 9004800326 | |||
| 533ac1afab | |||
| dc901f5951 | |||
| 67da422ea6 | |||
| 0bf33e7fe8 | |||
| 20f376f713 | |||
| d0c96422b2 | |||
| 65f0ad5dfd | |||
| 98ff2aed81 | |||
| 0787702f23 | |||
| f1b9527d3f | |||
| c5f6c95ca5 | |||
| d1bcb51225 | |||
| f1b8366531 | |||
| d85c8b8194 | |||
| 6ef55affcb | |||
| 9ae01ecc5a | |||
| 8bd1455a2c | |||
| 315c524c42 | |||
| 75cb8faac4 | |||
| cc12260225 | |||
| 1d926e705b | |||
| 4e88ea80f3 | |||
| b24f9fc448 | |||
| 8c2dc66c7c | |||
| 334d8a26ac | |||
| 74e50caf70 | |||
| d15d16d593 | |||
| ec82367c50 | |||
| a6d2bdf8bd | |||
| bae7458668 | |||
| b7642ad2be | |||
| 3dbecb0bd5 | |||
| a439a1059b | |||
| 80d6ee8cf5 | |||
| 69a8402f11 | |||
| 5812386676 | |||
| e62909d457 | |||
| 12b9712a8d | |||
| b4ea50d76a | |||
| bdb3c8f4a7 | |||
| 498f219ab9 | |||
| c0861bfcad | |||
| 68e76b5feb | |||
| 863cf61f1a | |||
| 802e5fc96f | |||
| f3b56f70e3 | |||
| 9ec707674a | |||
| 207ab47866 | |||
| dad26a164c | |||
| 04b5d3b29d | |||
| d081a72b9a | |||
| f096e8ff0b | |||
| 79a6092b28 | |||
| aa93aeec95 | |||
| 1e1db3525e | |||
| 544f76c502 | |||
| d588898f5d | |||
| 939b7a9584 | |||
| 520613aab0 | |||
| 330f99bb0e | |||
| 79318b56bd | |||
| fe0a0b72a8 | |||
| 4627dd58fb | |||
| c8c363e667 | |||
| 6237a25a69 | |||
| 801a8487f5 | |||
| 3168dd9897 | |||
| 01bb9a9431 | |||
| 57fe6f4acc | |||
| a1ae36a54c | |||
| 8a9972b6fc | |||
| f7657ad9ad | |||
| 6edaedb50d | |||
| 521b8122ac | |||
| 8fa53b778c | |||
| e5c4e0b89a | |||
| 77c04a57a9 | |||
| 80a47d850e | |||
| bf160e4237 | |||
| fc2faf9723 | |||
| d8245047a9 | |||
| c0a1ef94f0 | |||
| 21c57a6d22 | |||
| 4cad0f0cce | |||
| 4c2ade3930 | |||
| 14e1e62deb | |||
| 9b26f867bc | |||
| fd18cf6ac0 | |||
| 4278c04d60 | |||
| 0bad9e07db | |||
| 8b76f80b5c | |||
| 270ab00c52 | |||
| d9e806ed27 | |||
| 17bd7768eb | |||
| bf64cce947 | |||
| caece00e7f | |||
| dbdfaa5d9e | |||
| 42ac34ca70 | |||
| ea6939e18d | |||
| 63eb43d50a | |||
| e9d9f59a6a | |||
| 4eaa3304c3 | |||
| 1490ff7fcf | |||
| 3817df2a12 | |||
| 06a4ea0a02 | |||
| 66d114bfe3 | |||
| 6d3490da14 | |||
| e23243ff56 | |||
| 67609d71c0 | |||
| 7e5cfdeceb | |||
| 99df7e20e0 | |||
| d937cb153c | |||
| 4fb7ddfecf | |||
| 9dc1cc2d6f | |||
| 013eb0778b | |||
| 25fdc2c2c8 | |||
| 55c46f56ec | |||
| 6f7d61eafb | |||
| 03ff8e485e | |||
| e0ba6c6ead | |||
| 7807b9706b | |||
| 11599889fa | |||
| 2d750cbcab | |||
| d882469c79 | |||
| 7a3d520608 | |||
| dd80b52a11 | |||
| 388300c553 | |||
| eebab1bc19 | |||
| a70a0ebc74 | |||
| febc69bdef | |||
| 8b5b5333c6 | |||
| 55e07648cf | |||
| 0023bdab6e | |||
| 931e51a647 | |||
| 4bbaf0976f | |||
| e729570010 | |||
| b1966a3423 | |||
| 7803e1ec7c | |||
| def60169a8 | |||
| 25cc182793 | |||
| e170bcf0ef | |||
| d0952adaf9 | |||
| bdc89303b4 | |||
| 18538e833b | |||
| fe9f6f3cb5 | |||
| 18888ed520 | |||
| 4d83ec1525 | |||
| 58508bd87c | |||
| e7c835af52 | |||
| 6caf719158 | |||
| 5b80c97337 | |||
| bd26aa3dc4 | |||
| 9d210d254a | |||
| c8a542a3af | |||
| eadaa7b54e | |||
| 414df530f0 | |||
| 5234390931 | |||
| eb8eecf080 | |||
| 3aad68a6e6 | |||
| e25d61d80e | |||
| 66a1ee8007 | |||
| 172775c141 | |||
| 4b01b7fc88 | |||
| 68d832f7bc | |||
| d8ea2f4ac7 | |||
| 82bb9baec6 | |||
| 9848f4b92d | |||
| aeb37a4c6e | |||
| afcb01d155 | |||
| c5678ce877 | |||
| 76aad61191 | |||
| 467c25b20c | |||
| 0cdb60c0d9 | |||
| 23134796ee | |||
| fec4f91672 | |||
| 5b40bf9066 | |||
| 63f4f11aa8 | |||
| f520584368 | |||
| eef7cf28da | |||
| 30b4560bd5 | |||
| 1eb6e8f9f4 | |||
| 3ae2daec88 | |||
| 7f7516d9a0 | |||
| 3533e8ae6c | |||
| fe246bf54e | |||
| 4b82347d43 | |||
| 60fe2caf8f | |||
| 161d2cdceb | |||
| a4bc8cdac8 | |||
| 0be51ab902 | |||
| a16db39884 | |||
| d258f17852 | |||
| d77563147c | |||
| fcc5d82763 | |||
| 6b3db98327 | |||
| 69f1ef6be1 | |||
| 99680dbf6f | |||
| 585056ccc3 | |||
| e1807d2492 | |||
| 3e94960f0a | |||
| 222bbb5b84 | |||
| fddbebb33b | |||
| 62c1478913 | |||
| 66a67c931d | |||
| 550d73f1d7 | |||
| 47550aefae | |||
| 5106ac64da | |||
| 39ebfce1bb | |||
| 2379c154a5 | |||
| 5265b1738b | |||
| 4d3a8cc217 | |||
| 8253d772cb | |||
| a2a7107dd7 | |||
| b3284d4bd6 | |||
| ae164e625a | |||
| 9fe6c57b5d | |||
| 1a43a342ea | |||
| 59cf29949f | |||
| b2d68d93cf | |||
| 068b66b234 | |||
| e37a519ef8 | |||
| a45d2ddbb7 | |||
| 4c131db2ca | |||
| 948d603c0e | |||
| 9263133e64 | |||
| 3b208ae860 | |||
| b47c7dc3fa | |||
| b1314074a8 | |||
| 52a96de8d6 | |||
| 72d4a604c2 | |||
| 90126a4be7 | |||
| b2ee9ec92d | |||
| 0551fb0b1f | |||
| 6253b620a9 | |||
| faede927fc | |||
| 307828ff7f | |||
| 7b6e22292a | |||
| 4b5e40bff7 | |||
| bf4da9fd39 | |||
| 229ded8613 | |||
| 4dcf98cbb0 | |||
| 02a11704ce | |||
| 91e536425d | |||
| 0f1cc17af6 | |||
| 236cca30ba | |||
| 846663e320 | |||
| 726fe1ce2e | |||
| 88b5ffedb2 | |||
| 79e66c7a95 | |||
| 8d1b407bb0 | |||
| 77c01dc531 | |||
| cd30098eca | |||
| caa875f15c | |||
| 58e8a5c523 | |||
| bc7136b2cb | |||
| e13a64756f | |||
| 63332435d2 | |||
| 4e66c5758c | |||
| 723f8aea5d | |||
| c0d1c33f54 | |||
| 3dde0ffb79 | |||
| 0364d3f172 | |||
| 1902084523 | |||
| bbabd259b6 | |||
| 217ae263cd | |||
| 3b5f50023b | |||
| 518df3a73a | |||
| 893db17c03 | |||
| 7ce28559d2 | |||
| b1b4608dd8 | |||
| e702661388 | |||
| dfe8d46d99 | |||
| 9c6d48ae2c | |||
| 81e68bb5fa | |||
| 2b8bce5e4b | |||
| 9810ee543f | |||
| a0195987bd | |||
| 698413cd91 | |||
| ecc78b7a2a | |||
| a616b7637e | |||
| 7c4b64b50a | |||
| 637b522486 | |||
| 864e4e8aaf | |||
| b04f3318b9 | |||
| cc2cd80a01 | |||
| 1793eb7621 | |||
| 7643dbc439 | |||
| cf212d94f9 | |||
| 997175c753 | |||
| 6c0163c3ec | |||
| b79d358db9 | |||
| 07fe144b2f | |||
| f5d9c08468 | |||
| e3119bf77c | |||
| 4264a2bbd3 | |||
| d864ce2475 | |||
| 15fdd989e2 | |||
| 5b8638e15e | |||
| f3e7538fb7 | |||
| af8592082e | |||
| 62ee3f2777 | |||
| 835233672f | |||
| 8d4a0640e2 | |||
| bcc12aad19 | |||
| 4ffa1543cc |
@@ -0,0 +1,23 @@
|
||||
name: Agones Integration
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile
|
||||
- Makefile
|
||||
- deploy/k8s/**
|
||||
- scripts/verify_kind_agones.sh
|
||||
- .github/workflows/agones-integration.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
kind-agones:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify disposable kind/Agones lifecycle
|
||||
run: make verify-kind-agones
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Allocated Compose Smoke
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile
|
||||
- Makefile
|
||||
- compose.allocated-smoke.yml
|
||||
- server/api/**
|
||||
- server/store/**
|
||||
- server/workload/**
|
||||
- server/migrations/**
|
||||
- scripts/verify_allocated_compose.sh
|
||||
- .github/workflows/allocated-compose.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
allocated-compose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify independent allocated Compose flow
|
||||
run: make verify-allocated-compose
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Multiplayer Chaos Recovery
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile
|
||||
- Makefile
|
||||
- compose.chaos-smoke.yml
|
||||
- server/cmd/maintenance/**
|
||||
- server/store/**
|
||||
- server/migrations/**
|
||||
- scripts/verify_chaos_recovery.sh
|
||||
- .github/workflows/multiplayer-chaos.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-restart-recovery:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify API restart and stalled-allocation recovery
|
||||
run: make verify-chaos-recovery
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Multiplayer API Load
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- server/api/**
|
||||
- server/domain/**
|
||||
- server/matcher/**
|
||||
- Makefile
|
||||
- .github/workflows/multiplayer-load.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-load:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify 10,000-client API load boundary
|
||||
run: make verify-multiplayer-load
|
||||
@@ -0,0 +1,47 @@
|
||||
# The Go control plane is ~24k lines, and until this workflow existed the only
|
||||
# Go tests CI ever ran were the two load tests in multiplayer-load.yml. Nothing
|
||||
# else — domain policy, the wire/store boundaries, the allocator, the Steam
|
||||
# adapter — gated a change. The Godot unit suite is covered (verify-phase6 runs
|
||||
# test_runner.tscn as its first step); this closes the equivalent gap on the
|
||||
# Go side.
|
||||
#
|
||||
# Deliberately Docker-free and cluster-free so it stays fast enough to gate
|
||||
# every push. Tests that need a real PostgreSQL or Redis are behind the
|
||||
# `integration` build tag and stay with their own scripts; `go vet` is still
|
||||
# run over that tag so those files cannot rot uncompiled.
|
||||
name: Server Unit Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
# Integration-tagged files are excluded from the default build, so
|
||||
# without this a signature change could leave them broken until someone
|
||||
# ran the integration scripts by hand.
|
||||
- name: Vet integration-tagged tests
|
||||
run: go vet -tags integration ./...
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
# The control plane is concurrent by design: outbox dispatchers, the
|
||||
# event hub, the matcher worker and the allocator all run in parallel.
|
||||
- name: Test with race detector
|
||||
run: go test -race ./...
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Supply Chain Policy
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
repository-policy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify immutable image references and secret hygiene
|
||||
run: make verify-supply-chain
|
||||
- name: Verify release process is documented
|
||||
run: test -s docs/SUPPLY-CHAIN.md
|
||||
@@ -0,0 +1,141 @@
|
||||
# Investigate and fix the failing Agones Integration CI gate
|
||||
|
||||
## Task
|
||||
|
||||
`make verify-kind-agones` (workflow `.github/workflows/agones-integration.yml`,
|
||||
script `scripts/verify_kind_agones.sh`) fails. Find the root cause and fix it so
|
||||
the gate passes on CI. Repo: `jcreek/CosmicClash`, branch `feat/multiplayer`,
|
||||
PR #30.
|
||||
|
||||
## What is already known — do not re-derive this
|
||||
|
||||
**The failure.** `helm upgrade --install agones ... --wait --timeout 5m` fails
|
||||
with `Error: context deadline exceeded`. Immediately before, Helm reports:
|
||||
|
||||
```
|
||||
resource Deployment/agones-system/agones-controller not ready. status: InProgress, message: Available: 0/1
|
||||
resource Deployment/agones-system/agones-extensions not ready. status: InProgress, message: Available: 0/1
|
||||
resource Deployment/agones-system/agones-allocator not ready. status: InProgress, message: Available: 0/1
|
||||
```
|
||||
|
||||
So the cluster is created, the game-server image loads, and the Agones chart
|
||||
installs — but none of its Deployments become Available inside 5 minutes. The
|
||||
script never reaches the parts that exercise this repo's own manifests.
|
||||
|
||||
**It is pre-existing.** It fails identically at `089c127c`, the branch head
|
||||
before recent work. It is not caused by the branch's changes. Do not assume a
|
||||
recent commit broke it.
|
||||
|
||||
**It is not architecture-specific.** It fails the same way on GitHub's
|
||||
`ubuntu-24.04` amd64 runners and on an arm64 macOS developer machine. Agones
|
||||
1.49.0 publishes both amd64 and arm64 images.
|
||||
|
||||
**It is not a Helm kubeVersion rejection.** Agones charts 1.49.0, 1.50.0 and
|
||||
1.51.0 declare no `kubeVersion` constraint, so Helm is not refusing the
|
||||
Kubernetes version — the pods are being created and are not becoming ready.
|
||||
|
||||
**Ruled out as a red herring:** reproducing locally on a machine with heavy
|
||||
Docker usage produced `FailedCreatePodSandBox: containerd connection reset`,
|
||||
which is local resource pressure, not the CI cause. If you see that locally,
|
||||
clear Docker state and retry rather than chasing it.
|
||||
|
||||
**There may be two distinct failures, not one.** After `docker system prune`,
|
||||
a local run got *past* the Agones install cleanly (controller and allocator
|
||||
both reached "condition met") and failed later, at:
|
||||
|
||||
```
|
||||
scripts/verify_kind_agones.sh:146
|
||||
kubectl wait --for=jsonpath='{.status.ready}'=2 fleet/cosmic-clash-game -n cosmic-clash --timeout=5m
|
||||
error: timed out waiting for the condition on fleets/cosmic-clash-game
|
||||
```
|
||||
|
||||
So locally the Agones install is fine and the **Fleet's game-server pods never
|
||||
become Ready**; on CI the run never gets that far because the Agones install
|
||||
itself times out. Treat these as potentially separate problems: fixing the CI
|
||||
Agones timeout may simply expose the Fleet one underneath. Both need to pass.
|
||||
|
||||
The Fleet failure is the more suspicious of the two for recent work, because
|
||||
`deploy/k8s/base/fleet.yaml` changed: the join-signing key material moved from
|
||||
a single raw-bytes secret key (`join-signing-key`) to a JSON map
|
||||
(`join-signing-keys.json`), and the mount's `items[].key` moved with it. The
|
||||
script's `kubectl create secret` was updated to match and does succeed
|
||||
(`secret/cosmic-clash-game-server created`), so the obvious mismatch is not
|
||||
present -- but verify the pod actually mounts and starts rather than assuming.
|
||||
Note the script's `sed` also strips `--allocated-mode` and the roster path and
|
||||
blanks `--control-plane-url`, so the game server runs in a reduced mode here;
|
||||
check whether it is failing for a reason unrelated to the key at all.
|
||||
|
||||
## Pinned versions (all in `scripts/verify_kind_agones.sh`)
|
||||
|
||||
| Thing | Value | Override |
|
||||
|---|---|---|
|
||||
| Agones chart | `1.49.0` | `AGONES_VERSION` |
|
||||
| kind node image | `kindest/node:v1.33.1` (Kubernetes 1.33) | `KIND_NODE_IMAGE` |
|
||||
| Cluster | single node, `--wait 120s` | `KIND_CLUSTER_NAME` |
|
||||
| Runner | `ubuntu-latest` (ubuntu-24.04), 30 min timeout | — |
|
||||
|
||||
The chart is installed with `--set agones.controller.replicas=1`,
|
||||
`agones.extensions.replicas=1`, `agones.allocator.replicas=1`, and
|
||||
`agones.extensions.resources.{requests,limits}.ephemeral-storage` lowered to
|
||||
128Mi/512Mi. That ephemeral-storage override already exists because Agones 1.49
|
||||
otherwise requests 10,100 MiB and will not schedule on a default kind node —
|
||||
there is a comment saying so. **A similar resource-fit problem for the other
|
||||
Deployments is a strong hypothesis worth checking first.**
|
||||
|
||||
## Diagnostics are already in place
|
||||
|
||||
The script now dumps, on any failure and before the cluster is deleted: node
|
||||
capacity and conditions, pods in `agones-system` and `cosmic-clash`, recent
|
||||
events per namespace, and describe + current/previous logs for every not-ready
|
||||
pod. Set `KIND_KEEP_ON_FAILURE=1` to retain the cluster for interactive
|
||||
inspection instead of deleting it.
|
||||
|
||||
Its first run revealed a bug in the diagnostics themselves: a
|
||||
`kubectl cluster-info` reachability guard suppressed the entire dump. That
|
||||
guard has been removed, so the dump now always runs on failure.
|
||||
|
||||
**Start by reading that output**, either from a CI run or a local run. The most
|
||||
likely candidates it will distinguish between:
|
||||
|
||||
1. **Resource pressure** — `FailedScheduling ... Insufficient cpu/memory/
|
||||
ephemeral-storage`. Fix by lowering requests for the other Deployments the
|
||||
way extensions already is, or by giving the kind cluster more capacity.
|
||||
2. **Version incompatibility** — Agones 1.49 against Kubernetes 1.33. Check
|
||||
Agones' release notes for its supported Kubernetes range; if 1.33 is outside
|
||||
it, either raise `agones_version` or lower `kind_node_image`. Confirm the
|
||||
pairing is one Agones actually tests.
|
||||
3. **Probe/readiness failure** — pods Running but never Ready. The pod logs and
|
||||
describe output will show the failing probe.
|
||||
4. **Image pull** — `ImagePullBackOff` on an Agones image.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Do not weaken the gate to make it pass.** Removing `--wait`, extending the
|
||||
timeout to hide a real failure, or `|| true` around the install are all wrong.
|
||||
If the cause is genuinely a timeout on slow-but-working startup, raising it
|
||||
is acceptable *only* with evidence that the pods do become Available, and the
|
||||
new value should be justified in a comment.
|
||||
- Keep it a disposable, isolated cluster: it must not touch an existing cluster,
|
||||
and the EXIT trap must still remove the one it created.
|
||||
- If you change a pinned version, pin the new one explicitly and say why in the
|
||||
commit message. Do not float to `latest`.
|
||||
- `CLAUDE.md` applies: never create co-authored commits, never mention Claude.
|
||||
|
||||
## Verification
|
||||
|
||||
- `make verify-kind-agones` passes locally (needs Docker, kind, kubectl, Helm).
|
||||
- The `Agones Integration` workflow passes on PR #30. It is `pull_request`
|
||||
triggered with path filters on `Dockerfile`, `Makefile`, `deploy/k8s/**`,
|
||||
`scripts/verify_kind_agones.sh`, and its own workflow file — so a change to
|
||||
the script will trigger it.
|
||||
- Do not regress the other seven workflows. `Allocated Compose Smoke` was also
|
||||
failing and has just been fixed; confirm it stays green.
|
||||
|
||||
## Useful context
|
||||
|
||||
- `multiplayer-next.md` §7 task 8.49 describes what this gate is meant to prove.
|
||||
- `deploy/k8s/base/fleet.yaml` is the Fleet the script applies after Agones is
|
||||
up, with a `sed` that swaps the release digest placeholder for the locally
|
||||
built image and strips `--allocated-mode` and the roster path (there is no
|
||||
control plane in this disposable cluster).
|
||||
- The gate is a prerequisite for issue #17 (standing up a real cluster).
|
||||
@@ -6,7 +6,7 @@ Important rule: never create co-authored commits. Never mention Claude in commit
|
||||
|
||||
## Project overview
|
||||
|
||||
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. It is GDScript/Godot only today — the "C# backend" in README.md was never started, and the dedicated server is an export of this same Godot project. A **separate backend service is now planned** (not started) for casual/ranked matchmaking, which is a 1.0 launch blocker; see `docs/MATCHMAKING.md`. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 1–6). See `multiplayer-next.md` for what actually remains.
|
||||
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, a registry to publish images to, and a live Agones cluster; `TODO.md` orders them), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 1–6). See `multiplayer-next.md` for what actually remains.
|
||||
|
||||
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
|
||||
|
||||
@@ -14,15 +14,17 @@ Because the gameplay concept (vehicle soccer) can't be copyrighted but specific
|
||||
|
||||
The prose docs carry far more design rationale than the code comments, and several are load-bearing:
|
||||
|
||||
- `multiplayer-next.md` — **the current** multiplayer checklist. Short. Read this first for "what's left".
|
||||
- `multiplayer-todo.md` — 250 KB of historical design decisions, per-task implementation evidence, and §9's numbered "gotchas" list. Code comments cite it constantly by section/task number (`§2.4`, `task 5.10`); when a comment does, that section is the real explanation. Don't add new work here — it's the archive.
|
||||
- `multiplayer-next.md` — **the multiplayer task-tracking document**: outstanding work (§0), a numbered "gotchas" list (§9), and the current task breakdown with checkboxes (§7), all in one file. Start at §0 for "what's left". Day-to-day work is Phase 7 (Steam) and Phase 8 (matchmaking), whose numbered task breakdown and acceptance criteria live in §7, because that is the format tasks are picked up from. Phases 0–6 are done and archival — their evidence lives in git history, not the current doc.
|
||||
- `MULTIPLAYER_SPEC.md` — the architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget and match lifecycle state machine, as sections 1–6. Code comments across `Game/scripts/` cite it constantly by section number (`§2.4`, `§4.1`); many still say `multiplayer-next.md §N` for `N` 1–6 from before this doc was split out — when a comment does, the content is now here, not there. `multiplayer-next.md`'s own §7+ cites `§N` the same way and disambiguates by number (1–6 → this doc, 7+ → itself).
|
||||
- `TRAINING.md` — the full RL workflow (training, curriculum generations, export, eval, difficulty tiers).
|
||||
- `SERVER.md` — dedicated-server build, config, systemd deploy, sizing.
|
||||
- `STEAM.md` — optional GodotSteam custom-build setup and the transport contract.
|
||||
- `FLIGHT_MANUAL.md` — the player-facing flight model.
|
||||
- `docs/MATCHMAKING.md` — casual/ranked queue design. Not implemented; a 1.0 launch blocker, and the reason a backend service now exists in the plan.
|
||||
- `docs/TECH_STACK.md` — what the project is built with and why.
|
||||
- `TODO.md` — deferred non-multiplayer work (audio is the big one: there is none at all).
|
||||
- `docs/MATCHMAKING.md` — casual/ranked queue design, and the locked constraints (Go/PostgreSQL/Redis/Agones) the `server/` module implements. Partially implemented; a 1.0 launch blocker, and the reason a backend service outside the Godot project exists at all.
|
||||
- `docs/TECH_STACK.md` — what the project is built with and why, including the Go-vs-C#/Rust/C++ rationale for the matchmaking control plane.
|
||||
- `TODO.md` — deferred non-multiplayer work, **and** the ordered human-actionable backlog: which GitHub issue to do first, what each one unblocks, and which items are waiting on nobody. Start there when asking "what next". Audio is no longer absent — a procedural `AudioManager` covers UI, countdown, impact, goal and engine/turbo cues; what remains is authored assets.
|
||||
- `docs/THREAT-MODEL.md`, `docs/SUPPLY-CHAIN.md`, `docs/OBSERVABILITY.md`, `docs/MATCHMAKING-SLOs.md`, `docs/ADR-001-matchmaking-platform.md` — the control plane's security, release, telemetry and SLO contracts. `server/security/*.py` asserts several of them against the checked-in manifests, so changing a manifest often means changing one of these.
|
||||
- `docs/REVIEW-2026-09-feat-multiplayer.md` — a point-in-time adversarial review of this branch. Every finding in it is fixed; it is kept for the reasoning, not as a status report, and its header says so.
|
||||
|
||||
## Godot MCP server
|
||||
|
||||
@@ -108,7 +110,7 @@ GODOT_BIN=/path/to/godot make verify-enet-integration # non-default Godot b
|
||||
| `tests/networked_match_smoke.tscn` | see its header | Shorter attended variant of the above. |
|
||||
| `tests/net_sim_smoke.tscn` | see its header | The `--net-sim-*` latency/loss decorator actually changes observed behaviour. |
|
||||
|
||||
See `network_manager.gd`'s header comment and `multiplayer-todo.md` §9 gotchas 25–30 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
|
||||
See `network_manager.gd`'s header comment and `multiplayer-next.md` §9 gotchas 25–30 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
|
||||
|
||||
**`main_menu.tscn`'s Host/Join flow** is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add `MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"` to `project.godot [autoload]`, run `godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>` (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
|
||||
|
||||
@@ -132,7 +134,62 @@ To run one by hand, and for every config flag, see `SERVER.md`. `--smoke-force-g
|
||||
|
||||
### CI
|
||||
|
||||
`.github/workflows/` has exactly two jobs, both running the Make targets above: `dedicated-server-smoke.yml` (`make verify-phase6`) and `enet-integration.yml` (`make verify-enet-integration` inside the `enet-test` image). There is no unit-test-only workflow — `verify-phase6` runs `test_runner.tscn` as its first step.
|
||||
`.github/workflows/` has eight jobs, all but one running a Make target:
|
||||
|
||||
| Workflow | Runs | Needs |
|
||||
|---|---|---|
|
||||
| `server-unit-tests.yml` | `go build`/`vet`/`vet -tags integration`/`test`/`test -race` in `server/` | nothing (no Docker) |
|
||||
| `dedicated-server-smoke.yml` | `make verify-phase6` | Docker, several GB |
|
||||
| `enet-integration.yml` | `make verify-enet-integration` inside the `enet-test` image | Docker |
|
||||
| `allocated-compose.yml` | `make verify-allocated-compose` | Docker Compose |
|
||||
| `agones-integration.yml` | `make verify-kind-agones` | kind + Helm |
|
||||
| `multiplayer-chaos.yml` | `make verify-chaos-recovery` | Docker |
|
||||
| `multiplayer-load.yml` | `make verify-multiplayer-load` (two `-tags load` Go tests) | — |
|
||||
| `supply-chain.yml` | `make verify-supply-chain` | — |
|
||||
|
||||
**The Godot unit suite runs via `verify-phase6`**, which invokes `test_runner.tscn` as its first step — there is no separate Godot workflow. The Go unit suite has its own workflow because until it existed the only Go tests CI ran were `multiplayer-load`'s two load tests, so ~24k lines of control plane gated nothing.
|
||||
|
||||
Note what is *not* in CI: `make verify-multiplayer-local` (the combined local gate, which also runs the Python manifest/contract suites) and the `integration`-tagged Go tests, which need a real PostgreSQL/Redis and live in `scripts/run_*_integration.sh`. Run those by hand before landing server changes.
|
||||
|
||||
### When a gate fails, suspect the assertion first
|
||||
|
||||
The most expensive failures in this repo have not been broken behaviour. They
|
||||
have been **assertions that cannot distinguish the two states they implicitly
|
||||
claim to**, each reporting its own ambiguity as a confident verdict about the
|
||||
system under test. Five in one session, several costing multiple CI round trips:
|
||||
|
||||
| Assertion | What it actually conflated |
|
||||
|---|---|
|
||||
| `kubectl wait --for=jsonpath='{.status.ready}'` on an Agones Fleet | field does not exist vs. condition unmet — it could never pass |
|
||||
| `compose ps --status running \| grep -qx game-server` | not started *yet* vs. exited |
|
||||
| `remote_residual_position_p99 < 0.3` | real regression vs. host scheduling noise |
|
||||
| a validator reading `status.gameServer` | Agones' real response vs. an invented one, with unit tests asserting the invention |
|
||||
| `docker image inspect` guarding a build | image is current vs. image merely exists, so a rerun verified stale code |
|
||||
|
||||
Before theorising about the code, ask: **can this check tell "broken" from
|
||||
"not ready yet", "absent" from "unset", or "regressed" from "slow"?** If not,
|
||||
that is the bug, whatever else is also true.
|
||||
|
||||
Two habits follow from it, and both repeatedly beat reading code:
|
||||
|
||||
- **Make the script say what it saw before diagnosing why.** Most gates here are
|
||||
`curl -fsS` and bare `[[ ]]` under `set -e`, which abort silently — several CI
|
||||
runs produced nothing but `make: *** Error 1`. Report the failing line and
|
||||
command, print the value that failed its comparison, and dump the surrounding
|
||||
state *before* any cleanup trap destroys it. Every root cause found in that
|
||||
session came from doing this; essentially every confident guess made without
|
||||
it was wrong.
|
||||
- **Verify the diagnostics fire.** Two separate dumps were added and neither ran:
|
||||
one behind a `kubectl cluster-info` guard that misjudged reachability, one
|
||||
because a bare `trap ... ERR` does not fire inside functions or subshells
|
||||
without `set -E`. A diagnostic that has never been seen working is not
|
||||
evidence.
|
||||
|
||||
And when a test and the code agree but reality disagrees, suspect they were
|
||||
written together. A validator and its fixtures both encoded a response shape
|
||||
Agones never sends; nothing caught it because the gate had never run far enough
|
||||
to see a real one.
|
||||
|
||||
|
||||
### Other
|
||||
|
||||
@@ -148,7 +205,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
|
||||
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `free_play.tscn` (practice: no timer, R resets ball), `match.tscn` (150s timer, per-team score, kickoff resets), `spectate.tscn` (bot vs bot exhibition), `settings.tscn`, or — for online — `lobby.tscn` → `networked_match.tscn`. Esc returns to the menu. Canonical paths live in `scripts/scene_paths.gd`; use those constants rather than string literals.
|
||||
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; `AIShipController` runs an RL policy; `RLShipController` is driven by the training bridge; `LocalNetShipController` wraps another controller to record inputs into the network timeline. A ship with no controller is inert but simulated. The ShipAction shape *is* the RL action space and *is* what the wire format quantises — change it deliberately and everywhere at once.
|
||||
- **Arena vs game mode**: an arena (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting, an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`), two `Goal` instances, `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible.
|
||||
- **Arena registry**: `scripts/arena_registry.gd` is the single source of truth for the arena list — three settings × floor/elevated goal variants. `"random": true` gates which arenas Match/Spectate/the dedicated server may pick; **elevated-goal variants are Free-Play-only** until a checkpoint trained on `training_elevated.tscn` is promoted, because the current bots cannot score on an elevated goal. `path_for_match(match_index, mode)` is deliberately pure arithmetic so "the server cycles arenas" is unit-testable. `arena_base.tscn` is the scenery-free physical layout the dedicated server loads (clients still render the variant `MatchSim` names).
|
||||
- **Arena registry**: `scripts/arena_registry.gd` is the single source of truth for the arena list — three settings × floor/elevated goal variants. `"random": true` gates which arenas Match/Spectate/the dedicated server may pick; **elevated-goal variants are Free-Play-only** until a checkpoint trained on `training_elevated.tscn` is promoted, because the current bots cannot score on an elevated goal. `path_for_match(match_index, mode)` is deliberately pure arithmetic so "the server cycles arenas" is unit-testable. **The Go control plane keeps its own copy of the ranked-eligible subset** (`server/domain/ranked.go`'s `rankedArenas`), because ranked arena selection is a server-authoritative decision made before any Godot process exists. That copy is not free to drift: `server/domain/arena_registry_sync_test.go` parses this file and fails if the two disagree in either direction, or if a ranked path has no scene behind it. Editing the arena list therefore means updating `ranked.go` too — the test says so when it fails. `arena_base.tscn` is the scenery-free physical layout the dedicated server loads (clients still render the variant `MatchSim` names).
|
||||
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
||||
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning and the client/server parity trace depend on it.
|
||||
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes.
|
||||
@@ -184,6 +241,52 @@ Server process: `scenes/server_boot.tscn` (`server_boot.gd`) is the shell — st
|
||||
|
||||
Known-insecure, and the reason public hosting is gated: **slot reclaim is keyed by display name**, so anyone who knows a disconnected player's name can take their reserved slot. Verified Steam identity (Phase 7) is the fix. Don't expose a server to strangers before then.
|
||||
|
||||
### Matchmaking control plane (`server/`, Go)
|
||||
|
||||
The only component outside the Godot project, and roughly a third of the
|
||||
codebase. Layered so policy is testable without a database and persistence
|
||||
without a network:
|
||||
|
||||
- `domain/` (~3.2k lines) — **pure policy, no I/O**: matcher formation and
|
||||
rating tolerance, Glicko ratings and tiers, proposal/queue/match state
|
||||
machines, casual lineup and backfill selection, probe validation, join
|
||||
authorisations. Most behaviour worth asserting lives here and needs no
|
||||
fixture. `ranked.go`'s arena list is checked against `arena_registry.gd` (see
|
||||
Arena registry above).
|
||||
- `store/` (~5.3k) — PostgreSQL boundaries. Every mutation goes through
|
||||
`RunSerializable`; contention is expected rather than exceptional, so the
|
||||
retry budget and jittered backoff there are load-bearing, not decoration.
|
||||
- `api/` (~2.5k) — HTTP surface and the outbox dispatchers. `Service` is a
|
||||
struct of optional providers, each nil-guarded into a 503, which is why a
|
||||
binary can look healthy while a whole feature is unreachable — check what
|
||||
`cmd/*/main.go` actually assigns before concluding a feature is broken.
|
||||
- `allocator/`, `supervisor/`, `agones/` — allocation, the Go process that
|
||||
wraps the exported Godot server in an Agones pod, and the Agones client.
|
||||
- `matcher/`, `workload/`, `observability/`, `steam/`, `testkit/` — the matcher
|
||||
worker loop, workload-token signing, metrics, the Steam Web API adapter, and
|
||||
deterministic offline fakes.
|
||||
|
||||
`cmd/` holds seven binaries: `control-plane`, `matcher`, `allocator`,
|
||||
`maintenance`, `game-server-supervisor`, `migrate`, and `testkit-api`.
|
||||
**`testkit-api` is test-only** — it injects a fake Steam login that accepts any
|
||||
ticket, and must never be deployed in place of `control-plane`.
|
||||
|
||||
Three things that are easy to get wrong:
|
||||
|
||||
- **Integration tests are behind `//go:build integration`** and need a real
|
||||
PostgreSQL/Redis, so `go test ./...` silently skips them. Run them through
|
||||
`scripts/run_*_integration.sh`, which start their own disposable containers.
|
||||
`go vet -tags integration ./...` is worth running too, or those files rot
|
||||
uncompiled.
|
||||
- **Config is start-time.** Tier bands, the join-signing key set, Steam
|
||||
credentials and the probe providers are all read once in `main()`. Changing
|
||||
them is a rolling restart, not a hot reload — deliberate, and consistent with
|
||||
how everything else in these binaries is supplied.
|
||||
- **The wire contract is versioned.** `contracts/v1/openapi.json` and
|
||||
`state-transitions.json` are asserted by `contracts/v1/test_contracts.py`;
|
||||
changing a status code or operation ID without updating them breaks generated
|
||||
clients silently.
|
||||
|
||||
### Steam transport
|
||||
|
||||
`net_transport.gd` (`NetTransport`) is a deliberately narrow boundary: a transport only *creates a peer*; `NetworkManager` keeps ownership of polling, RPC policy and lifecycle. `enet_transport.gd` and `steam_transport.gd` implement it. `NetworkManager.host()/join()` default to `"enet"`; passing `"steam"` **never falls back** — a missing custom build or failed init returns an error naming the missing prerequisite (`steam_bootstrap.gd` produces those messages). Discovery and server advertisement are intentionally unimplemented until a project-owned App ID exists; the local default is Valve's Spacewar App ID 480, which must never be used to advertise servers or ship.
|
||||
|
||||
+80
-5
@@ -1,10 +1,12 @@
|
||||
# Local-only dedicated-server build and verification image. Pin the Godot
|
||||
# release family used by project.godot; no image is pushed by this repository.
|
||||
FROM --platform=linux/amd64 barichello/godot-ci:4.7.1 AS project-imported
|
||||
# barichello/godot-ci:4.7.1 (linux/amd64), resolved 2026-08-29.
|
||||
FROM --platform=linux/amd64 barichello/godot-ci@sha256:622e5ca81b54cd8038ecf7de5d157b47efc800d7cf635af2eec18a6aee4bab7e AS project-imported
|
||||
WORKDIR /workspace
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libfontconfig1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# The pinned headless Godot image already runs imports without fontconfig.
|
||||
# Do not refresh its old Ubuntu archive here: its historical keyring rejects
|
||||
# current Noble signatures, while this source-only import stage needs no OS
|
||||
# packages at all.
|
||||
COPY Game /workspace/Game
|
||||
# `--import` starts the editor, waits for resource import to finish, then
|
||||
# exits. Do not combine it with `--quit`, which ends the editor after one
|
||||
@@ -28,7 +30,11 @@ RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn"
|
||||
&& mkdir -p /opt/cosmic-clash \
|
||||
&& godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64
|
||||
|
||||
FROM --platform=linux/amd64 ubuntu:24.04 AS server
|
||||
# ubuntu:noble linux/amd64 manifest, resolved 2026-09-03. The prior pin
|
||||
# carried an obsolete archive keyring and rejected current Noble signatures
|
||||
# during apt-get update. This remains a digest pin; package verification is
|
||||
# deliberately not bypassed.
|
||||
FROM --platform=linux/amd64 ubuntu@sha256:1e0a86e57d247923571b75e0aaf48a1449cf8c543d51fb3e07a4a7d7bfa79316 AS server
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/
|
||||
COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server
|
||||
@@ -41,3 +47,72 @@ ENTRYPOINT ["/opt/cosmic-clash/cosmic-clash-server"]
|
||||
FROM exporter AS smoke-client
|
||||
WORKDIR /workspace
|
||||
ENTRYPOINT ["godot", "--headless", "--path", "Game", "res://tests/export_server_smoke.tscn", "--"]
|
||||
|
||||
# Builds the process supervisor (server/supervisor, multiplayer-next.md task
|
||||
# 8.27/8.28) that wraps the Agones-allocated dedicated server as PID 1.
|
||||
# golang:1.23-alpine (matches server/go.mod's `go 1.23`), resolved 2026-09-01.
|
||||
FROM --platform=linux/amd64 golang@sha256:383395b794dffa5b53012a212365d40c8e37109a626ca30d6151c8348d380b5f AS supervisor-build
|
||||
WORKDIR /workspace/server
|
||||
COPY server/go.mod server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY server/ ./
|
||||
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/game-server-supervisor ./cmd/game-server-supervisor
|
||||
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/control-plane ./cmd/control-plane
|
||||
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/testkit-api ./cmd/testkit-api
|
||||
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/matcher ./cmd/matcher
|
||||
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/allocator ./cmd/allocator
|
||||
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/maintenance ./cmd/maintenance
|
||||
|
||||
# Agones-allocated fleet image: the same dedicated-server export as `server`
|
||||
# (unchanged above; make verify-phase6 exercises that target exactly as
|
||||
# before), wrapped by the Go supervisor as PID 1 instead of the direct
|
||||
# launcher script -- required for process-ready/assignment-ready Agones SDK
|
||||
# calls and control-plane registration (multiplayer-next.md §8.27/§8.28).
|
||||
# deploy/k8s/base/fleet.yaml invokes this target with the deployment-specific
|
||||
# supervisor flags and mounts the roster/signing material required by the
|
||||
# allocated startup path. Workload credentials are delivered through the
|
||||
# Agones allocation annotation; a projected token volume is not required.
|
||||
FROM server AS game-server
|
||||
COPY --from=supervisor-build /opt/cosmic-clash/game-server-supervisor /opt/cosmic-clash/game-server-supervisor
|
||||
RUN chmod 0755 /opt/cosmic-clash/game-server-supervisor
|
||||
ENTRYPOINT ["/opt/cosmic-clash/game-server-supervisor"]
|
||||
|
||||
# The production control-plane API. deploy/k8s/base/control-plane-deployment.yaml
|
||||
# has always referenced this image, but nothing built it: cmd/control-plane was
|
||||
# absent from the Go build stage and no target existed, so the checked-in
|
||||
# Kubernetes base could not produce its own advertised topology.
|
||||
#
|
||||
# This must never be substituted with the testkit-api target below, which
|
||||
# injects a fake login provider that accepts any ticket string.
|
||||
FROM server AS control-plane
|
||||
COPY --from=supervisor-build /opt/cosmic-clash/control-plane /opt/cosmic-clash/control-plane
|
||||
COPY server/migrations /opt/cosmic-clash/migrations
|
||||
RUN chmod 0755 /opt/cosmic-clash/control-plane
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/opt/cosmic-clash/control-plane"]
|
||||
|
||||
# TEST ONLY. Supplies a fake Steam login that accepts any ticket; never deploy
|
||||
# this in place of the control-plane target above.
|
||||
FROM server AS testkit-api
|
||||
COPY --from=supervisor-build /opt/cosmic-clash/testkit-api /opt/cosmic-clash/testkit-api
|
||||
COPY server/migrations /opt/cosmic-clash/migrations
|
||||
RUN chmod 0755 /opt/cosmic-clash/testkit-api
|
||||
ENTRYPOINT ["/opt/cosmic-clash/testkit-api"]
|
||||
|
||||
FROM server AS matcher
|
||||
COPY --from=supervisor-build /opt/cosmic-clash/matcher /opt/cosmic-clash/matcher
|
||||
COPY server/migrations /opt/cosmic-clash/migrations
|
||||
RUN chmod 0755 /opt/cosmic-clash/matcher
|
||||
ENTRYPOINT ["/opt/cosmic-clash/matcher"]
|
||||
|
||||
FROM server AS allocator
|
||||
COPY --from=supervisor-build /opt/cosmic-clash/allocator /opt/cosmic-clash/allocator
|
||||
COPY server/migrations /opt/cosmic-clash/migrations
|
||||
RUN chmod 0755 /opt/cosmic-clash/allocator
|
||||
ENTRYPOINT ["/opt/cosmic-clash/allocator"]
|
||||
|
||||
FROM server AS maintenance
|
||||
COPY --from=supervisor-build /opt/cosmic-clash/maintenance /opt/cosmic-clash/maintenance
|
||||
COPY server/migrations /opt/cosmic-clash/migrations
|
||||
RUN chmod 0755 /opt/cosmic-clash/maintenance
|
||||
ENTRYPOINT ["/opt/cosmic-clash/maintenance"]
|
||||
|
||||
+52
-12
@@ -27,18 +27,58 @@ Welcome to space, pilot! This guide will teach you everything you need to know a
|
||||
|
||||
### Controller Controls
|
||||
|
||||
#### Translation
|
||||
Button names are the Xbox layout; a PlayStation pad maps the same physical
|
||||
positions (A = ✕, B = ○, X = □, Y = △).
|
||||
|
||||
- Left Stick - Strafe (Left/Right) + Thrust (Forward/Back)
|
||||
- Right Trigger - Forward Thrust
|
||||
- Left Trigger - Reverse Thrust
|
||||
- Face Buttons - Up/Down Thrust
|
||||
**The left stick points the nose, the right stick moves the hull.** Your ship has
|
||||
six degrees of freedom and a pad has exactly six analog axes, so every one gets a
|
||||
real axis rather than an on/off button.
|
||||
|
||||
#### Rotation
|
||||
#### Rotation — left stick and shoulders
|
||||
|
||||
- Right Stick - Pitch/Yaw
|
||||
- Shoulder Buttons - Roll
|
||||
- `A Button` - Turbo Boost
|
||||
- Left Stick (left/right) - Yaw
|
||||
- Left Stick (up/down) - Pitch. Flight-sim polarity by default: **push the
|
||||
stick forward and the nose goes down.** Flip it with "Invert pitch" in
|
||||
Settings → Controls.
|
||||
- `LB` - Roll Left (Bank Left)
|
||||
- `RB` - Roll Right (Bank Right)
|
||||
|
||||
#### Translation — right stick and triggers
|
||||
|
||||
- `RT` - Forward Thrust (Main Engines)
|
||||
- `LT` - Reverse Thrust (Retro Engines)
|
||||
- Right Stick (left/right) - Strafe (Port/Starboard Thrusters)
|
||||
- Right Stick (up/down) - Thrust Up/Down (Dorsal/Ventral Thrusters)
|
||||
- `L3` (click the left stick) - Turbo Boost
|
||||
|
||||
`X` and `Y` are deliberately unused in flight, and `A`/`B` are menu-only, so a
|
||||
reflexive face-button press never does anything mid-match.
|
||||
|
||||
#### Menus
|
||||
|
||||
- D-Pad or Left Stick - move the highlight
|
||||
- `A` - select
|
||||
- `B` - back
|
||||
- `Start` - leave a match in progress (deliberately not `B`, which is too easy
|
||||
to press by accident mid-game)
|
||||
|
||||
#### Other
|
||||
|
||||
- `R3` (click the right stick) - Toggle ball camera
|
||||
- `D-Pad Up` - Reset the ball (Free Play only)
|
||||
|
||||
The triggers and sticks are **analog**: a half-pulled trigger gives half thrust,
|
||||
and a gentle stick lean gives a gentle turn. Keyboard keys are all-or-nothing,
|
||||
which is the main reason a pad is easier to fly precisely.
|
||||
|
||||
### Rebinding
|
||||
|
||||
Every control above — keyboard and controller alike — can be remapped in
|
||||
**Settings → Controls**. Pick the device with the Keyboard/Controller toggle,
|
||||
click the binding you want to change, and press the key or button to assign.
|
||||
Binding an input that is already in use unbinds it from the action that had it,
|
||||
and the screen tells you which. "Reset all bindings to defaults" restores this
|
||||
table.
|
||||
|
||||
## 🛸 Basic Flight Principles
|
||||
|
||||
@@ -69,7 +109,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
#### Camera Control
|
||||
|
||||
- **Ball Cam**: Press `Enter` to toggle ball tracking camera
|
||||
- **Ball Cam**: Press `Space` (or `R3` on a controller) to toggle ball tracking camera
|
||||
- **Ship Cam**: Normal follow camera that looks where your ship points
|
||||
|
||||
### Intermediate Maneuvers
|
||||
@@ -115,7 +155,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
## 🎮 Ball Cam vs Ship Cam
|
||||
|
||||
### Ball Cam Mode (`Enter` to toggle)
|
||||
### Ball Cam Mode (`Space` / `R3` to toggle)
|
||||
|
||||
- **Camera**: Always looks toward the ball
|
||||
- **Ship Control**: Based on ship orientation (NOT camera view)
|
||||
@@ -133,7 +173,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
### Turbo System
|
||||
|
||||
- **Activation**: Hold `Shift` (keyboard) or `A` (controller) while thrusting forward
|
||||
- **Activation**: Hold `Shift` (keyboard) or `L3` (controller) while thrusting forward
|
||||
- **Effect**: 2.5x thrust multiplier on main engines only
|
||||
- **Strategy**: Use for quick acceleration or emergency maneuvers
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+39
-3
@@ -26,7 +26,9 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
|
||||
[autoload]
|
||||
|
||||
GameSettings="*res://scripts/game_settings.gd"
|
||||
ControlPlaneClient="*res://scripts/control_plane_client.gd"
|
||||
VideoSettings="*res://scripts/video_settings.gd"
|
||||
InputSettings="*res://scripts/input_settings.gd"
|
||||
BackgroundFPS="*res://scripts/background_fps.gd"
|
||||
PerfOverlay="*res://scripts/perf_overlay.gd"
|
||||
NetSim="*res://scripts/net_sim.gd"
|
||||
@@ -34,6 +36,7 @@ NetworkManager="*res://scripts/network_manager.gd"
|
||||
MatchNet="*res://scripts/match_net.gd"
|
||||
MatchSim="*res://scripts/match_sim.gd"
|
||||
NetDebugOverlay="*res://scripts/net_debug_overlay.gd"
|
||||
AudioManager="*res://scripts/audio_manager.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -53,42 +56,49 @@ enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
|
||||
reset_ball={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
move_forward={
|
||||
"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":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_back={
|
||||
"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":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_left={
|
||||
"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":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_right={
|
||||
"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":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_up={
|
||||
"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)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
|
||||
]
|
||||
}
|
||||
move_down={
|
||||
"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":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
turbo={
|
||||
"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":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":7,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
turn_left={
|
||||
@@ -106,13 +116,13 @@ turn_right={
|
||||
pitch_up={
|
||||
"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":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
|
||||
]
|
||||
}
|
||||
pitch_down={
|
||||
"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":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
|
||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
|
||||
]
|
||||
}
|
||||
roll_left={
|
||||
@@ -127,6 +137,32 @@ roll_right={
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
toggle_ball_cam={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":8,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
ui_cancel={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
ui_accept={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, 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":4194310,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
leave_gameplay={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
toggle_perf_overlay={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
|
||||
+38
-22
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1_lobby"]
|
||||
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||
|
||||
[node name="Lobby" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -10,27 +11,42 @@ anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_lobby")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(520, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 40
|
||||
text = "Lobby"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
@@ -39,74 +55,74 @@ text = "Connecting..."
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="TeamsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TeamsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TeamsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TeamsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Team0Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="Team0Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team0Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
[node name="Team0Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 1"
|
||||
|
||||
[node name="Team0List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
[node name="Team0List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="TeamsVSeparator" type="VSeparator" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="TeamsVSeparator" type="VSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Team1Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="Team1Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team1Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
[node name="Team1Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 2"
|
||||
|
||||
[node name="Team1List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
[node name="Team1List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="ControlsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ControlsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ControlsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ControlsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SwitchTeamButton" type="Button" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
[node name="SwitchTeamButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Switch Team"
|
||||
|
||||
[node name="ReadyButton" type="CheckButton" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
[node name="ReadyButton" type="CheckButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Ready"
|
||||
|
||||
[node name="LeaveButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="LeaveButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Leave"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||
[connection signal="toggled" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||
|
||||
+73
-50
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bcq14356s3e2i"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://bcq14356s3e2i"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"]
|
||||
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -10,120 +11,141 @@ anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_menu")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(420, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 48
|
||||
text = "Cosmic Clash"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="SubtitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SubtitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Physics-based soccer in space"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleSpacer" type="Control" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FreePlayButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Free Play"
|
||||
|
||||
[node name="FreePlayHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FreePlayHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Solo practice — no timer, R resets the ball"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ArenaRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ArenaRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="ArenaLabel" type="Label" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
||||
[node name="ArenaLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||
layout_mode = 2
|
||||
text = "Arena"
|
||||
|
||||
[node name="ArenaDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
||||
[node name="ArenaDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="MatchSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MatchHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Match"
|
||||
|
||||
[node name="MatchHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "A 2:30 match — you vs a trained bot"
|
||||
|
||||
[node name="MatchRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DifficultyLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"]
|
||||
[node name="DifficultyLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||
layout_mode = 2
|
||||
text = "Difficulty"
|
||||
|
||||
[node name="DifficultyDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"]
|
||||
[node name="DifficultyDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Play Match"
|
||||
|
||||
[node name="MultiplayerSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MultiplayerHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Multiplayer"
|
||||
|
||||
[node name="MultiplayerHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "LAN / direct IP — host a match or join one"
|
||||
|
||||
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FindMatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Find Match"
|
||||
|
||||
[node name="HostButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Host"
|
||||
|
||||
[node name="JoinRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="JoinRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="JoinAddressEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
[node name="JoinAddressEdit" type="LineEdit" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
@@ -131,12 +153,12 @@ size_flags_horizontal = 3
|
||||
text = "127.0.0.1"
|
||||
placeholder_text = "IP address"
|
||||
|
||||
[node name="JoinButton" type="Button" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
[node name="JoinButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||
custom_minimum_size = Vector2(96, 40)
|
||||
layout_mode = 2
|
||||
text = "Join"
|
||||
|
||||
[node name="MultiplayerErrorLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerErrorLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 0.5, 0.5, 1)
|
||||
layout_mode = 2
|
||||
@@ -145,82 +167,82 @@ text = ""
|
||||
autowrap_mode = 2
|
||||
visible = false
|
||||
|
||||
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SettingsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SettingsButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SettingsButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Settings"
|
||||
|
||||
[node name="DevSection" type="VBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="DevSection" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DevSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="DevHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Developer"
|
||||
|
||||
[node name="DevHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Dev-only — hidden in release builds"
|
||||
|
||||
[node name="DevOpponentRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevOpponentRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DevOpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
[node name="DevOpponentLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
layout_mode = 2
|
||||
text = "Opponent override"
|
||||
|
||||
[node name="DevBotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
[node name="DevBotDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Spectate"
|
||||
|
||||
[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Watch two bots play each other"
|
||||
|
||||
[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="BotADropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="VsLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
layout_mode = 2
|
||||
text = "vs"
|
||||
|
||||
[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="BotBDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Watch Match"
|
||||
@@ -272,11 +294,12 @@ custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Cancel"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
|
||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
|
||||
[connection signal="text_submitted" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/matchmaking.gd" id="1_matchmaking"]
|
||||
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||
|
||||
[node name="Matchmaking" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_matchmaking")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 40
|
||||
text = "Find a Match"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="PlaylistDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Ready to search"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="DetailLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
autowrap_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RankedProfileLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
text = "Ranked profile unavailable"
|
||||
horizontal_alignment = 1
|
||||
visible = false
|
||||
|
||||
[node name="QueueButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 52)
|
||||
layout_mode = 2
|
||||
text = "Search"
|
||||
|
||||
[node name="CancelButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Cancel Search"
|
||||
visible = false
|
||||
|
||||
[node name="ProposalRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="AcceptButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Accept"
|
||||
visible = false
|
||||
|
||||
[node name="DeclineButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Decline"
|
||||
visible = false
|
||||
|
||||
[node name="BackButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Back"
|
||||
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
+131
-45
@@ -1,6 +1,8 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/settings_menu.gd" id="1_settings"]
|
||||
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||
[ext_resource type="Script" path="res://scripts/controls_settings.gd" id="3_controls"]
|
||||
|
||||
[node name="SettingsMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -10,70 +12,90 @@ anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_settings")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 24
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(420, 0)
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 36
|
||||
text = "Settings"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
[node name="TabContainer" type="TabContainer" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
tab_alignment = 1
|
||||
|
||||
[node name="PresetRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="Video" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer"]
|
||||
custom_minimum_size = Vector2(420, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="PresetLabel" type="Label" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||
[node name="PresetRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="PresetLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Graphics preset"
|
||||
|
||||
[node name="PresetDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||
[node name="PresetDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="AARow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="AALabel" type="Label" parent="CenterContainer/VBoxContainer/AARow"]
|
||||
[node name="AALabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Anti-aliasing"
|
||||
|
||||
[node name="AADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/AARow"]
|
||||
[node name="AADropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ResolutionRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ResolutionRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="ResolutionLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
[node name="ResolutionLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Resolution scale"
|
||||
|
||||
[node name="ResolutionSlider" type="HSlider" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
[node name="ResolutionSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
@@ -84,23 +106,23 @@ max_value = 1.0
|
||||
step = 0.05
|
||||
value = 1.0
|
||||
|
||||
[node name="ResolutionValueLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
[node name="ResolutionValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="GlowRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="GlowLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
|
||||
[node name="GlowLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Glow intensity"
|
||||
|
||||
[node name="GlowSlider" type="HSlider" parent="CenterContainer/VBoxContainer/GlowRow"]
|
||||
[node name="GlowSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
@@ -111,23 +133,23 @@ max_value = 1.5
|
||||
step = 0.05
|
||||
value = 1.0
|
||||
|
||||
[node name="GlowValueLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
|
||||
[node name="GlowValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="BrightnessRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="BrightnessRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="BrightnessLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
[node name="BrightnessLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Brightness"
|
||||
|
||||
[node name="BrightnessSlider" type="HSlider" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
[node name="BrightnessSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
@@ -138,72 +160,136 @@ max_value = 1.3
|
||||
step = 0.02
|
||||
value = 1.0
|
||||
|
||||
[node name="BrightnessValueLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
[node name="BrightnessValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 0)
|
||||
layout_mode = 2
|
||||
text = "100%"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="VsyncRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="VsyncRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="VsyncLabel" type="Label" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||
[node name="VsyncLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "VSync"
|
||||
|
||||
[node name="VsyncDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||
[node name="VsyncDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="FpsCapRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FpsCapRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="FpsCapLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
[node name="FpsCapLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "FPS cap"
|
||||
|
||||
[node name="FpsCapDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
[node name="FpsCapDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="FpsReadoutRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FpsReadoutRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="FpsReadoutTitleLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
[node name="FpsReadoutTitleLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
custom_minimum_size = Vector2(110, 0)
|
||||
layout_mode = 2
|
||||
text = "Current"
|
||||
|
||||
[node name="FpsReadoutLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
[node name="FpsReadoutLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "0 fps"
|
||||
|
||||
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
[node name="Controls" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
script = ExtResource("3_controls")
|
||||
|
||||
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer"]
|
||||
custom_minimum_size = Vector2(520, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DeviceRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DeviceLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||
custom_minimum_size = Vector2(160, 0)
|
||||
layout_mode = 2
|
||||
text = "Device"
|
||||
|
||||
[node name="KeyboardButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
toggle_mode = true
|
||||
button_pressed = true
|
||||
text = "Keyboard"
|
||||
|
||||
[node name="ControllerButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
toggle_mode = true
|
||||
text = "Controller"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 0.85, 0.5, 1)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = ""
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="BindingList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="InvertPitchCheck" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
text = "Invert pitch"
|
||||
|
||||
[node name="ResetButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
text = "Reset all bindings to defaults"
|
||||
|
||||
[node name="BackButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Back"
|
||||
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
||||
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
|
||||
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
||||
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
|
||||
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
|
||||
[connection signal="pressed" from="MarginContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
class_name AgonesSDK
|
||||
extends Node
|
||||
|
||||
# Dependency-free REST bridge for the Agones sidecar. The Go supervisor owns
|
||||
# the process-ready probe and /ready transition; this node owns the game
|
||||
# process's periodic Health pings and terminal Shutdown/annotation calls.
|
||||
|
||||
const HEALTH_INTERVAL_SECONDS := 2.0
|
||||
const REQUEST_TIMEOUT_SECONDS := 2.0
|
||||
const MAX_ANNOTATION_VALUE_LENGTH := 4096
|
||||
|
||||
var _base_url := ""
|
||||
var _health_timer: Timer = null
|
||||
var _health_in_flight := false
|
||||
var _health_started_msec := 0
|
||||
# Set when start_health() is called before this node is inside the tree, so
|
||||
# _ready() can arm the timer at the first moment it is legal to do so.
|
||||
var _health_pending := false
|
||||
|
||||
|
||||
# Health is armed here rather than by the caller. A Timer only ticks while its
|
||||
# owner is inside the SceneTree, so arming it from a caller that has not yet
|
||||
# parented this node produces a node that looks configured and never pings —
|
||||
# which is exactly how every allocated GameServer silently failed its Agones
|
||||
# health check and was recycled.
|
||||
func _ready() -> void:
|
||||
if _health_pending:
|
||||
_health_pending = false
|
||||
_arm_health()
|
||||
|
||||
|
||||
func configure_from_environment() -> bool:
|
||||
var port := OS.get_environment("AGONES_SDK_HTTP_PORT")
|
||||
if port.is_empty() or not port.is_valid_int() or int(port) < 1 or int(port) > 65535:
|
||||
return false
|
||||
_base_url = "http://127.0.0.1:%d" % int(port)
|
||||
return true
|
||||
|
||||
|
||||
func configure_for_testing(base_url: String) -> bool:
|
||||
if not base_url.begins_with("http://127.0.0.1:") and not base_url.begins_with("http://localhost:"):
|
||||
return false
|
||||
_base_url = base_url.trim_suffix("/")
|
||||
return true
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return not _base_url.is_empty()
|
||||
|
||||
|
||||
# Returns whether health pings are running. It is a bool rather than void
|
||||
# because every way this can fail used to be silent, and a game server that
|
||||
# believes it is healthy while sending nothing is worse than one that refuses
|
||||
# to start: Agones recycles the former every ~20 seconds forever.
|
||||
func start_health() -> bool:
|
||||
if not is_available():
|
||||
push_error("AgonesSDK: start_health() before configuration; no health pings will be sent")
|
||||
return false
|
||||
if _health_timer != null:
|
||||
return true
|
||||
if not is_inside_tree():
|
||||
# Deferred rather than fatal: the caller may legitimately configure
|
||||
# before parenting. _ready() arms it. Still reported, because if the
|
||||
# node is never parented this is the whole failure.
|
||||
_health_pending = true
|
||||
push_warning("AgonesSDK: start_health() called outside the tree; deferring until ready")
|
||||
return false
|
||||
_arm_health()
|
||||
return true
|
||||
|
||||
|
||||
func _arm_health() -> void:
|
||||
if _health_timer != null:
|
||||
return
|
||||
_health_timer = Timer.new()
|
||||
_health_timer.name = "AgonesHealth"
|
||||
_health_timer.wait_time = HEALTH_INTERVAL_SECONDS
|
||||
_health_timer.one_shot = false
|
||||
_health_timer.timeout.connect(_send_health)
|
||||
add_child(_health_timer)
|
||||
_health_timer.start()
|
||||
_send_health()
|
||||
|
||||
|
||||
func health_is_running() -> bool:
|
||||
return _health_timer != null and is_inside_tree()
|
||||
|
||||
|
||||
func stop_health() -> void:
|
||||
if _health_timer != null:
|
||||
_health_timer.stop()
|
||||
_health_timer.queue_free()
|
||||
_health_timer = null
|
||||
|
||||
|
||||
func health() -> int:
|
||||
return await _request(HTTPClient.METHOD_POST, "/health", {})
|
||||
|
||||
|
||||
func mark_ready() -> int:
|
||||
return await _request(HTTPClient.METHOD_POST, "/ready", {})
|
||||
|
||||
|
||||
func shutdown() -> int:
|
||||
return await _request(HTTPClient.METHOD_POST, "/shutdown", {})
|
||||
|
||||
|
||||
func set_annotation(key: String, value: String) -> int:
|
||||
if not annotation_is_valid(key, value):
|
||||
return 400
|
||||
return await _request(HTTPClient.METHOD_PUT, "/metadata/annotation", {"key": key, "value": value})
|
||||
|
||||
|
||||
static func annotation_is_valid(key: String, value: String) -> bool:
|
||||
return not (key.is_empty() or value.is_empty() or value.length() > MAX_ANNOTATION_VALUE_LENGTH or "\n" in key or "\r" in key or "\n" in value or "\r" in value)
|
||||
|
||||
|
||||
func _send_health() -> void:
|
||||
if not is_available():
|
||||
return
|
||||
# The latch stops overlapping requests, but it must never become permanent.
|
||||
# It is set across an await, and a request that never completes would
|
||||
# otherwise silence health for the lifetime of the process. HTTPRequest's
|
||||
# own timeout normally resolves this; the elapsed check is the backstop for
|
||||
# the case where request_completed never fires at all.
|
||||
if _health_in_flight:
|
||||
var stuck_for := Time.get_ticks_msec() - _health_started_msec
|
||||
if stuck_for < int(REQUEST_TIMEOUT_SECONDS * 2.0 * 1000.0):
|
||||
return
|
||||
push_warning("Agones health ping did not complete in %dms; sending another" % stuck_for)
|
||||
_health_in_flight = true
|
||||
_health_started_msec = Time.get_ticks_msec()
|
||||
var status := await health()
|
||||
_health_in_flight = false
|
||||
if status < 200 or status >= 300:
|
||||
push_warning("Agones health ping failed (%d)" % status)
|
||||
|
||||
|
||||
func _request(method: int, path: String, payload: Dictionary) -> int:
|
||||
if not is_available() or not path.begins_with("/"):
|
||||
return 408
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = REQUEST_TIMEOUT_SECONDS
|
||||
add_child(request)
|
||||
var body := JSON.stringify(payload)
|
||||
var err := request.request(_base_url + path, PackedStringArray(["Content-Type: application/json"]), method, body)
|
||||
if err != OK:
|
||||
request.queue_free()
|
||||
return 599
|
||||
var result = await request.request_completed
|
||||
request.queue_free()
|
||||
return int(result[1])
|
||||
@@ -0,0 +1,85 @@
|
||||
class_name AssignmentState
|
||||
extends RefCounted
|
||||
|
||||
# Verified assignment-ready manifest returned by the control plane. The join
|
||||
# authorisation is retained in memory only and is never written to the restart
|
||||
# snapshot; transport installation belongs to the explicit ENet/Steam layer.
|
||||
|
||||
var available := false
|
||||
var match_id := ""
|
||||
var server_id := ""
|
||||
var slot := -1
|
||||
var expires_at := ""
|
||||
var protocol_version := 0
|
||||
var transport := ""
|
||||
var endpoint := ""
|
||||
var join_authorisation := ""
|
||||
var error_message := ""
|
||||
|
||||
|
||||
func apply(payload: Dictionary, expected_player_id: String = "") -> bool:
|
||||
for key in ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"]:
|
||||
if not payload.has(key):
|
||||
return _reject("Assignment response is missing " + key)
|
||||
if not payload["match_id"] is String or not payload["server_id"] is String or not payload["player_id"] is String or not _valid_nonnegative_integer(payload["slot"]) or not payload["expires_at"] is String or not _valid_nonnegative_integer(payload["protocol_version"]) or not payload["transport"] is String or not payload["endpoint"] is String or not payload["join_authorisation"] is String:
|
||||
return _reject("Assignment response contains invalid types")
|
||||
var next_match_id := String(payload["match_id"])
|
||||
var next_server_id := String(payload["server_id"])
|
||||
var next_transport := String(payload["transport"])
|
||||
var next_endpoint := String(payload["endpoint"])
|
||||
var next_player_id := String(payload["player_id"])
|
||||
var next_expires_at := String(payload["expires_at"])
|
||||
if not is_valid_expiry_timestamp(next_expires_at):
|
||||
return _reject("Assignment response contains invalid expiry")
|
||||
var expiry_unix := Time.get_unix_time_from_datetime_string(next_expires_at)
|
||||
if not is_valid_opaque_id(next_match_id) or not is_valid_opaque_id(next_server_id) or not is_valid_opaque_id(next_player_id) or (not expected_player_id.is_empty() and next_player_id != expected_player_id) or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or not _valid_endpoint(next_endpoint) or expiry_unix <= Time.get_unix_time_from_system() or String(payload["join_authorisation"]).is_empty():
|
||||
return _reject("Assignment response contains invalid values")
|
||||
match_id = next_match_id
|
||||
server_id = next_server_id
|
||||
slot = int(payload["slot"])
|
||||
expires_at = next_expires_at
|
||||
protocol_version = int(payload["protocol_version"])
|
||||
transport = next_transport
|
||||
endpoint = next_endpoint
|
||||
join_authorisation = String(payload["join_authorisation"])
|
||||
available = true
|
||||
error_message = ""
|
||||
return true
|
||||
|
||||
|
||||
static func is_valid_expiry_timestamp(value: String) -> bool:
|
||||
if value.is_empty():
|
||||
return false
|
||||
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
|
||||
return timestamp_pattern.search(value) != null
|
||||
|
||||
|
||||
static func is_valid_opaque_id(value: String) -> bool:
|
||||
if value.length() < 16 or value.length() > 128:
|
||||
return false
|
||||
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||
return resource_pattern.search(value) != null
|
||||
|
||||
|
||||
static func _valid_endpoint(value: String) -> bool:
|
||||
if value.is_empty() or value.contains("/") or value.contains("?") or value.contains("#"):
|
||||
return false
|
||||
var separator := value.rfind(":")
|
||||
if separator <= 0 or separator >= value.length() - 1:
|
||||
return false
|
||||
var port := value.substr(separator + 1)
|
||||
return port.is_valid_int() and int(port) >= 1 and int(port) <= 65535
|
||||
|
||||
|
||||
static func _valid_nonnegative_integer(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
|
||||
func _reject(reason: String) -> bool:
|
||||
available = false
|
||||
error_message = reason
|
||||
return false
|
||||
@@ -0,0 +1,149 @@
|
||||
extends Node
|
||||
|
||||
# Dependency-free audio foundation. Authored assets can replace these tones
|
||||
# later without changing gameplay call sites or the multiplayer event flow.
|
||||
|
||||
const SAMPLE_RATE := 44100
|
||||
const MAX_INTENSITY := 1.0
|
||||
|
||||
var enabled := true
|
||||
var _engine_player: AudioStreamPlayer
|
||||
var _engine_turbo := false
|
||||
var _last_wall_scrape_ms := -1000
|
||||
|
||||
|
||||
func bind_tree_buttons(root: Node) -> void:
|
||||
if root == null:
|
||||
return
|
||||
for node in root.find_children("*", "BaseButton", true, false):
|
||||
bind_button(node as BaseButton)
|
||||
|
||||
|
||||
func bind_button(button: BaseButton) -> void:
|
||||
if button == null:
|
||||
return
|
||||
var callback := Callable(self, "play_ui_click")
|
||||
if not button.pressed.is_connected(callback):
|
||||
button.pressed.connect(callback)
|
||||
|
||||
|
||||
func play_ui_click() -> void:
|
||||
_play_tone(880.0, 0.045, 0.10)
|
||||
|
||||
|
||||
func play_countdown(count: int) -> void:
|
||||
if count <= 0:
|
||||
_play_tone(1046.5, 0.12, 0.18)
|
||||
else:
|
||||
_play_tone(countdown_frequency(count), 0.08, 0.14)
|
||||
|
||||
|
||||
func play_impact(intensity: float) -> void:
|
||||
var amount := clamp_intensity(intensity)
|
||||
if amount <= 0.0:
|
||||
return
|
||||
_play_tone(150.0 + 180.0 * amount, 0.06 + 0.08 * amount, 0.08 + 0.18 * amount)
|
||||
|
||||
|
||||
func play_wall_scrape(intensity: float) -> void:
|
||||
var amount := clamp_intensity(intensity)
|
||||
if amount <= 0.0:
|
||||
return
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
if now_ms - _last_wall_scrape_ms < 80:
|
||||
return
|
||||
_last_wall_scrape_ms = now_ms
|
||||
_play_tone(110.0 + 90.0 * amount, 0.05 + 0.07 * amount, 0.05 + 0.10 * amount)
|
||||
|
||||
|
||||
func play_goal() -> void:
|
||||
_play_tone(523.25, 0.22, 0.22)
|
||||
_play_tone(783.99, 0.30, 0.18)
|
||||
|
||||
|
||||
func set_engine_state(thrust: float, turbo: bool) -> void:
|
||||
var amount := clamp_intensity(thrust)
|
||||
var rising_turbo := should_play_turbo_cue(_engine_turbo, turbo, amount)
|
||||
if not enabled or amount <= 0.01:
|
||||
stop_engine()
|
||||
return
|
||||
if rising_turbo:
|
||||
_play_tone(260.0, 0.16, 0.16)
|
||||
_engine_turbo = turbo
|
||||
if _engine_player == null or not is_instance_valid(_engine_player):
|
||||
_engine_player = AudioStreamPlayer.new()
|
||||
_engine_player.stream = _engine_stream()
|
||||
add_child(_engine_player)
|
||||
_engine_player.play()
|
||||
_engine_player.pitch_scale = engine_pitch(amount, turbo)
|
||||
_engine_player.volume_db = linear_to_db(engine_volume(amount, turbo))
|
||||
|
||||
|
||||
func stop_engine() -> void:
|
||||
if _engine_player != null and is_instance_valid(_engine_player):
|
||||
_engine_player.stop()
|
||||
_engine_turbo = false
|
||||
|
||||
|
||||
static func engine_pitch(thrust: float, turbo: bool) -> float:
|
||||
var amount := clamp_intensity(thrust)
|
||||
return 0.75 + amount * 0.55 + (0.30 if turbo and amount > 0.01 else 0.0)
|
||||
|
||||
|
||||
static func engine_volume(thrust: float, turbo: bool) -> float:
|
||||
var amount := clamp_intensity(thrust)
|
||||
return clampf(0.015 + amount * 0.045 + (0.025 if turbo and amount > 0.01 else 0.0), 0.0, 0.1)
|
||||
|
||||
|
||||
static func should_play_turbo_cue(previous_turbo: bool, turbo: bool, thrust: float) -> bool:
|
||||
return turbo and not previous_turbo and clamp_intensity(thrust) > 0.01
|
||||
|
||||
|
||||
static func clamp_intensity(value: float) -> float:
|
||||
if not is_finite(value):
|
||||
return 0.0
|
||||
return clampf(value, 0.0, MAX_INTENSITY)
|
||||
|
||||
|
||||
static func countdown_frequency(count: int) -> float:
|
||||
return 440.0 + float(clampi(count, 1, 9)) * 55.0
|
||||
|
||||
|
||||
func _play_tone(frequency: float, duration: float, volume: float) -> void:
|
||||
if not enabled or frequency <= 0.0 or duration <= 0.0 or volume <= 0.0:
|
||||
return
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = SAMPLE_RATE
|
||||
stream.stereo = false
|
||||
stream.data = _tone_data(frequency, duration, volume)
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.stream = stream
|
||||
add_child(player)
|
||||
player.finished.connect(player.queue_free)
|
||||
player.play()
|
||||
|
||||
|
||||
func _engine_stream() -> AudioStreamWAV:
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = SAMPLE_RATE
|
||||
stream.stereo = false
|
||||
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
stream.data = _tone_data(92.0, 1.0, 0.65)
|
||||
stream.loop_end = SAMPLE_RATE
|
||||
return stream
|
||||
|
||||
|
||||
func _tone_data(frequency: float, duration: float, volume: float) -> PackedByteArray:
|
||||
var frames := maxi(1, int(duration * SAMPLE_RATE))
|
||||
var data := PackedByteArray()
|
||||
data.resize(frames * 2)
|
||||
for index in frames:
|
||||
var envelope := minf(1.0, float(index) / 256.0) * minf(1.0, float(frames - index) / 1024.0)
|
||||
var sample := int(sin(TAU * frequency * float(index) / SAMPLE_RATE) * volume * envelope * 32767.0)
|
||||
if sample < 0:
|
||||
sample += 65536
|
||||
data[index * 2] = sample & 0xff
|
||||
data[index * 2 + 1] = (sample >> 8) & 0xff
|
||||
return data
|
||||
@@ -0,0 +1,153 @@
|
||||
class_name ConnectionLeaseClient
|
||||
extends Node
|
||||
|
||||
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
||||
|
||||
signal reconciliation_failed(reason: String)
|
||||
|
||||
const CLAIMED := "claimed"
|
||||
const UNAVAILABLE := "unavailable"
|
||||
const REJECTED := "rejected"
|
||||
|
||||
var _base_url := ""
|
||||
var _workload_token := ""
|
||||
var _match_id := ""
|
||||
var _server_id := ""
|
||||
var _pending: Array[Dictionary] = []
|
||||
var _processing := false
|
||||
|
||||
|
||||
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
|
||||
base_url = base_url.strip_edges().trim_suffix("/")
|
||||
workload_token = workload_token.strip_edges()
|
||||
if not valid_configuration(base_url, workload_token, match_id, server_id):
|
||||
return false
|
||||
_base_url = base_url
|
||||
_workload_token = workload_token
|
||||
_match_id = match_id
|
||||
_server_id = server_id
|
||||
return true
|
||||
|
||||
|
||||
# Admission awaits one bounded request only. If the control plane is down, the
|
||||
# same process may continue using its local generation and this exact event is
|
||||
# retained ahead of every later disconnect/reconnect for ordered reconciliation.
|
||||
func claim(player_id: String, expected_generation: int) -> Dictionary:
|
||||
if not AssignmentState.is_valid_opaque_id(player_id) or expected_generation < 0:
|
||||
return {"status": REJECTED}
|
||||
var event := _connect_event(player_id, expected_generation)
|
||||
if _processing or not _pending.is_empty():
|
||||
if expected_generation == 0:
|
||||
return {"status": REJECTED}
|
||||
_pending.append(event)
|
||||
_start_processing()
|
||||
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
|
||||
var response := await _send(event, true)
|
||||
if String(response.get("status", "")) == UNAVAILABLE:
|
||||
if expected_generation == 0:
|
||||
return {"status": REJECTED}
|
||||
_pending.append(event)
|
||||
_start_processing()
|
||||
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
|
||||
return response
|
||||
|
||||
|
||||
func record_disconnect(player_id: String, generation: int) -> void:
|
||||
if not AssignmentState.is_valid_opaque_id(player_id) or generation < 1:
|
||||
return
|
||||
_pending.append(_disconnect_event(player_id, generation))
|
||||
_start_processing()
|
||||
|
||||
|
||||
func _start_processing() -> void:
|
||||
if _processing or _pending.is_empty() or not is_inside_tree():
|
||||
return
|
||||
_process_pending()
|
||||
|
||||
|
||||
func _process_pending() -> void:
|
||||
_processing = true
|
||||
while not _pending.is_empty() and is_inside_tree():
|
||||
var event := _pending[0]
|
||||
var response := await _send(event)
|
||||
var status := String(response.get("status", ""))
|
||||
if status == CLAIMED:
|
||||
_pending.pop_front()
|
||||
continue
|
||||
if status == REJECTED:
|
||||
reconciliation_failed.emit("durable connection lease conflict")
|
||||
_processing = false
|
||||
return
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
_processing = false
|
||||
|
||||
|
||||
func _send(event: Dictionary, allow_recovery := false) -> Dictionary:
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = 1.0
|
||||
add_child(request)
|
||||
var operation := String(event["operation"])
|
||||
var endpoint := "%s/v1/servers/%s/%s" % [_base_url, _server_id.uri_encode(), operation]
|
||||
var start_error := request.request(endpoint, [
|
||||
"Authorization: Bearer " + _workload_token,
|
||||
"Content-Type: application/json",
|
||||
"Idempotency-Key: " + String(event["key"]),
|
||||
], HTTPClient.METHOD_POST, JSON.stringify(event["payload"]))
|
||||
if start_error != OK:
|
||||
request.queue_free()
|
||||
return {"status": UNAVAILABLE}
|
||||
var raw: Array = await request.request_completed
|
||||
request.queue_free()
|
||||
return classify_response(operation, int(event["generation"]), int(raw[0]), int(raw[1]), raw[3], allow_recovery)
|
||||
|
||||
|
||||
func _connect_event(player_id: String, expected_generation: int) -> Dictionary:
|
||||
return {
|
||||
"operation": "connect",
|
||||
"generation": expected_generation,
|
||||
"key": event_key(_match_id, player_id, "connect", expected_generation),
|
||||
"payload": {"player_id": player_id, "expected_generation": expected_generation},
|
||||
}
|
||||
|
||||
|
||||
func _disconnect_event(player_id: String, generation: int) -> Dictionary:
|
||||
return {
|
||||
"operation": "disconnect",
|
||||
"generation": generation,
|
||||
"key": event_key(_match_id, player_id, "disconnect", generation),
|
||||
"payload": {"player_id": player_id, "generation": generation},
|
||||
}
|
||||
|
||||
|
||||
static func classify_response(operation: String, generation: int, request_result: int, response_code: int, body: PackedByteArray, allow_recovery := false) -> Dictionary:
|
||||
if request_result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code == 429 or response_code >= 500:
|
||||
return {"status": UNAVAILABLE}
|
||||
if operation == "disconnect" and response_code == 204:
|
||||
return {"status": CLAIMED, "generation": generation}
|
||||
if operation == "connect" and response_code == 200:
|
||||
var decoded = JSON.parse_string(body.get_string_from_utf8())
|
||||
if decoded is Dictionary and _valid_generation(decoded.get("generation")):
|
||||
var claimed_generation := int(decoded["generation"])
|
||||
if claimed_generation == generation + 1 or (allow_recovery and generation == 0 and claimed_generation > 1):
|
||||
return {"status": CLAIMED, "generation": claimed_generation}
|
||||
return {"status": REJECTED}
|
||||
|
||||
|
||||
static func _valid_generation(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 1
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 1.0 and float(value) == floor(float(value)) and float(value) <= 9007199254740991.0
|
||||
return false
|
||||
|
||||
|
||||
static func event_key(match_id: String, player_id: String, operation: String, generation: int) -> String:
|
||||
return "server-lease-" + (match_id + "\n" + player_id + "\n" + operation + "\n" + str(generation)).sha256_text()
|
||||
|
||||
|
||||
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
|
||||
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#") or base_url.contains("@"):
|
||||
return false
|
||||
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
|
||||
return false
|
||||
return AssignmentState.is_valid_opaque_id(match_id) and AssignmentState.is_valid_opaque_id(server_id)
|
||||
@@ -0,0 +1,889 @@
|
||||
extends Node
|
||||
|
||||
# Authenticated HTTP boundary for matchmaking. ENet/Steam carries the match
|
||||
# itself; this client only handles queue/proposal control-plane state.
|
||||
|
||||
signal request_succeeded(operation: String, payload: Dictionary)
|
||||
signal request_failed(operation: String, http_code: int, detail: String)
|
||||
signal session_expired()
|
||||
signal probe_challenge_received(region: String, nonce_base64: String)
|
||||
signal probe_recorded(region: String, server_rtt_ms: int)
|
||||
signal session_changed(player_id: String)
|
||||
signal websocket_event(event: Dictionary)
|
||||
signal websocket_status_changed(status: String)
|
||||
signal assignment_connection_started(assignment: AssignmentState)
|
||||
signal assignment_connection_failed(detail: String)
|
||||
|
||||
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
|
||||
# Release builds must point at the real control plane rather than a developer's
|
||||
# loopback. The environment variable is read at startup so the same binary can
|
||||
# be pointed at a staging or production endpoint without a rebuild.
|
||||
const BASE_URL_ENV := "COSMIC_CLASH_CONTROL_PLANE_URL"
|
||||
const PERSIST_PATH := "user://matchmaking_state.cfg"
|
||||
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
|
||||
|
||||
var base_url := DEFAULT_BASE_URL
|
||||
var access_token := ""
|
||||
var auth_expired := false
|
||||
var player_id := ""
|
||||
var session_expires_at := ""
|
||||
var state: MatchmakingState
|
||||
var ranked_profile: RankedProfileState
|
||||
var assignment: AssignmentState
|
||||
|
||||
var _request: HTTPRequest
|
||||
var _operation := ""
|
||||
var _last_queue_create: Dictionary = {}
|
||||
var _last_mutation: Dictionary = {}
|
||||
var _last_mutation_retryable := false
|
||||
var _websocket: WebSocketPeer
|
||||
var _websocket_status := "DISCONNECTED"
|
||||
var _websocket_retry_seconds := 0.0
|
||||
var _websocket_backoff := 1.0
|
||||
var _authoritative_recovery_seconds := AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||
var _pending_proposal_id := ""
|
||||
var _pending_assignment_match_id := ""
|
||||
var _pending_resync_resource_id := ""
|
||||
# The final wiring step of the matchmaking pipeline: once state.phase reaches
|
||||
# ASSIGNED, the client must actually start the game transport. connect_to_assignment()
|
||||
# already existed with correct validation/signal behavior, but nothing ever
|
||||
# called it -- a player would sit on "Your match server is ready" forever.
|
||||
# These two fields defer the connect attempt until the assignment fetch
|
||||
# (triggered independently, earlier, by ASSIGNMENT_READY) has actually
|
||||
# completed, and prevent a duplicate/replayed ASSIGNED update from firing a
|
||||
# second connection attempt for the same match.
|
||||
var _pending_connect_match_id := ""
|
||||
var _connect_attempted_match_id := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
state = MatchmakingState.new()
|
||||
ranked_profile = RankedProfileState.new()
|
||||
assignment = AssignmentState.new()
|
||||
_load_persisted_state()
|
||||
state.changed.connect(_persist_state)
|
||||
_request = HTTPRequest.new()
|
||||
_request.timeout = 10.0
|
||||
add_child(_request)
|
||||
_request.request_completed.connect(_on_request_completed)
|
||||
state.resync_required.connect(_on_resync_required)
|
||||
_websocket = WebSocketPeer.new()
|
||||
assignment_connection_failed.connect(_on_assignment_connection_failed)
|
||||
NetworkManager.connection_failed.connect(_on_network_connection_failed)
|
||||
|
||||
|
||||
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
|
||||
# synchronous failures (assignment missing/expired, invalid endpoint,
|
||||
# NetworkManager.join() erroring immediately) previously only emitted
|
||||
# assignment_connection_failed -- a signal nothing in the client actually
|
||||
# listened to. state.phase would stay stuck at ASSIGNED, the UI would keep
|
||||
# showing "Your match server is ready" forever, and there was no way back to
|
||||
# a fresh search.
|
||||
func _on_assignment_connection_failed(detail: String) -> void:
|
||||
state.fail(detail)
|
||||
|
||||
|
||||
# The likelier real-world failure than the synchronous one above:
|
||||
# NetworkManager.join() returns OK immediately (the attempt started), but the
|
||||
# actual ENet handshake fails asynchronously later -- unreachable server,
|
||||
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
|
||||
# main_menu.gd's own _on_connection_failed exists to cover for the direct-join
|
||||
# flow (see its header comment); nothing covered it for a matchmaking-driven
|
||||
# connect. Guarded to CONNECTING so this never reacts to an unrelated
|
||||
# connection_failed, such as one belonging to main_menu.gd's own direct join.
|
||||
func _on_network_connection_failed() -> void:
|
||||
if state.phase == MatchmakingState.CONNECTING:
|
||||
state.fail("Unable to connect to the match server")
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not auth_expired and is_session_expired(session_expires_at):
|
||||
_expire_session()
|
||||
if _websocket == null:
|
||||
return
|
||||
_websocket.poll()
|
||||
var ready_state := _websocket.get_ready_state()
|
||||
if ready_state == WebSocketPeer.STATE_OPEN:
|
||||
_websocket_retry_seconds = 0.0
|
||||
_websocket_backoff = 1.0
|
||||
_set_websocket_status("CONNECTED")
|
||||
while _websocket.get_available_packet_count() > 0:
|
||||
_handle_websocket_packet(_websocket.get_packet())
|
||||
elif ready_state == WebSocketPeer.STATE_CONNECTING:
|
||||
_set_websocket_status("CONNECTING")
|
||||
elif ready_state == WebSocketPeer.STATE_CLOSED:
|
||||
_set_websocket_status("DISCONNECTED")
|
||||
if not auth_expired and is_valid_access_token(access_token):
|
||||
_websocket_retry_seconds -= _delta
|
||||
if _websocket_retry_seconds <= 0.0:
|
||||
_websocket_retry_seconds = _websocket_backoff
|
||||
_websocket_backoff = minf(_websocket_backoff * 2.0, 30.0)
|
||||
connect_event_stream()
|
||||
if not _pending_proposal_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
||||
var proposal_id := _pending_proposal_id
|
||||
_pending_proposal_id = ""
|
||||
recover_proposal(proposal_id)
|
||||
elif not _pending_assignment_match_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
||||
var match_id := _pending_assignment_match_id
|
||||
_pending_assignment_match_id = ""
|
||||
fetch_assignment(match_id)
|
||||
if not _pending_connect_match_id.is_empty() and _assignment_ready_for(_pending_connect_match_id):
|
||||
var match_id := _pending_connect_match_id
|
||||
_pending_connect_match_id = ""
|
||||
_connect_attempted_match_id = match_id
|
||||
connect_to_assignment()
|
||||
_poll_authoritative_recovery(_delta)
|
||||
|
||||
|
||||
# The assignment fetch (triggered independently by ASSIGNMENT_READY, which
|
||||
# always precedes ASSIGNED) and the ASSIGNED transition that should start the
|
||||
# transport can arrive in either order. This is the shared readiness check
|
||||
# both _connect_when_assigned and the deferred _process retry above use.
|
||||
func _assignment_ready_for(match_id: String) -> bool:
|
||||
return assignment != null and assignment.available and assignment.match_id == match_id and _assignment_is_fresh(assignment)
|
||||
|
||||
|
||||
# Starts (or defers, if the assignment fetch triggered by the earlier
|
||||
# ASSIGNMENT_READY event hasn't completed yet) the game transport once the
|
||||
# ticket-state machine reaches ASSIGNED. connect_to_assignment() itself
|
||||
# already existed with full validation and failure signalling; nothing ever
|
||||
# called it, so a player reaching "Your match server is ready" never actually
|
||||
# connected. _connect_attempted_match_id guards against a duplicate/replayed
|
||||
# ASSIGNED update firing a second connection attempt for the same match.
|
||||
func _connect_when_assigned(match_id: String) -> void:
|
||||
if state.phase != MatchmakingState.ASSIGNED or not is_valid_resource_id(match_id) or match_id == _connect_attempted_match_id:
|
||||
return
|
||||
if _assignment_ready_for(match_id):
|
||||
_connect_attempted_match_id = match_id
|
||||
connect_to_assignment()
|
||||
else:
|
||||
_pending_connect_match_id = match_id
|
||||
|
||||
|
||||
# configured_base_url resolves the endpoint this build should use, preferring
|
||||
# explicit configuration over the loopback development default.
|
||||
static func configured_base_url() -> String:
|
||||
var configured := OS.get_environment(BASE_URL_ENV).strip_edges()
|
||||
if is_valid_base_url(configured):
|
||||
return configured
|
||||
return DEFAULT_BASE_URL
|
||||
|
||||
|
||||
# has_session reports whether matchmaking requests can be made at all. Without
|
||||
# it every request fails ERR_UNAUTHORIZED at the first guard in _start_request.
|
||||
func has_session() -> bool:
|
||||
return not access_token.is_empty() and not is_session_expired(session_expires_at)
|
||||
|
||||
|
||||
func configure(url: String, token: String) -> bool:
|
||||
var normalized := url.strip_edges().trim_suffix("/")
|
||||
var normalized_token := token.strip_edges()
|
||||
if not is_valid_base_url(normalized) or not is_valid_access_token(normalized_token):
|
||||
return false
|
||||
base_url = normalized
|
||||
access_token = normalized_token
|
||||
session_expires_at = ""
|
||||
auth_expired = false
|
||||
if _websocket != null:
|
||||
connect_event_stream()
|
||||
return true
|
||||
|
||||
|
||||
func connect_event_stream() -> Error:
|
||||
if not is_valid_access_token(access_token) or auth_expired or not is_valid_base_url(base_url):
|
||||
return ERR_UNAUTHORIZED
|
||||
var socket_url := websocket_url(base_url) + "/v1/events"
|
||||
_websocket = WebSocketPeer.new()
|
||||
# Godot 4.7 moved handshake headers onto WebSocketPeer; the second
|
||||
# connect_to_url argument is TLSOptions, not an HTTP header array. Keep the
|
||||
# bearer token in the authenticated handshake without putting it in the URL.
|
||||
_websocket.handshake_headers = PackedStringArray(["Authorization: Bearer " + access_token])
|
||||
var err := _websocket.connect_to_url(socket_url)
|
||||
if err != OK:
|
||||
_set_websocket_status("DISCONNECTED")
|
||||
return err
|
||||
_websocket_retry_seconds = 0.0
|
||||
_set_websocket_status("CONNECTING")
|
||||
return OK
|
||||
|
||||
|
||||
func disconnect_event_stream() -> void:
|
||||
if _websocket != null:
|
||||
_websocket.close()
|
||||
_websocket_retry_seconds = 0.0
|
||||
_websocket_backoff = 1.0
|
||||
_set_websocket_status("DISCONNECTED")
|
||||
|
||||
|
||||
static func websocket_url(url: String) -> String:
|
||||
if url.begins_with("https://"):
|
||||
return "wss://" + url.trim_prefix("https://")
|
||||
if url.begins_with("http://"):
|
||||
return "ws://" + url.trim_prefix("http://")
|
||||
return ""
|
||||
|
||||
|
||||
func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error:
|
||||
if not is_valid_resource_id(ticket_id) or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1:
|
||||
return ERR_INVALID_PARAMETER
|
||||
if not state.begin_queue(ticket_id, playlist):
|
||||
return ERR_INVALID_PARAMETER
|
||||
var key := _idempotency_key("queue")
|
||||
_last_queue_create = {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version, "key": key}
|
||||
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version}, key)
|
||||
if err != OK:
|
||||
state.fail("Could not start matchmaking: %s" % error_string(err))
|
||||
return err
|
||||
|
||||
|
||||
# Regional latency probing. The backend issues a single-use nonce, the client
|
||||
# echoes it back with its opaque platform location, and the backend derives the
|
||||
# round trip from its own timestamps -- no client-measured latency is accepted.
|
||||
#
|
||||
# Until a ticket has RTT evidence for at least one region the matcher will not
|
||||
# consider it (server/domain.validCandidate requires a non-empty map), so this
|
||||
# has to complete before searching is meaningful.
|
||||
const PROBE_REGIONS := ["EU", "NA"]
|
||||
|
||||
|
||||
func request_probe_challenge(region: String) -> Error:
|
||||
if not is_valid_probe_region(region):
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("probe_challenge_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s/challenge" % region, {}, "")
|
||||
|
||||
|
||||
func submit_probe_answer(region: String, nonce_base64: String, opaque_location_base64: String) -> Error:
|
||||
if not is_valid_probe_region(region) or nonce_base64.is_empty() or opaque_location_base64.is_empty():
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("probe_answer_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s" % region, {
|
||||
"nonce": nonce_base64,
|
||||
"opaque_location": opaque_location_base64,
|
||||
}, "")
|
||||
|
||||
|
||||
static func is_valid_probe_region(region: String) -> bool:
|
||||
return region == "EU" or region == "NA"
|
||||
|
||||
|
||||
# The platform location is opaque to us by design: the backend treats it as a
|
||||
# blob and never derives placement from anything the client measured. Without a
|
||||
# Steam runtime there is nothing to report, so send a stable non-empty marker
|
||||
# rather than failing the probe -- the RTT is what actually matters and that is
|
||||
# measured by the backend either way.
|
||||
static func opaque_location_payload() -> String:
|
||||
if Engine.has_singleton("Steam"):
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
if steam.has_method("getLocalPingLocation"):
|
||||
var location = steam.call("getLocalPingLocation")
|
||||
if location is String and not String(location).is_empty():
|
||||
return Marshalls.utf8_to_base64(String(location))
|
||||
return Marshalls.utf8_to_base64("no-platform-ping-location")
|
||||
|
||||
|
||||
func login_steam(web_api_ticket: String) -> Error:
|
||||
if not is_valid_web_api_ticket(web_api_ticket):
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("steam_session", HTTPClient.METHOD_POST, "/v1/session/steam", {"web_api_ticket": web_api_ticket}, "")
|
||||
|
||||
|
||||
func retry_queue_create() -> Error:
|
||||
if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"):
|
||||
return ERR_INVALID_DATA
|
||||
var ticket_id := String(_last_queue_create["ticket_id"])
|
||||
var playlist := String(_last_queue_create["playlist"])
|
||||
if not state.begin_queue(ticket_id, playlist):
|
||||
return ERR_INVALID_PARAMETER
|
||||
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": String(_last_queue_create["client_build"]), "protocol_version": int(_last_queue_create["protocol_version"])}, String(_last_queue_create["key"]))
|
||||
if err != OK:
|
||||
state.fail("Could not retry matchmaking: %s" % error_string(err))
|
||||
return err
|
||||
|
||||
|
||||
func can_retry_queue_create() -> bool:
|
||||
return not _last_queue_create.is_empty() and state.phase == MatchmakingState.FAILED and String(_last_queue_create.get("ticket_id", "")) == state.ticket_id
|
||||
|
||||
|
||||
func retry_last_mutation() -> Error:
|
||||
if not can_retry_last_mutation():
|
||||
return ERR_INVALID_DATA
|
||||
var request := _last_mutation.duplicate(true)
|
||||
return _start_request(String(request["operation"]), int(request["method"]), String(request["path"]), request["payload"], String(request["key"]), int(request["expected_revision"]))
|
||||
|
||||
|
||||
func can_retry_last_mutation() -> bool:
|
||||
return _last_mutation_retryable and not _last_mutation.is_empty() and _operation.is_empty() and not auth_expired and is_valid_access_token(access_token)
|
||||
|
||||
|
||||
func recover_queue(ticket_id: String) -> Error:
|
||||
if not is_valid_resource_id(ticket_id):
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "")
|
||||
|
||||
|
||||
func recover_proposal(proposal_id: String) -> Error:
|
||||
if not is_valid_resource_id(proposal_id):
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "")
|
||||
|
||||
|
||||
static func resync_target(resource_id: String, ticket_id: String, proposal_id: String, proposal_open: bool) -> String:
|
||||
if resource_id == ticket_id and not ticket_id.is_empty():
|
||||
return ticket_id
|
||||
if resource_id == proposal_id and not proposal_id.is_empty():
|
||||
return proposal_id if proposal_open else ticket_id
|
||||
return ""
|
||||
|
||||
|
||||
func fetch_ranked_profile() -> Error:
|
||||
return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "")
|
||||
|
||||
|
||||
func fetch_assignment(match_id: String) -> Error:
|
||||
if not is_valid_resource_id(match_id) or player_id.is_empty():
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "")
|
||||
|
||||
|
||||
# Starts the assigned game transport only after AssignmentState has validated
|
||||
# the complete player-scoped manifest. The signed authorisation is passed to
|
||||
# MatchNet's hello RPC, never appended to the endpoint URL or logged. Server
|
||||
# admission remains authoritative; this method only owns the client-side
|
||||
# readiness/transport boundary.
|
||||
func connect_to_assignment() -> Error:
|
||||
if assignment == null or not assignment.available or not _assignment_is_fresh(assignment):
|
||||
var unavailable_detail := "Match assignment is unavailable or expired"
|
||||
assignment_connection_failed.emit(unavailable_detail)
|
||||
return ERR_UNAUTHORIZED
|
||||
var endpoint := _split_assignment_endpoint(assignment.endpoint)
|
||||
if endpoint.is_empty():
|
||||
var invalid_detail := "Match assignment endpoint is invalid"
|
||||
assignment_connection_failed.emit(invalid_detail)
|
||||
return ERR_INVALID_PARAMETER
|
||||
var transport := NetworkManager.TRANSPORT_ENET if assignment.transport == "enet" else NetworkManager.TRANSPORT_STEAM
|
||||
MatchNet.join_authorisation = assignment.join_authorisation
|
||||
state.mark_connecting()
|
||||
var err := NetworkManager.join(String(endpoint["host"]), int(endpoint["port"]), transport)
|
||||
if err != OK:
|
||||
MatchNet.join_authorisation = ""
|
||||
assignment_connection_failed.emit("Unable to connect to match server")
|
||||
return err
|
||||
assignment_connection_started.emit(assignment)
|
||||
return OK
|
||||
|
||||
|
||||
static func _assignment_is_fresh(value: AssignmentState) -> bool:
|
||||
if value == null or not AssignmentState.is_valid_expiry_timestamp(value.expires_at):
|
||||
return false
|
||||
var expiry := Time.get_unix_time_from_datetime_string(value.expires_at)
|
||||
return expiry > Time.get_unix_time_from_system()
|
||||
|
||||
|
||||
static func _split_assignment_endpoint(value: String) -> Dictionary:
|
||||
if not AssignmentState._valid_endpoint(value):
|
||||
return {}
|
||||
var separator := value.rfind(":")
|
||||
return {"host": value.substr(0, separator), "port": int(value.substr(separator + 1))}
|
||||
|
||||
|
||||
func heartbeat(ticket_id: String, expected_revision: int) -> Error:
|
||||
if not is_valid_resource_id(ticket_id) or expected_revision < 0:
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("queue_heartbeat", HTTPClient.METHOD_POST, "/v1/queue/%s/heartbeat" % ticket_id, {}, _idempotency_key("heartbeat"), expected_revision)
|
||||
|
||||
|
||||
func cancel_queue(ticket_id: String, expected_revision: int) -> Error:
|
||||
if not is_valid_resource_id(ticket_id) or expected_revision < 0 or not state.can_cancel():
|
||||
return ERR_INVALID_PARAMETER
|
||||
return _start_request("queue_cancel", HTTPClient.METHOD_POST, "/v1/queue/%s/cancel" % ticket_id, {}, _idempotency_key("cancel"), expected_revision)
|
||||
|
||||
|
||||
func respond_to_proposal(proposal_id: String, accept: bool, expected_revision: int) -> Error:
|
||||
if not is_valid_resource_id(proposal_id) or expected_revision < 0:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var action := "accept" if accept else "decline"
|
||||
return _start_request("proposal_" + action, HTTPClient.METHOD_POST, "/v1/proposals/%s/%s" % [proposal_id, action], {}, _idempotency_key("proposal"), expected_revision)
|
||||
|
||||
|
||||
static func is_valid_base_url(url: String) -> bool:
|
||||
if url.is_empty() or url.contains(" ") or url.contains("\r") or url.contains("\n") or url.contains("?") or url.contains("#") or url.contains("@") or url.ends_with("/"):
|
||||
return false
|
||||
return url.begins_with("http://") or url.begins_with("https://")
|
||||
|
||||
|
||||
static func is_valid_web_api_ticket(ticket: String) -> bool:
|
||||
return not ticket.is_empty() and ticket.length() <= 4096 and not ticket.contains("\r") and not ticket.contains("\n")
|
||||
|
||||
|
||||
static func is_valid_access_token(token: String) -> bool:
|
||||
var separator := token.find(":")
|
||||
return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n")
|
||||
|
||||
|
||||
static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool:
|
||||
if expires_at.is_empty():
|
||||
return false
|
||||
if not is_valid_rfc3339_timestamp(expires_at):
|
||||
return true
|
||||
var expiry_unix := Time.get_unix_time_from_datetime_string(expires_at)
|
||||
if expiry_unix < 0:
|
||||
return true
|
||||
var current_unix := now_unix
|
||||
if current_unix < 0:
|
||||
current_unix = int(Time.get_unix_time_from_system())
|
||||
return expiry_unix <= current_unix
|
||||
|
||||
|
||||
static func is_valid_session_response(payload: Dictionary) -> bool:
|
||||
if not payload.has("expires_at") or not payload["expires_at"] is String:
|
||||
return false
|
||||
var expires_at := String(payload["expires_at"])
|
||||
return is_valid_rfc3339_timestamp(expires_at) and not is_session_expired(expires_at)
|
||||
|
||||
|
||||
static func is_valid_rfc3339_timestamp(value: String) -> bool:
|
||||
if value.is_empty():
|
||||
return false
|
||||
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
|
||||
if timestamp_pattern.search(value) == null:
|
||||
return false
|
||||
var year := int(value.substr(0, 4))
|
||||
var month := int(value.substr(5, 2))
|
||||
var day := int(value.substr(8, 2))
|
||||
var hour := int(value.substr(11, 2))
|
||||
var minute := int(value.substr(14, 2))
|
||||
var second := int(value.substr(17, 2))
|
||||
if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59:
|
||||
return false
|
||||
var days_in_month := [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
var leap_year := year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
||||
if leap_year:
|
||||
days_in_month[1] = 29
|
||||
if day < 1 or day > days_in_month[month - 1]:
|
||||
return false
|
||||
var timezone_index := value.find("+", 19)
|
||||
if timezone_index < 0:
|
||||
timezone_index = value.find("-", 19)
|
||||
if timezone_index >= 0:
|
||||
var offset_hour := int(value.substr(timezone_index + 1, 2))
|
||||
var offset_minute := int(value.substr(timezone_index + 4, 2))
|
||||
if offset_hour > 23 or offset_minute > 59:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func is_valid_resource_id(value: String) -> bool:
|
||||
if value.length() < 16 or value.length() > 128:
|
||||
return false
|
||||
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||
return resource_pattern.search(value) != null
|
||||
|
||||
|
||||
static func is_retryable_mutation_response(response_code: int) -> bool:
|
||||
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
|
||||
|
||||
|
||||
static func should_recover_queue_after_conflict(operation: String, response_code: int, ticket_id: String) -> bool:
|
||||
return response_code == HTTPClient.RESPONSE_CONFLICT and operation in ["queue_heartbeat", "queue_cancel"] and not ticket_id.is_empty()
|
||||
|
||||
|
||||
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
||||
var result := payload.duplicate(true)
|
||||
for pair in [["enqueued_at", "enqueued_at_unix"], ["expires_at", "expires_at_unix"]]:
|
||||
var source_key: String = pair[0]
|
||||
var target_key: String = pair[1]
|
||||
if not result.has(source_key):
|
||||
continue
|
||||
if not result[source_key] is String or not is_valid_rfc3339_timestamp(String(result[source_key])):
|
||||
result[target_key] = -1
|
||||
else:
|
||||
result[target_key] = Time.get_unix_time_from_datetime_string(String(result[source_key]))
|
||||
return result
|
||||
|
||||
|
||||
static func normalize_proposal(payload: Dictionary) -> Dictionary:
|
||||
var result := payload.duplicate(true)
|
||||
if not result.has("expires_at"):
|
||||
return result
|
||||
if not result["expires_at"] is String or not is_valid_rfc3339_timestamp(String(result["expires_at"])):
|
||||
result["expires_at_unix"] = -1
|
||||
else:
|
||||
result["expires_at_unix"] = int(Time.get_unix_time_from_datetime_string(String(result["expires_at"])))
|
||||
return result
|
||||
|
||||
|
||||
func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error:
|
||||
if _request == null or not _operation.is_empty() or not is_valid_base_url(base_url):
|
||||
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
|
||||
if operation != "steam_session" and access_token.is_empty():
|
||||
return ERR_UNAUTHORIZED
|
||||
if operation != "steam_session" and is_session_expired(session_expires_at):
|
||||
_expire_session()
|
||||
return ERR_UNAUTHORIZED
|
||||
var headers := PackedStringArray(["Accept: application/json"])
|
||||
if operation != "steam_session":
|
||||
headers.append("Authorization: Bearer " + access_token)
|
||||
if not idempotency_key.is_empty():
|
||||
headers.append("Idempotency-Key: " + idempotency_key)
|
||||
if expected_revision >= 0:
|
||||
headers.append("If-Match-Revision: %d" % expected_revision)
|
||||
var body := "" if payload.is_empty() else JSON.stringify(payload)
|
||||
_operation = operation
|
||||
var err := _request.request(base_url + path, headers, method, body)
|
||||
if err != OK:
|
||||
_operation = ""
|
||||
return err
|
||||
if not idempotency_key.is_empty():
|
||||
_last_mutation = {"operation": operation, "method": method, "path": path, "payload": payload.duplicate(true), "key": idempotency_key, "expected_revision": expected_revision}
|
||||
_last_mutation_retryable = false
|
||||
return OK
|
||||
|
||||
|
||||
func _expire_session() -> void:
|
||||
if auth_expired:
|
||||
return
|
||||
access_token = ""
|
||||
auth_expired = true
|
||||
disconnect_event_stream()
|
||||
state.fail("Session expired; sign in again")
|
||||
ranked_profile.set_error("Session expired; sign in again")
|
||||
session_expired.emit()
|
||||
|
||||
|
||||
func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var operation := _operation
|
||||
_operation = ""
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
||||
if operation == "ranked_profile":
|
||||
ranked_profile.set_error("Ranked profile request failed")
|
||||
elif operation == "queue_create":
|
||||
state.fail("Control-plane request failed")
|
||||
elif operation == "queue_recover" or operation == "proposal_recover":
|
||||
state.set_notice("Could not refresh matchmaking state; retrying")
|
||||
else:
|
||||
state.set_notice("Control-plane request failed; retrying is safe")
|
||||
request_failed.emit(operation, response_code, "network error")
|
||||
return
|
||||
var parsed = JSON.parse_string(body.get_string_from_utf8())
|
||||
if not parsed is Dictionary:
|
||||
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
||||
if operation == "ranked_profile":
|
||||
ranked_profile.set_error("Ranked profile returned invalid JSON")
|
||||
elif operation == "queue_create":
|
||||
state.fail("Control-plane returned invalid JSON")
|
||||
elif operation == "queue_recover" or operation == "proposal_recover":
|
||||
state.set_notice("Could not refresh matchmaking state; retrying")
|
||||
else:
|
||||
state.set_notice("Control-plane returned invalid JSON; retrying is safe")
|
||||
request_failed.emit(operation, response_code, "invalid JSON")
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
|
||||
var detail := String(parsed.get("error", "request rejected"))
|
||||
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
|
||||
var recover_queue_after_conflict := should_recover_queue_after_conflict(operation, response_code, state.ticket_id)
|
||||
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
|
||||
access_token = ""
|
||||
auth_expired = true
|
||||
disconnect_event_stream()
|
||||
state.fail("Session expired; sign in again")
|
||||
ranked_profile.set_error("Session expired; sign in again")
|
||||
session_expired.emit()
|
||||
elif response_code == HTTPClient.RESPONSE_GONE and operation == "queue_recover":
|
||||
state.expire("Queue ticket expired")
|
||||
elif response_code == HTTPClient.RESPONSE_SERVICE_UNAVAILABLE:
|
||||
state.set_notice("Matchmaking is temporarily unavailable; retrying is safe")
|
||||
elif response_code == HTTPClient.RESPONSE_UPGRADE_REQUIRED and operation == "queue_create":
|
||||
# Distinct from the generic queue_create failure below: retrying
|
||||
# with the same client build can never succeed, so the retry
|
||||
# offer must not be shown (can_retry_queue_create() checks
|
||||
# _last_queue_create; clearing it here suppresses "Retry Search").
|
||||
_last_queue_create = {}
|
||||
state.fail("Your client is out of date -- please update to continue searching")
|
||||
elif operation == "ranked_profile":
|
||||
ranked_profile.set_error(detail)
|
||||
elif response_code == HTTPClient.RESPONSE_NOT_FOUND and (operation == "queue_recover" or operation == "proposal_recover"):
|
||||
state.fail("Matchmaking record is no longer available")
|
||||
elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover":
|
||||
state.fail(detail)
|
||||
else:
|
||||
state.set_notice(detail)
|
||||
request_failed.emit(operation, response_code, detail)
|
||||
if recover_proposal_after_conflict:
|
||||
_pending_resync_resource_id = state.proposal_id
|
||||
call_deferred("_run_pending_resync")
|
||||
if recover_queue_after_conflict:
|
||||
_pending_resync_resource_id = state.ticket_id
|
||||
call_deferred("_run_pending_resync")
|
||||
return
|
||||
var payload: Dictionary = parsed
|
||||
_last_mutation_retryable = false
|
||||
if operation == "steam_session":
|
||||
var returned_token := String(payload.get("access_token", ""))
|
||||
var returned_player_id := String(payload.get("player_id", ""))
|
||||
if not is_valid_resource_id(returned_player_id) or not is_valid_access_token(returned_token) or not is_valid_session_response(payload):
|
||||
request_failed.emit(operation, response_code, "invalid session response")
|
||||
return
|
||||
player_id = returned_player_id
|
||||
access_token = returned_token
|
||||
auth_expired = false
|
||||
session_expires_at = String(payload.get("expires_at", ""))
|
||||
connect_event_stream()
|
||||
session_changed.emit(player_id)
|
||||
elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel":
|
||||
if not _valid_queue_response(payload):
|
||||
state.fail("Queue response contains invalid contract data")
|
||||
request_failed.emit(operation, response_code, "invalid queue response")
|
||||
return
|
||||
if operation == "queue_create":
|
||||
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
|
||||
elif operation.begins_with("proposal_"):
|
||||
if not _valid_proposal_response(payload):
|
||||
state.fail("Proposal response contains an invalid proposal identifier")
|
||||
request_failed.emit(operation, response_code, "invalid proposal identifier")
|
||||
return
|
||||
if operation.begins_with("queue_"):
|
||||
if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"):
|
||||
_queue_proposal_if_ready(payload)
|
||||
_queue_assignment_if_ready(payload)
|
||||
_connect_when_assigned(String(payload.get("match_id", "")))
|
||||
elif operation.begins_with("proposal_"):
|
||||
state.apply_proposal_update(normalize_proposal(payload))
|
||||
elif operation == "ranked_profile":
|
||||
if not ranked_profile.apply(payload):
|
||||
request_failed.emit(operation, response_code, ranked_profile.error_message)
|
||||
return
|
||||
elif operation == "assignment":
|
||||
if not assignment.apply(payload, player_id):
|
||||
request_failed.emit(operation, response_code, assignment.error_message)
|
||||
return
|
||||
elif operation.begins_with("probe_challenge_"):
|
||||
# Answer immediately: the nonce is single-use and short-lived, and the
|
||||
# interval to this answer is exactly what the backend measures.
|
||||
var challenge_region := operation.trim_prefix("probe_challenge_")
|
||||
var nonce := String(payload.get("nonce", ""))
|
||||
if nonce.is_empty():
|
||||
request_failed.emit(operation, response_code, "probe challenge did not include a nonce")
|
||||
return
|
||||
probe_challenge_received.emit(challenge_region, nonce)
|
||||
elif operation.begins_with("probe_answer_"):
|
||||
probe_recorded.emit(operation.trim_prefix("probe_answer_"), int(payload.get("server_rtt_ms", -1)))
|
||||
request_succeeded.emit(operation, payload)
|
||||
if not _pending_resync_resource_id.is_empty():
|
||||
call_deferred("_run_pending_resync")
|
||||
|
||||
|
||||
func _handle_websocket_packet(packet: PackedByteArray) -> void:
|
||||
var parsed = JSON.parse_string(packet.get_string_from_utf8())
|
||||
if not parsed is Dictionary or not _valid_websocket_event(parsed):
|
||||
websocket_status_changed.emit("INVALID_EVENT")
|
||||
return
|
||||
var event: Dictionary = parsed
|
||||
websocket_event.emit(event)
|
||||
var event_name := String(event["event"])
|
||||
if event_name == "state_changed":
|
||||
# Allocation and match lifecycle rows are keyed by match ID, not ticket
|
||||
# ID. Recover the owner-scoped ticket projection instead of feeding the
|
||||
# match revision/resource into the ticket reducer. ASSIGNMENT_READY also
|
||||
# carries the durable lookup key, so the assignment fetch can follow the
|
||||
# recovery request without depending on a circular assignment_changed
|
||||
# notification from the assignment GET itself.
|
||||
if event.has("match_id"):
|
||||
var match_id := String(event["match_id"])
|
||||
_on_resync_required(state.ticket_id)
|
||||
if String(event["state"]) == "ASSIGNMENT_READY":
|
||||
_pending_assignment_match_id = match_id
|
||||
return
|
||||
var update := event.duplicate(true)
|
||||
update["ticket_id"] = String(event["resource_id"])
|
||||
if not state.apply_ticket_update(update):
|
||||
return
|
||||
elif event_name == "proposal_changed":
|
||||
var proposal_update := event.duplicate(true)
|
||||
proposal_update["proposal_id"] = String(event["resource_id"])
|
||||
if state.prepare_proposal_recovery(String(proposal_update["proposal_id"])):
|
||||
state.apply_proposal_update(proposal_update)
|
||||
elif event_name == "assignment_changed":
|
||||
state.mark_assignment_ready()
|
||||
_pending_assignment_match_id = String(event["match_id"])
|
||||
elif event_name == "error":
|
||||
state.set_notice("Control-plane error: %s" % String(event["code"]))
|
||||
_on_resync_required(String(event["resource_id"]))
|
||||
|
||||
|
||||
static func _valid_websocket_event(event: Dictionary) -> bool:
|
||||
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
|
||||
return false
|
||||
if not event.has("revision") or not _valid_revision(event["revision"]):
|
||||
return false
|
||||
if not event.has("resource_id") or not event["resource_id"] is String or not is_valid_resource_id(String(event["resource_id"])):
|
||||
return false
|
||||
if not event.has("occurred_at") or not event["occurred_at"] is String or not is_valid_rfc3339_timestamp(String(event["occurred_at"])):
|
||||
return false
|
||||
var event_name := String(event["event"])
|
||||
if event_name == "assignment_changed":
|
||||
return event.has("match_id") and event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and event.has("server_id") and event["server_id"] is String and is_valid_resource_id(String(event["server_id"]))
|
||||
if event_name == "error":
|
||||
return event.has("code") and String(event["code"]) in ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]
|
||||
if event_name == "state_changed":
|
||||
if not event.has("state") or String(event["state"]) not in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
||||
return false
|
||||
if event.has("match_id"):
|
||||
return event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and String(event["match_id"]) == String(event["resource_id"])
|
||||
return true
|
||||
if event_name == "proposal_changed":
|
||||
return event.has("state") and String(event["state"]) in ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
|
||||
return false
|
||||
|
||||
|
||||
static func _valid_revision(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
|
||||
static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool:
|
||||
return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key]))
|
||||
|
||||
|
||||
static func _valid_queue_response(payload: Dictionary) -> bool:
|
||||
for key in ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"]:
|
||||
if not payload.has(key):
|
||||
return false
|
||||
if not _valid_response_opaque_id(payload, "ticket_id") or not _valid_response_opaque_id(payload, "player_id"):
|
||||
return false
|
||||
if not payload["playlist"] is String or not String(payload["playlist"]) in ["casual", "ranked"]:
|
||||
return false
|
||||
if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
||||
return false
|
||||
if payload.has("match_id"):
|
||||
if not payload["match_id"] is String or not is_valid_resource_id(String(payload["match_id"])):
|
||||
return false
|
||||
if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]:
|
||||
return false
|
||||
if payload.has("proposal_id"):
|
||||
if not payload["proposal_id"] is String or not is_valid_resource_id(String(payload["proposal_id"])):
|
||||
return false
|
||||
if String(payload["state"]) != "PROPOSED":
|
||||
return false
|
||||
if payload.has("match_id") and payload.has("proposal_id"):
|
||||
return false
|
||||
if not _valid_revision(payload["revision"]):
|
||||
return false
|
||||
return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"]))
|
||||
|
||||
|
||||
func _queue_assignment_if_ready(payload: Dictionary) -> void:
|
||||
if String(payload.get("state", "")) != "ASSIGNMENT_READY":
|
||||
return
|
||||
var match_id := String(payload.get("match_id", ""))
|
||||
if is_valid_resource_id(match_id):
|
||||
_pending_assignment_match_id = match_id
|
||||
|
||||
|
||||
func _queue_proposal_if_ready(payload: Dictionary) -> void:
|
||||
if String(payload.get("state", "")) != "PROPOSED":
|
||||
return
|
||||
var proposal_id := String(payload.get("proposal_id", ""))
|
||||
if is_valid_resource_id(proposal_id) and state.prepare_proposal_recovery(proposal_id):
|
||||
_pending_proposal_id = proposal_id
|
||||
|
||||
|
||||
func _poll_authoritative_recovery(delta: float) -> void:
|
||||
if auth_expired or not is_valid_access_token(access_token) or state.ticket_id.is_empty() or state.phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]:
|
||||
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||
return
|
||||
_authoritative_recovery_seconds -= maxf(0.0, delta)
|
||||
if _authoritative_recovery_seconds > 0.0 or not _operation.is_empty():
|
||||
return
|
||||
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
|
||||
_run_resync(resource_id)
|
||||
|
||||
|
||||
static func _valid_proposal_response(payload: Dictionary) -> bool:
|
||||
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array:
|
||||
return false
|
||||
var participants: Array = payload["participants"]
|
||||
if participants.size() < 2 or participants.size() > 6:
|
||||
return false
|
||||
var seen := {}
|
||||
for participant in participants:
|
||||
if not participant is Dictionary:
|
||||
return false
|
||||
if not participant.has("player_id") or not participant["player_id"] is String or not is_valid_resource_id(String(participant["player_id"])) or seen.has(String(participant["player_id"])):
|
||||
return false
|
||||
if not participant.has("response") or not participant["response"] is String or not String(participant["response"]) in ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]:
|
||||
return false
|
||||
if not participant.has("team") or not participant.has("slot") or not _valid_revision(participant["team"]) or not _valid_revision(participant["slot"]):
|
||||
return false
|
||||
var team := int(participant["team"])
|
||||
var slot := int(participant["slot"])
|
||||
if team > 1 or slot > 5 or slot / 3 != team:
|
||||
return false
|
||||
seen[String(participant["player_id"])] = true
|
||||
return true
|
||||
|
||||
|
||||
func _on_resync_required(resource_id: String) -> void:
|
||||
if not _operation.is_empty():
|
||||
_pending_resync_resource_id = resource_id
|
||||
return
|
||||
_run_resync(resource_id)
|
||||
|
||||
|
||||
func _run_pending_resync() -> void:
|
||||
if not _operation.is_empty() or _pending_resync_resource_id.is_empty():
|
||||
return
|
||||
var resource_id := _pending_resync_resource_id
|
||||
_pending_resync_resource_id = ""
|
||||
_run_resync(resource_id)
|
||||
|
||||
|
||||
func _run_resync(resource_id: String) -> void:
|
||||
var target := resync_target(resource_id, state.ticket_id, state.proposal_id, state.has_open_proposal())
|
||||
if target == state.ticket_id and not state.ticket_id.is_empty():
|
||||
recover_queue(state.ticket_id)
|
||||
elif target == state.proposal_id and not state.proposal_id.is_empty():
|
||||
recover_proposal(state.proposal_id)
|
||||
|
||||
|
||||
func _set_websocket_status(status: String) -> void:
|
||||
if _websocket_status == status:
|
||||
return
|
||||
_websocket_status = status
|
||||
websocket_status_changed.emit(status)
|
||||
if status == "CONNECTED":
|
||||
if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
|
||||
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
|
||||
if _operation.is_empty():
|
||||
_run_resync(resource_id)
|
||||
else:
|
||||
# A reconnect must not lose its authoritative recovery merely because
|
||||
# the previous mutation has not acknowledged yet. The deferred path
|
||||
# runs after that request completes and avoids an ERR_BUSY drop.
|
||||
_pending_resync_resource_id = resource_id
|
||||
|
||||
|
||||
func _idempotency_key(prefix: String) -> String:
|
||||
return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())]
|
||||
|
||||
|
||||
func _persist_state(snapshot: Dictionary) -> void:
|
||||
var config := ConfigFile.new()
|
||||
config.set_value("matchmaking", "snapshot", JSON.stringify(snapshot))
|
||||
config.save(PERSIST_PATH)
|
||||
|
||||
|
||||
func _load_persisted_state() -> void:
|
||||
var config := ConfigFile.new()
|
||||
if config.load(PERSIST_PATH) != OK:
|
||||
return
|
||||
var raw = config.get_value("matchmaking", "snapshot", "")
|
||||
if not raw is String or String(raw).is_empty():
|
||||
return
|
||||
var parsed = JSON.parse_string(String(raw))
|
||||
if parsed is Dictionary and not state.restore_snapshot(parsed):
|
||||
state.fail("Saved matchmaking state is invalid")
|
||||
@@ -0,0 +1,188 @@
|
||||
extends ScrollContainer
|
||||
|
||||
# Settings screen's Controls tab: rebinds every action in InputSettings.ACTIONS
|
||||
# for either device, toggles invert-pitch, and resets to the project.godot
|
||||
# defaults. InputSettings owns the bindings themselves and their persistence;
|
||||
# this script is only the editor for them, and deliberately keeps
|
||||
# settings_menu.gd video-only.
|
||||
#
|
||||
# Rows are built in code rather than laid out in settings.tscn so the list stays
|
||||
# derived from InputSettings.ACTIONS — adding a rebindable action means editing
|
||||
# that one const, not this scene as well.
|
||||
|
||||
# A joypad axis has to travel this far before a capture accepts it. Resting
|
||||
# stick drift is routinely a few percent off centre and would otherwise bind
|
||||
# itself the instant the player opened a capture.
|
||||
const AXIS_CAPTURE_THRESHOLD := 0.5
|
||||
|
||||
@onready var keyboard_button: Button = %KeyboardButton
|
||||
@onready var controller_button: Button = %ControllerButton
|
||||
@onready var status_label: Label = %StatusLabel
|
||||
@onready var binding_list: VBoxContainer = %BindingList
|
||||
@onready var invert_pitch_check: CheckBox = %InvertPitchCheck
|
||||
@onready var reset_button: Button = %ResetButton
|
||||
|
||||
var _device: String = InputSettings.DEVICE_KEYBOARD
|
||||
# The action currently awaiting an input event, or "" when not capturing.
|
||||
var _capturing: String = ""
|
||||
# action -> the row's Button, so a rebuild-free label refresh is possible and
|
||||
# so capture can restore the right button's text on cancel.
|
||||
var _row_buttons: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
keyboard_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_KEYBOARD))
|
||||
controller_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_JOYPAD))
|
||||
invert_pitch_check.toggled.connect(_on_invert_pitch_toggled)
|
||||
reset_button.pressed.connect(_on_reset_pressed)
|
||||
invert_pitch_check.button_pressed = InputSettings.invert_pitch
|
||||
_update_device_buttons()
|
||||
_rebuild_rows()
|
||||
|
||||
|
||||
func _on_device_selected(device: String) -> void:
|
||||
_cancel_capture()
|
||||
_device = device
|
||||
_update_device_buttons()
|
||||
_rebuild_rows()
|
||||
|
||||
|
||||
func _update_device_buttons() -> void:
|
||||
keyboard_button.button_pressed = _device == InputSettings.DEVICE_KEYBOARD
|
||||
controller_button.button_pressed = _device == InputSettings.DEVICE_JOYPAD
|
||||
|
||||
|
||||
func _rebuild_rows() -> void:
|
||||
for child in binding_list.get_children():
|
||||
child.queue_free()
|
||||
_row_buttons.clear()
|
||||
|
||||
var last_group := ""
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var group: String = entry["group"]
|
||||
if group != last_group:
|
||||
last_group = group
|
||||
binding_list.add_child(_make_group_header(group))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = entry["label"]
|
||||
label.custom_minimum_size = Vector2(200, 0)
|
||||
row.add_child(label)
|
||||
|
||||
var button := Button.new()
|
||||
var action: String = entry["action"]
|
||||
button.text = InputSettings.binding_text(action, _device)
|
||||
button.custom_minimum_size = Vector2(0, 36)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.clip_text = true
|
||||
button.pressed.connect(_begin_capture.bind(action))
|
||||
row.add_child(button)
|
||||
_row_buttons[action] = button
|
||||
|
||||
binding_list.add_child(row)
|
||||
|
||||
# Re-bound after every rebuild: the rows above are new nodes each time, so
|
||||
# the buttons AudioManager was tracking no longer exist.
|
||||
AudioManager.bind_tree_buttons(self)
|
||||
|
||||
|
||||
func _make_group_header(group: String) -> Label:
|
||||
var header := Label.new()
|
||||
header.text = group
|
||||
header.add_theme_font_size_override("font_size", 18)
|
||||
header.modulate = Color(1, 1, 1, 0.7)
|
||||
return header
|
||||
|
||||
|
||||
func _begin_capture(action: String) -> void:
|
||||
_cancel_capture()
|
||||
_capturing = action
|
||||
var button: Button = _row_buttons[action]
|
||||
button.text = "Press a key…" if _device == InputSettings.DEVICE_KEYBOARD else "Press a button…"
|
||||
status_label.text = "Listening — press Escape to cancel."
|
||||
|
||||
|
||||
func _cancel_capture() -> void:
|
||||
if _capturing == "":
|
||||
return
|
||||
var action := _capturing
|
||||
_capturing = ""
|
||||
if _row_buttons.has(action) and is_instance_valid(_row_buttons[action]):
|
||||
_row_buttons[action].text = InputSettings.binding_text(action, _device)
|
||||
status_label.text = ""
|
||||
|
||||
|
||||
# _input rather than _unhandled_input: the row Button has focus while capturing,
|
||||
# and an unhandled-input handler would never see the key that Button consumes as
|
||||
# its own activation. Everything consumed here is marked handled so the pending
|
||||
# event cannot also re-press that button and re-enter capture.
|
||||
func _input(event: InputEvent) -> void:
|
||||
if _capturing == "":
|
||||
return
|
||||
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
get_viewport().set_input_as_handled()
|
||||
_cancel_capture()
|
||||
return
|
||||
|
||||
var captured := _capturable_event(event)
|
||||
if captured == null:
|
||||
return
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
var action := _capturing
|
||||
_capturing = ""
|
||||
var displaced := InputSettings.set_binding(action, captured)
|
||||
_rebuild_rows()
|
||||
if displaced.is_empty():
|
||||
status_label.text = ""
|
||||
else:
|
||||
status_label.text = "Unbound %s — it was using the same input." % ", ".join(_labels_for(displaced))
|
||||
|
||||
|
||||
# Returns the event to bind, or null if this event is not a legal binding for
|
||||
# the device kind currently being edited. Keeping the check here means a joypad
|
||||
# press can never land in the keyboard column just because that tab was open.
|
||||
func _capturable_event(event: InputEvent) -> InputEvent:
|
||||
if _device == InputSettings.DEVICE_KEYBOARD:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
var key := InputEventKey.new()
|
||||
key.physical_keycode = event.physical_keycode
|
||||
return key
|
||||
return null
|
||||
|
||||
if event is InputEventJoypadButton and event.pressed:
|
||||
var button := InputEventJoypadButton.new()
|
||||
button.button_index = event.button_index
|
||||
return button
|
||||
if event is InputEventJoypadMotion and absf(event.axis_value) >= AXIS_CAPTURE_THRESHOLD:
|
||||
var motion := InputEventJoypadMotion.new()
|
||||
motion.axis = event.axis
|
||||
motion.axis_value = signf(event.axis_value)
|
||||
return motion
|
||||
return null
|
||||
|
||||
|
||||
func _labels_for(actions: PackedStringArray) -> PackedStringArray:
|
||||
var out := PackedStringArray()
|
||||
for action in actions:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
if entry["action"] == action:
|
||||
out.append(entry["label"])
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
func _on_invert_pitch_toggled(pressed: bool) -> void:
|
||||
InputSettings.invert_pitch = pressed
|
||||
|
||||
|
||||
func _on_reset_pressed() -> void:
|
||||
_cancel_capture()
|
||||
InputSettings.reset_all()
|
||||
invert_pitch_check.button_pressed = InputSettings.invert_pitch
|
||||
_rebuild_rows()
|
||||
status_label.text = "Bindings reset to defaults."
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk7plirjfqvld
|
||||
@@ -136,6 +136,7 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
|
||||
# real-time presentation delay between episodes.
|
||||
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
||||
return
|
||||
AudioManager.play_goal()
|
||||
var goal_position := Vector3.ZERO
|
||||
for goal in arena.get_goals():
|
||||
if goal.team == conceding_team:
|
||||
@@ -182,6 +183,7 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
|
||||
add_child(rig)
|
||||
_camera_rig = rig
|
||||
rig.impact_feedback.connect(AudioManager.play_impact)
|
||||
rig.target = target
|
||||
# Also wires the scene's static HUD (if any) to the same ship, rather
|
||||
# than letting it guess via the "ship" group.
|
||||
@@ -274,7 +276,11 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
||||
|
||||
|
||||
func _unhandled_input(event):
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
# leave_gameplay (Escape / Start), NOT ui_cancel. ui_cancel carries the B
|
||||
# button so menus behave the way a controller player expects, and B is far
|
||||
# too easy to hit by accident for "abandon the match you are playing".
|
||||
# Menus and the lobby still use ui_cancel; only live gameplay is guarded.
|
||||
if event.is_action_pressed("leave_gameplay"):
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name InputJitterBuffer
|
||||
extends RefCounted
|
||||
|
||||
# Per-player server-side input state (multiplayer-todo.md §3, task 3.2).
|
||||
# Per-player server-side input state (MULTIPLAYER_SPEC.md §3; multiplayer-next.md task 3.2).
|
||||
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
|
||||
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
|
||||
# 3.5's unit tests can drive it with scripted arrival traces with no live
|
||||
@@ -18,7 +18,7 @@ extends RefCounted
|
||||
# class's, since only the caller knows the current server tick.
|
||||
|
||||
const RING_SIZE := 32
|
||||
# 500ms at 60Hz (multiplayer-todo.md §3.2's own numbers) — a duration, not a
|
||||
# 500ms at 60Hz (MULTIPLAYER_SPEC.md §3.2's own numbers) — a duration, not a
|
||||
# tick-rate-derived constant, so left as a literal rather than pulling in
|
||||
# SimConstants for one number.
|
||||
const STARVE_ZERO_TICKS := 30
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name InputLeadController
|
||||
extends RefCounted
|
||||
|
||||
# Client-owned input_lead control loop (multiplayer-todo.md §3.3, task 3.3).
|
||||
# Client-owned input_lead control loop (MULTIPLAYER_SPEC.md §3.3; multiplayer-next.md task 3.3).
|
||||
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
|
||||
# so it's directly unit-testable against scripted depth traces.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
extends Node
|
||||
|
||||
# Autoload: persisted keyboard/controller bindings on top of project.godot's
|
||||
# [input] defaults, plus the flight-feel preferences that belong with them
|
||||
# (invert pitch). The Settings screen's Controls tab (controls_settings.gd) is
|
||||
# the only writer; PlayerShipController is the only reader of pitch_sign().
|
||||
#
|
||||
# project.godot stays the single source of truth for *defaults*: _ready()
|
||||
# snapshots whatever InputMap holds at boot, before any override is applied, so
|
||||
# the default table is never duplicated in GDScript and can never drift from the
|
||||
# file. An override is only ever a delta on top of that snapshot.
|
||||
#
|
||||
# Persisted to user://input.cfg rather than user://settings.cfg, deliberately.
|
||||
# VideoSettings.save() builds a fresh ConfigFile and writes it, which would drop
|
||||
# every section it does not itself know about — so two autoloads sharing one
|
||||
# file would silently erase each other. A separate file sidesteps that entirely
|
||||
# instead of coupling the two save paths.
|
||||
|
||||
# Each action is bound at most once per device kind. That is a deliberate
|
||||
# simplification of Godot's arbitrary-length event list: it makes a rebind row
|
||||
# a single button rather than an editable list, and makes "what is X bound to?"
|
||||
# answerable. The consequence is that applying a binding replaces the whole
|
||||
# event list for that action (see apply()), so anything project.godot binds
|
||||
# beyond one keyboard + one joypad event per action would be dropped here.
|
||||
const DEVICE_KEYBOARD := "keyboard"
|
||||
const DEVICE_JOYPAD := "joypad"
|
||||
|
||||
const SETTINGS_PATH := "user://input.cfg"
|
||||
|
||||
# The rebindable action list, and the only place the Controls tab and the tests
|
||||
# read it from. Order is display order. Actions NOT listed here (ui_*, the F3/F4
|
||||
# debug overlays) are deliberately not rebindable.
|
||||
const ACTIONS := [
|
||||
{"action": "move_forward", "label": "Thrust forward", "group": "Flight"},
|
||||
{"action": "move_back", "label": "Thrust backward", "group": "Flight"},
|
||||
{"action": "move_left", "label": "Strafe left", "group": "Flight"},
|
||||
{"action": "move_right", "label": "Strafe right", "group": "Flight"},
|
||||
{"action": "move_up", "label": "Thrust up", "group": "Flight"},
|
||||
{"action": "move_down", "label": "Thrust down", "group": "Flight"},
|
||||
{"action": "turbo", "label": "Turbo", "group": "Flight"},
|
||||
{"action": "turn_left", "label": "Yaw left", "group": "Attitude"},
|
||||
{"action": "turn_right", "label": "Yaw right", "group": "Attitude"},
|
||||
{"action": "pitch_up", "label": "Pitch up", "group": "Attitude"},
|
||||
{"action": "pitch_down", "label": "Pitch down", "group": "Attitude"},
|
||||
{"action": "roll_left", "label": "Roll left", "group": "Attitude"},
|
||||
{"action": "roll_right", "label": "Roll right", "group": "Attitude"},
|
||||
{"action": "toggle_ball_cam", "label": "Ball camera", "group": "Other"},
|
||||
{"action": "reset_ball", "label": "Reset ball (Free Play)", "group": "Other"},
|
||||
]
|
||||
|
||||
# button_index -> label, using the Xbox names the default map is expressed in.
|
||||
# InputEvent.as_text() renders these as "Joypad Button 9 (Left Shoulder)", which
|
||||
# is both long and wrong-looking in a rebind row.
|
||||
const JOY_BUTTON_NAMES := {
|
||||
JOY_BUTTON_A: "A", JOY_BUTTON_B: "B", JOY_BUTTON_X: "X", JOY_BUTTON_Y: "Y",
|
||||
JOY_BUTTON_BACK: "Back", JOY_BUTTON_GUIDE: "Guide", JOY_BUTTON_START: "Start",
|
||||
JOY_BUTTON_LEFT_STICK: "L3", JOY_BUTTON_RIGHT_STICK: "R3",
|
||||
JOY_BUTTON_LEFT_SHOULDER: "LB", JOY_BUTTON_RIGHT_SHOULDER: "RB",
|
||||
JOY_BUTTON_DPAD_UP: "D-Pad Up", JOY_BUTTON_DPAD_DOWN: "D-Pad Down",
|
||||
JOY_BUTTON_DPAD_LEFT: "D-Pad Left", JOY_BUTTON_DPAD_RIGHT: "D-Pad Right",
|
||||
}
|
||||
|
||||
# axis -> [label at negative deflection, label at positive deflection]. The
|
||||
# triggers rest at 0 and only travel positive, so their negative half is never
|
||||
# a reachable binding and is labelled as such rather than as a direction.
|
||||
const JOY_AXIS_NAMES := {
|
||||
JOY_AXIS_LEFT_X: ["Left Stick Left", "Left Stick Right"],
|
||||
JOY_AXIS_LEFT_Y: ["Left Stick Up", "Left Stick Down"],
|
||||
JOY_AXIS_RIGHT_X: ["Right Stick Left", "Right Stick Right"],
|
||||
JOY_AXIS_RIGHT_Y: ["Right Stick Up", "Right Stick Down"],
|
||||
JOY_AXIS_TRIGGER_LEFT: ["LT", "LT"],
|
||||
JOY_AXIS_TRIGGER_RIGHT: ["RT", "RT"],
|
||||
}
|
||||
|
||||
signal bindings_changed
|
||||
|
||||
# Push the right stick forward and the nose goes down (flight-sim). Ticking this
|
||||
# flips it. Applied in PlayerShipController rather than by rewriting the
|
||||
# bindings, so it stays one preference instead of two swapped rows the player
|
||||
# then has to reason about.
|
||||
var invert_pitch: bool = false
|
||||
|
||||
# action -> {DEVICE_KEYBOARD: InputEvent|null, DEVICE_JOYPAD: InputEvent|null},
|
||||
# snapshotted from InputMap at boot before any override lands.
|
||||
var _defaults: Dictionary = {}
|
||||
# Same shape, but only for actions the player has actually customised. A device
|
||||
# key that is absent means "still using the default"; a device key present with
|
||||
# null means "the player deliberately unbound it".
|
||||
var _overrides: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_capture_defaults()
|
||||
_load()
|
||||
apply()
|
||||
|
||||
|
||||
# Reads project.godot's [input] back out of InputMap. Anything that is neither a
|
||||
# key nor a joypad button/motion event (mouse buttons, say) is ignored rather
|
||||
# than mis-filed under a device kind it does not belong to.
|
||||
func _capture_defaults() -> void:
|
||||
_defaults.clear()
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var slots := {DEVICE_KEYBOARD: null, DEVICE_JOYPAD: null}
|
||||
if InputMap.has_action(action):
|
||||
for event in InputMap.action_get_events(action):
|
||||
var kind := device_kind_of(event)
|
||||
if kind != "" and slots[kind] == null:
|
||||
slots[kind] = event
|
||||
_defaults[action] = slots
|
||||
|
||||
|
||||
# "" for an event this system cannot express (mouse, gesture, MIDI), which is
|
||||
# also the signal to callers that it is not a legal binding.
|
||||
static func device_kind_of(event: InputEvent) -> String:
|
||||
if event is InputEventKey:
|
||||
return DEVICE_KEYBOARD
|
||||
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
|
||||
return DEVICE_JOYPAD
|
||||
return ""
|
||||
|
||||
|
||||
func _load() -> void:
|
||||
_overrides.clear()
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SETTINGS_PATH) != OK:
|
||||
return
|
||||
invert_pitch = cfg.get_value("input", "invert_pitch", invert_pitch)
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
|
||||
var key := "%s.%s" % [action, device]
|
||||
if not cfg.has_section_key("bindings", key):
|
||||
continue
|
||||
var stored = cfg.get_value("bindings", key)
|
||||
if not (stored is Dictionary):
|
||||
continue
|
||||
# An empty dict is the "deliberately unbound" sentinel — see save().
|
||||
if stored.is_empty():
|
||||
_set_override(action, device, null)
|
||||
continue
|
||||
var event := event_from_dict(stored)
|
||||
if event != null:
|
||||
_set_override(action, device, event)
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("input", "invert_pitch", invert_pitch)
|
||||
for action in _overrides:
|
||||
var slots: Dictionary = _overrides[action]
|
||||
for device in slots:
|
||||
var event: InputEvent = slots[device]
|
||||
var key := "%s.%s" % [action, device]
|
||||
# An unbound override is written as an empty dict, NOT as null:
|
||||
# ConfigFile.set_value() treats a null value as "erase this key", so
|
||||
# storing null would drop the entry and the next load would fall
|
||||
# back to the project default — silently rebinding something the
|
||||
# player had deliberately cleared.
|
||||
cfg.set_value("bindings", key, {} if event == null else event_to_dict(event))
|
||||
cfg.save(SETTINGS_PATH)
|
||||
|
||||
|
||||
# Rebuilds InputMap for every rebindable action from defaults + overrides. Runs
|
||||
# wholesale rather than incrementally so there is exactly one code path that
|
||||
# decides what an action is bound to, whatever route got us here.
|
||||
func apply() -> void:
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
if not InputMap.has_action(action):
|
||||
continue
|
||||
InputMap.action_erase_events(action)
|
||||
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
|
||||
var event := get_binding(action, device)
|
||||
if event != null:
|
||||
InputMap.action_add_event(action, event)
|
||||
bindings_changed.emit()
|
||||
|
||||
|
||||
func get_binding(action: String, device: String) -> InputEvent:
|
||||
if _overrides.has(action) and _overrides[action].has(device):
|
||||
return _overrides[action][device]
|
||||
if _defaults.has(action):
|
||||
return _defaults[action][device]
|
||||
return null
|
||||
|
||||
|
||||
func get_default_binding(action: String, device: String) -> InputEvent:
|
||||
if not _defaults.has(action):
|
||||
return null
|
||||
return _defaults[action][device]
|
||||
|
||||
|
||||
# Binds `event` to `action`, replacing whatever that action had for the event's
|
||||
# own device kind. Returns the actions that were unbound to avoid a duplicate,
|
||||
# so the caller can say so rather than leaving the player to discover it.
|
||||
func set_binding(action: String, event: InputEvent) -> PackedStringArray:
|
||||
var device := device_kind_of(event)
|
||||
if device == "":
|
||||
return PackedStringArray()
|
||||
var displaced := find_conflicts(event, action)
|
||||
for other in displaced:
|
||||
_set_override(other, device, null)
|
||||
_set_override(action, device, event)
|
||||
apply()
|
||||
return displaced
|
||||
|
||||
|
||||
func clear_binding(action: String, device: String) -> void:
|
||||
_set_override(action, device, null)
|
||||
apply()
|
||||
|
||||
|
||||
# Actions already bound to an equivalent event, excluding `except_action`.
|
||||
# Compared by value rather than by object identity — the event coming out of a
|
||||
# rebind capture is a different instance from the one in the map.
|
||||
func find_conflicts(event: InputEvent, except_action: String = "") -> PackedStringArray:
|
||||
var device := device_kind_of(event)
|
||||
var out := PackedStringArray()
|
||||
if device == "":
|
||||
return out
|
||||
for entry in ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
if action == except_action:
|
||||
continue
|
||||
var bound := get_binding(action, device)
|
||||
if bound != null and events_match(bound, event):
|
||||
out.append(action)
|
||||
return out
|
||||
|
||||
|
||||
# Equality by the fields a binding is identified by. Deliberately not
|
||||
# InputEvent.is_match(): for an axis that ignores axis_value, which would make
|
||||
# "Right Stick Up" and "Right Stick Down" collide as the same binding.
|
||||
static func events_match(a: InputEvent, b: InputEvent) -> bool:
|
||||
if a is InputEventKey and b is InputEventKey:
|
||||
return a.physical_keycode == b.physical_keycode
|
||||
if a is InputEventJoypadButton and b is InputEventJoypadButton:
|
||||
return a.button_index == b.button_index
|
||||
if a is InputEventJoypadMotion and b is InputEventJoypadMotion:
|
||||
return a.axis == b.axis and signf(a.axis_value) == signf(b.axis_value)
|
||||
return false
|
||||
|
||||
|
||||
func reset_action(action: String) -> void:
|
||||
_overrides.erase(action)
|
||||
apply()
|
||||
|
||||
|
||||
func reset_all() -> void:
|
||||
_overrides.clear()
|
||||
invert_pitch = false
|
||||
apply()
|
||||
|
||||
|
||||
func has_override(action: String) -> bool:
|
||||
return _overrides.has(action)
|
||||
|
||||
|
||||
func pitch_sign() -> float:
|
||||
return -1.0 if invert_pitch else 1.0
|
||||
|
||||
|
||||
func _set_override(action: String, device: String, event: InputEvent) -> void:
|
||||
if not _overrides.has(action):
|
||||
_overrides[action] = {}
|
||||
_overrides[action][device] = event
|
||||
|
||||
|
||||
# ConfigFile stores Dictionary values natively, so bindings persist as plain
|
||||
# data. Never the Object(...) literal Godot writes into project.godot — that
|
||||
# form is only parsed by the engine's own project-file loader, and round-tripping
|
||||
# it through user:// would be storing engine-internal syntax in a save file.
|
||||
static func event_to_dict(event: InputEvent) -> Dictionary:
|
||||
if event is InputEventKey:
|
||||
return {"type": "key", "physical_keycode": int(event.physical_keycode)}
|
||||
if event is InputEventJoypadButton:
|
||||
return {"type": "joy_button", "button_index": int(event.button_index)}
|
||||
if event is InputEventJoypadMotion:
|
||||
return {"type": "joy_axis", "axis": int(event.axis), "value": float(signf(event.axis_value))}
|
||||
return {}
|
||||
|
||||
|
||||
# Returns null for anything unrecognised, so a save file from a newer build (or
|
||||
# a hand-edited one) degrades to "this action is unbound" rather than crashing
|
||||
# the game before the player can reach the Controls tab to fix it.
|
||||
static func event_from_dict(data: Dictionary) -> InputEvent:
|
||||
match data.get("type", ""):
|
||||
"key":
|
||||
var key := InputEventKey.new()
|
||||
key.physical_keycode = int(data.get("physical_keycode", 0))
|
||||
return key if key.physical_keycode != 0 else null
|
||||
"joy_button":
|
||||
var button := InputEventJoypadButton.new()
|
||||
button.button_index = int(data.get("button_index", -1))
|
||||
return button if button.button_index >= 0 else null
|
||||
"joy_axis":
|
||||
var motion := InputEventJoypadMotion.new()
|
||||
motion.axis = int(data.get("axis", -1))
|
||||
motion.axis_value = signf(float(data.get("value", 0.0)))
|
||||
return motion if motion.axis >= 0 and motion.axis_value != 0.0 else null
|
||||
return null
|
||||
|
||||
|
||||
func event_to_text(event: InputEvent) -> String:
|
||||
if event == null:
|
||||
return "Unbound"
|
||||
if event is InputEventKey:
|
||||
# Physical keycodes throughout, so the label matches the key's position
|
||||
# on a non-QWERTY layout the same way the binding itself does. The
|
||||
# headless display server has no keyboard layout to consult and pushes
|
||||
# an ERROR for the attempt — which the ENet smoke gate treats as a
|
||||
# failure on sight — so fall back to the unmapped keycode there.
|
||||
var keycode: int = event.physical_keycode
|
||||
if DisplayServer.get_name() != "headless":
|
||||
keycode = DisplayServer.keyboard_get_keycode_from_physical(keycode)
|
||||
return OS.get_keycode_string(keycode)
|
||||
if event is InputEventJoypadButton:
|
||||
return JOY_BUTTON_NAMES.get(event.button_index, "Button %d" % event.button_index)
|
||||
if event is InputEventJoypadMotion:
|
||||
if JOY_AXIS_NAMES.has(event.axis):
|
||||
return JOY_AXIS_NAMES[event.axis][0 if event.axis_value < 0.0 else 1]
|
||||
return "Axis %d%s" % [event.axis, "-" if event.axis_value < 0.0 else "+"]
|
||||
return event.as_text()
|
||||
|
||||
|
||||
func binding_text(action: String, device: String) -> String:
|
||||
return event_to_text(get_binding(action, device))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjtdsbem7kwdv
|
||||
+13
-1
@@ -9,7 +9,7 @@ extends Control
|
||||
# of something else: change_scene_to_file() operates on
|
||||
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
|
||||
# below call it themselves, which hangs if this scene isn't actually the
|
||||
# tree's current_scene when that happens (see multiplayer-todo.md §9
|
||||
# tree's current_scene when that happens (see multiplayer-next.md §9
|
||||
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
|
||||
|
||||
@onready var _status_label: Label = %StatusLabel
|
||||
@@ -19,6 +19,7 @@ extends Control
|
||||
@onready var _switch_team_button: Button = %SwitchTeamButton
|
||||
@onready var _ready_button: CheckButton = %ReadyButton
|
||||
@onready var _leave_button: Button = %LeaveButton
|
||||
var _planned_server_shutdown := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -27,6 +28,7 @@ func _ready() -> void:
|
||||
MatchNet.player_left.connect(_on_roster_changed)
|
||||
MatchNet.player_state_changed.connect(_on_roster_changed)
|
||||
MatchNet.rejected.connect(_on_rejected)
|
||||
MatchNet.server_shutdown.connect(_on_server_shutdown)
|
||||
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||
|
||||
# The server process is never a roster member (§1.1 decision 2) — it
|
||||
@@ -34,6 +36,9 @@ func _ready() -> void:
|
||||
_controls_row.visible = NetworkManager.is_client
|
||||
|
||||
_refresh()
|
||||
if not MatchNet.last_server_shutdown_reason.is_empty():
|
||||
_planned_server_shutdown = true
|
||||
_status_label.text = "Server closed: %s" % MatchNet.last_server_shutdown_reason
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
@@ -61,7 +66,14 @@ func _on_rejected(reason: String) -> void:
|
||||
_status_label.text = "Connection rejected: %s" % reason
|
||||
|
||||
|
||||
func _on_server_shutdown(reason: String) -> void:
|
||||
_planned_server_shutdown = true
|
||||
_status_label.text = "Server closed: %s" % reason
|
||||
|
||||
|
||||
func _on_disconnected_from_server() -> void:
|
||||
if _planned_server_shutdown:
|
||||
return
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ extends RefCounted
|
||||
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
|
||||
# Client-owned local-ship prediction history (multiplayer-todo.md §4.3).
|
||||
# Client-owned local-ship prediction history (MULTIPLAYER_SPEC.md §4.3).
|
||||
# This is deliberately independent of NetworkedMatch and the scene tree so
|
||||
# sequence/ring behaviour can be tested from scripted traces. Each entry is
|
||||
# tagged with its full sequence number: an old value in a wrapped slot is
|
||||
@@ -73,6 +73,7 @@ const RING_SIZE := 128
|
||||
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
||||
var _ring_entry: Array = []
|
||||
var _has_recorded := false
|
||||
var _first_recorded_seq := -1
|
||||
|
||||
var newest_recorded_seq := -1
|
||||
var last_acknowledged_seq := 0
|
||||
@@ -94,6 +95,7 @@ func begin_epoch() -> void:
|
||||
_ring_seq[i] = -1
|
||||
_ring_entry[i] = null
|
||||
_has_recorded = false
|
||||
_first_recorded_seq = -1
|
||||
newest_recorded_seq = -1
|
||||
last_acknowledged_seq = 0
|
||||
resync_required = false
|
||||
@@ -106,6 +108,8 @@ func begin_epoch() -> void:
|
||||
func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool:
|
||||
var overflowed_now := false
|
||||
if not _has_recorded or seq > newest_recorded_seq:
|
||||
if not _has_recorded:
|
||||
_first_recorded_seq = seq
|
||||
if seq - last_acknowledged_seq > RING_SIZE:
|
||||
# Only the LEADING edge of an episode counts: resync_required is
|
||||
# still true for every subsequent tick of the same stall, and
|
||||
@@ -140,6 +144,8 @@ func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: b
|
||||
func record_unsimulated(seq: int, action: ShipAction) -> bool:
|
||||
var overflowed_now := false
|
||||
if not _has_recorded or seq > newest_recorded_seq:
|
||||
if not _has_recorded:
|
||||
_first_recorded_seq = seq
|
||||
if seq - last_acknowledged_seq > RING_SIZE:
|
||||
overflowed_now = not resync_required
|
||||
resync_required = true
|
||||
@@ -289,6 +295,11 @@ func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
|
||||
|
||||
|
||||
func _missing_status(seq: int) -> String:
|
||||
# The server starts its acknowledgement clock at sequence 0, while the
|
||||
# first local post-step prediction is normally sequence 1. This is a normal
|
||||
# startup boundary, not a lost ring entry and must not trigger a hard snap.
|
||||
if not _has_recorded or seq < _first_recorded_seq:
|
||||
return "warmup_not_recorded"
|
||||
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
|
||||
return "missing_evicted"
|
||||
return "missing_not_recorded"
|
||||
|
||||
@@ -37,6 +37,7 @@ const DIFFICULTIES := [
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
AudioManager.bind_tree_buttons(self)
|
||||
# An idle menu has no reason to render past the display's own refresh
|
||||
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
|
||||
var refresh_rate := DisplayServer.screen_get_refresh_rate()
|
||||
@@ -51,7 +52,7 @@ func _ready() -> void:
|
||||
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
||||
%FreePlayButton.grab_focus()
|
||||
|
||||
|
||||
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
||||
@@ -119,6 +120,11 @@ func _list_bots() -> Array[String]:
|
||||
# disk, else the newest (last) bot.
|
||||
func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void:
|
||||
dropdown.clear()
|
||||
# These are filled from whatever checkpoints happen to be in res://bots, so
|
||||
# a long filename would otherwise widen the OptionButton (size_flags_h =
|
||||
# EXPAND_FILL) and drag the whole menu past its 420px minimum width.
|
||||
dropdown.clip_text = true
|
||||
dropdown.fit_to_longest_item = false
|
||||
if include_none:
|
||||
dropdown.add_item("(Use difficulty)")
|
||||
dropdown.set_item_metadata(0, "")
|
||||
@@ -172,7 +178,7 @@ func _on_match_pressed() -> void:
|
||||
|
||||
|
||||
func _on_settings_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/settings.tscn")
|
||||
get_tree().change_scene_to_file(ScenePaths.SETTINGS)
|
||||
|
||||
|
||||
func _on_spectate_pressed() -> void:
|
||||
@@ -190,6 +196,10 @@ func _on_host_pressed() -> void:
|
||||
_leave_to_lobby()
|
||||
|
||||
|
||||
func _on_find_match_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/matchmaking.tscn")
|
||||
|
||||
|
||||
func _on_join_pressed() -> void:
|
||||
_start_join()
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ func _run_kickoff_countdown() -> void:
|
||||
_set_frozen(true)
|
||||
for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1):
|
||||
kickoff_countdown.emit(count)
|
||||
AudioManager.play_countdown(count)
|
||||
# process_always=false: if full-time fires mid-countdown (see
|
||||
# _on_match_timer_timeout's get_tree().paused = true), this stalls
|
||||
# harmlessly in lockstep with the pause instead of ticking a
|
||||
@@ -132,6 +133,7 @@ func _run_kickoff_countdown() -> void:
|
||||
if _match_over:
|
||||
return
|
||||
kickoff_countdown.emit(0)
|
||||
AudioManager.play_countdown(0)
|
||||
_set_frozen(false)
|
||||
|
||||
|
||||
|
||||
+396
-11
@@ -1,7 +1,7 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
|
||||
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
|
||||
# top of NetworkManager's raw transport (§2.5, §1.3 of MULTIPLAYER_SPEC.md).
|
||||
# hello/welcome, strict protocol_version and physics_ticks_per_second
|
||||
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
|
||||
# somewhere durable to keep it across the lobby→match scene transition —
|
||||
@@ -11,14 +11,19 @@ extends Node
|
||||
|
||||
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
||||
|
||||
signal player_joined(peer_id: int, player_name: String)
|
||||
signal player_left(peer_id: int)
|
||||
signal player_state_changed(peer_id: int, team: int, ready: bool)
|
||||
signal rejected(reason: String) # client-side only: the server refused our hello
|
||||
signal welcomed() # client-side only: our hello was accepted
|
||||
signal server_shutdown(reason: String) # client-side notification before planned close
|
||||
signal result_submission_accepted
|
||||
signal result_submission_retrying(http_code: int)
|
||||
|
||||
const TEAM_COUNT := 2
|
||||
const RECONNECT_GRACE_SECONDS := 60.0
|
||||
|
||||
# player_name is the one client-supplied value in _hello that gets broadcast
|
||||
# verbatim to every other peer (protocol_version/tick_hz are checked, never
|
||||
@@ -36,18 +41,40 @@ const MAX_PLAYER_NAME_LENGTH := 24
|
||||
class PlayerInfo:
|
||||
var peer_id: int
|
||||
var player_name: String
|
||||
var player_identity: String
|
||||
var team: int = 0
|
||||
var spawn_index: int = -1
|
||||
var ready: bool = false
|
||||
|
||||
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
|
||||
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void:
|
||||
peer_id = p_peer_id
|
||||
player_name = p_player_name
|
||||
player_identity = p_player_identity
|
||||
team = p_team
|
||||
ready = p_ready
|
||||
|
||||
|
||||
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
|
||||
var local_player_name := "Player"
|
||||
var last_server_shutdown_reason := ""
|
||||
# Set by the assignment connection path. Direct-IP/community-server joins keep
|
||||
# this empty for backwards compatibility; allocated matches carry the opaque
|
||||
# signed authorisation in hello rather than putting it in the endpoint URL.
|
||||
var join_authorisation := ""
|
||||
var require_join_authorisation := false
|
||||
var admissions_open := true
|
||||
var _allowed_join_authorisations: Dictionary = {}
|
||||
var _active_join_peers: Dictionary = {} # opaque authorisation -> peer_id
|
||||
var _join_history: Dictionary = {} # token -> {generation, lost_at}
|
||||
var _join_authorisation_context: Dictionary = {}
|
||||
# Key ID -> raw HMAC key. A set rather than a single key so a signing-key
|
||||
# rotation does not invalidate authorisations already issued for in-flight
|
||||
# matches: the allocator signs with the new key while servers still accept
|
||||
# both, and the old key is dropped once no live match can reference it.
|
||||
var _join_signing_keys := {}
|
||||
var _connection_lease_claim := Callable()
|
||||
var _connection_lease_disconnect := Callable()
|
||||
var _result_submit := Callable()
|
||||
|
||||
# Test hook (tests/match_net_smoke.gd): set false before connecting to
|
||||
# suppress the automatic real hello, so a test can send a deliberately
|
||||
@@ -64,8 +91,9 @@ func _ready() -> void:
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
roster.clear()
|
||||
last_server_shutdown_reason = ""
|
||||
if _auto_hello:
|
||||
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
|
||||
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation)
|
||||
|
||||
|
||||
func _on_disconnected_from_server() -> void:
|
||||
@@ -81,6 +109,76 @@ func _on_disconnected_from_server() -> void:
|
||||
# the same process.
|
||||
func _on_shutting_down() -> void:
|
||||
roster.clear()
|
||||
_allowed_join_authorisations.clear()
|
||||
_active_join_peers.clear()
|
||||
_join_history.clear()
|
||||
_join_authorisation_context.clear()
|
||||
_join_signing_keys = {}
|
||||
_connection_lease_claim = Callable()
|
||||
_connection_lease_disconnect = Callable()
|
||||
_result_submit = Callable()
|
||||
require_join_authorisation = false
|
||||
admissions_open = true
|
||||
|
||||
|
||||
# signing_keys maps key ID to raw key bytes. An empty dictionary disables
|
||||
# signature verification, which is only valid for local/direct-hosted play.
|
||||
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_keys: Dictionary = {}) -> bool:
|
||||
var allowed := {}
|
||||
for token in tokens:
|
||||
if not token is String or String(token).is_empty():
|
||||
return false
|
||||
allowed[String(token)] = true
|
||||
if allowed.is_empty() or not context.has("match_id") or not context["match_id"] is String or String(context["match_id"]).is_empty() or not context.has("server_id") or not context["server_id"] is String or String(context["server_id"]).is_empty() or not context.has("protocol_version") or not _valid_integer_claim(context["protocol_version"]) or int(context["protocol_version"]) < 1:
|
||||
return false
|
||||
_allowed_join_authorisations = allowed
|
||||
_join_authorisation_context = context.duplicate(true)
|
||||
_join_signing_keys = {}
|
||||
for key_id in signing_keys:
|
||||
var raw = signing_keys[key_id]
|
||||
if not raw is PackedByteArray or PackedByteArray(raw).is_empty():
|
||||
return false
|
||||
_join_signing_keys[str(key_id)] = PackedByteArray(raw).duplicate()
|
||||
require_join_authorisation = true
|
||||
return true
|
||||
|
||||
|
||||
func assigned_player_slots() -> Array:
|
||||
var result: Array = []
|
||||
var seen_identities := {}
|
||||
var seen_slots := {}
|
||||
for token in _allowed_join_authorisations.keys():
|
||||
var claims := _join_claims(String(token))
|
||||
if claims.is_empty():
|
||||
return []
|
||||
var identity := str(claims.get("PlayerID", ""))
|
||||
var team := int(claims.get("Team", -1))
|
||||
var slot := int(claims.get("Slot", -1))
|
||||
if identity.is_empty() or team < 0 or team >= TEAM_COUNT or slot < 0 or slot > 5 or slot / 3 != team or seen_identities.has(identity) or seen_slots.has(slot):
|
||||
return []
|
||||
seen_identities[identity] = true
|
||||
seen_slots[slot] = true
|
||||
result.append({
|
||||
"player_identity": identity,
|
||||
"team": team,
|
||||
"slot": slot,
|
||||
})
|
||||
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["slot"]) < int(b["slot"]))
|
||||
return result
|
||||
|
||||
|
||||
func player_identity(peer_id: int) -> String:
|
||||
if not roster.has(peer_id):
|
||||
return ""
|
||||
return String((roster[peer_id] as PlayerInfo).player_identity)
|
||||
|
||||
|
||||
static func reservation_identity_matches(slot_identity: String, incoming_identity: String, slot_name: String, incoming_name: String) -> bool:
|
||||
# Authenticated allocations must never fall back to a client-chosen display
|
||||
# name. The name fallback exists only for direct, unauthenticated servers.
|
||||
if not slot_identity.is_empty() or not incoming_identity.is_empty():
|
||||
return not slot_identity.is_empty() and slot_identity == incoming_identity
|
||||
return slot_name == incoming_name
|
||||
|
||||
|
||||
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
|
||||
@@ -90,10 +188,24 @@ func _on_shutting_down() -> void:
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_cleanup_disconnected_peer(peer_id)
|
||||
|
||||
|
||||
func _cleanup_disconnected_peer(peer_id: int) -> void:
|
||||
NetworkManager.invalidate_peer(peer_id)
|
||||
_remove_player(peer_id)
|
||||
|
||||
|
||||
func _remove_player(peer_id: int) -> void:
|
||||
for token in _active_join_peers.keys():
|
||||
if int(_active_join_peers[token]) == peer_id:
|
||||
_active_join_peers.erase(token)
|
||||
var history: Dictionary = _join_history.get(token, {})
|
||||
history["lost_at"] = Time.get_unix_time_from_system()
|
||||
_join_history[token] = history
|
||||
if _connection_lease_disconnect.is_valid():
|
||||
_connection_lease_disconnect.call(_join_identity(token), int(history.get("generation", 0)))
|
||||
break
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
roster.erase(peer_id)
|
||||
@@ -120,7 +232,8 @@ func _remove_player(peer_id: int) -> void:
|
||||
# from inside signal-handling: by then poll() has fully returned, every
|
||||
# disconnect event in this batch has been dispatched, and get_peers()
|
||||
# reflects the settled, genuinely-still-connected set.
|
||||
call_deferred("_broadcast_player_left", peer_id)
|
||||
if is_inside_tree():
|
||||
call_deferred("_broadcast_player_left", peer_id)
|
||||
|
||||
|
||||
func _broadcast_player_left(peer_id: int) -> void:
|
||||
@@ -129,6 +242,30 @@ func _broadcast_player_left(peer_id: int) -> void:
|
||||
_player_left.rpc_id(other_peer_id, peer_id)
|
||||
|
||||
|
||||
func broadcast_server_shutdown(reason: String) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var safe_reason := _sanitize_shutdown_reason(reason)
|
||||
for peer_id in multiplayer.get_peers():
|
||||
_server_shutdown.rpc_id(peer_id, safe_reason)
|
||||
|
||||
|
||||
static func _sanitize_shutdown_reason(raw: String) -> String:
|
||||
var clean := ""
|
||||
for c in raw:
|
||||
var code := c.unicode_at(0)
|
||||
if code >= 0x20 and code != 0x7F:
|
||||
clean += c
|
||||
clean = clean.strip_edges()
|
||||
if clean.length() > 96:
|
||||
clean = clean.substr(0, 96)
|
||||
return clean if not clean.is_empty() else "server_shutdown"
|
||||
|
||||
|
||||
static func admission_rejection(is_open: bool) -> String:
|
||||
return "" if is_open else "server is draining"
|
||||
|
||||
|
||||
# Balances a new joiner onto whichever team currently has fewer players
|
||||
# (ties go to team 0). Server only.
|
||||
func _pick_balanced_team() -> int:
|
||||
@@ -145,12 +282,16 @@ func _pick_balanced_team() -> int:
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
|
||||
func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_join_authorisation: String = "") -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if roster.has(peer_id):
|
||||
return # duplicate hello from an already-accepted peer; ignore
|
||||
var admission_error := admission_rejection(admissions_open)
|
||||
if not admission_error.is_empty():
|
||||
await _reject(peer_id, admission_error)
|
||||
return
|
||||
|
||||
if protocol_version != NetCodec.PROTOCOL_VERSION:
|
||||
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
|
||||
@@ -158,10 +299,26 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
|
||||
if tick_hz != SimConstants.TICK_HZ:
|
||||
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
|
||||
return
|
||||
if require_join_authorisation and not _valid_join_authorisation(supplied_join_authorisation):
|
||||
await _reject(peer_id, "join authorisation rejected")
|
||||
return
|
||||
if require_join_authorisation and _active_join_peers.has(supplied_join_authorisation):
|
||||
await _reject(peer_id, "join authorisation already in use")
|
||||
return
|
||||
var join_generation := 1
|
||||
if require_join_authorisation:
|
||||
join_generation = await _claim_join_authorisation(supplied_join_authorisation, peer_id)
|
||||
if join_generation < 0:
|
||||
await _reject(peer_id, "join authorisation lease rejected")
|
||||
return
|
||||
if player_name.length() > MAX_INPUT_LENGTH:
|
||||
await _reject(peer_id, "player name too long")
|
||||
return
|
||||
var clean_name := _sanitize_player_name(player_name)
|
||||
var identity := _join_identity(supplied_join_authorisation) if require_join_authorisation else clean_name
|
||||
if identity.is_empty():
|
||||
await _reject(peer_id, "join authorisation rejected")
|
||||
return
|
||||
|
||||
# Tell the new peer about everyone already here before anyone is told
|
||||
# about them, so no client ever observes an unknown peer_id in a
|
||||
@@ -171,12 +328,222 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
|
||||
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
|
||||
|
||||
var team := _pick_balanced_team()
|
||||
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
|
||||
var spawn_index := -1
|
||||
if require_join_authorisation:
|
||||
var claims := _join_claims(supplied_join_authorisation)
|
||||
var assigned_slot := int(claims.get("Slot", -1))
|
||||
var assigned_team := int(claims.get("Team", -1))
|
||||
if assigned_slot < 0 or assigned_slot > 5 or assigned_team < 0 or assigned_team >= TEAM_COUNT or assigned_slot / 3 != assigned_team:
|
||||
await _reject(peer_id, "join authorisation rejected")
|
||||
return
|
||||
team = assigned_team
|
||||
spawn_index = assigned_slot % 3
|
||||
var info := PlayerInfo.new(peer_id, clean_name, team, false, identity)
|
||||
info.spawn_index = spawn_index
|
||||
roster[peer_id] = info
|
||||
if require_join_authorisation:
|
||||
# _reserve_join_authorisation already owns the active peer reservation;
|
||||
# keeping the generation in the history makes fencing auditable without
|
||||
# exposing it to the client.
|
||||
_join_history[supplied_join_authorisation]["generation"] = join_generation
|
||||
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
|
||||
_welcome.rpc_id(peer_id)
|
||||
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
|
||||
|
||||
|
||||
func _valid_join_authorisation(token: String) -> bool:
|
||||
if token.is_empty() or not _allowed_join_authorisations.has(token):
|
||||
return false
|
||||
var standard_token := token.replace("-", "+").replace("_", "/")
|
||||
while standard_token.length() % 4 != 0:
|
||||
standard_token += "="
|
||||
var decoded := Marshalls.base64_to_raw(standard_token)
|
||||
if decoded.is_empty():
|
||||
return false
|
||||
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
|
||||
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope.has("Signature") or str(envelope["Signature"]).is_empty():
|
||||
return false
|
||||
var claims = envelope["Authorisation"]
|
||||
if not claims is Dictionary:
|
||||
return false
|
||||
for string_claim in ["MatchID", "ServerID", "PlayerID", "SteamID", "Protocol", "ExpiresAt"]:
|
||||
if not claims.has(string_claim) or not claims[string_claim] is String or String(claims[string_claim]).is_empty():
|
||||
return false
|
||||
for integer_claim in ["Slot", "Team", "Generation"]:
|
||||
if not claims.has(integer_claim) or not _valid_integer_claim(claims[integer_claim]):
|
||||
return false
|
||||
if not envelope["Signature"] is String or String(envelope["Signature"]).is_empty():
|
||||
return false
|
||||
var claimed_team := int(claims.get("Team", -1))
|
||||
var claimed_slot := int(claims.get("Slot", -1))
|
||||
if claimed_team < 0 or claimed_team >= TEAM_COUNT or claimed_slot < 0 or claimed_slot > 5 or claimed_slot / 3 != claimed_team:
|
||||
return false
|
||||
var protocol := str(claims.get("Protocol", ""))
|
||||
var expires_at := str(claims.get("ExpiresAt", ""))
|
||||
if not AssignmentState.is_valid_expiry_timestamp(expires_at):
|
||||
return false
|
||||
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
|
||||
if not _join_signing_keys.is_empty():
|
||||
var signature_token := str(envelope["Signature"])
|
||||
var signature := Marshalls.base64_to_raw(signature_token)
|
||||
if signature.size() != 32:
|
||||
return false
|
||||
# The key ID selects which of the currently-valid keys signed this
|
||||
# authorisation, so the allocator can rotate without invalidating
|
||||
# authorisations already issued for in-flight matches. It is part of
|
||||
# the signed bytes below, so pointing it at a different key simply
|
||||
# fails verification rather than choosing a weaker key.
|
||||
var key_id := str(claims.get("KeyID", ""))
|
||||
if not _join_signing_keys.has(key_id):
|
||||
return false
|
||||
var signing_key: PackedByteArray = _join_signing_keys[key_id]
|
||||
if signing_key.is_empty():
|
||||
return false
|
||||
var canonical := PackedByteArray()
|
||||
# Must stay byte-identical to server/domain/join_auth.go's
|
||||
# JoinAuthorisationBytes; the two change together or every join fails.
|
||||
var fields := [
|
||||
str(claims.get("MatchID", "")), str(claims.get("ServerID", "")),
|
||||
str(claims.get("PlayerID", "")), str(claims.get("SteamID", "")),
|
||||
str(int(claims.get("Slot", -1))), str(int(claims.get("Team", -1))), protocol,
|
||||
str(int(claims.get("Generation", 0))), expires_at, key_id,
|
||||
]
|
||||
for index in fields.size():
|
||||
canonical.append_array(String(fields[index]).to_utf8_buffer())
|
||||
if index < fields.size() - 1:
|
||||
canonical.append(0)
|
||||
var hmac := HMACContext.new()
|
||||
hmac.start(HashingContext.HASH_SHA256, signing_key)
|
||||
hmac.update(canonical)
|
||||
if hmac.finish() != signature:
|
||||
return false
|
||||
return str(claims.get("MatchID", "")) == str(_join_authorisation_context.get("match_id", "")) \
|
||||
and str(claims.get("ServerID", "")) == str(_join_authorisation_context.get("server_id", "")) \
|
||||
and protocol == str(_join_authorisation_context.get("protocol", "")) \
|
||||
and int(claims.get("Slot", -1)) >= 0 and int(claims.get("Slot", -1)) <= 5 \
|
||||
and expiry > Time.get_unix_time_from_system()
|
||||
|
||||
|
||||
static func _valid_integer_claim(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
|
||||
func _join_identity(token: String) -> String:
|
||||
if token.is_empty():
|
||||
return ""
|
||||
var standard_token := token.replace("-", "+").replace("_", "/")
|
||||
while standard_token.length() % 4 != 0:
|
||||
standard_token += "="
|
||||
var decoded := Marshalls.base64_to_raw(standard_token)
|
||||
if decoded.is_empty():
|
||||
return ""
|
||||
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
|
||||
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
|
||||
return ""
|
||||
return str(envelope["Authorisation"].get("PlayerID", ""))
|
||||
|
||||
|
||||
func _join_claims(token: String) -> Dictionary:
|
||||
if token.is_empty():
|
||||
return {}
|
||||
var standard_token := token.replace("-", "+").replace("_", "/")
|
||||
while standard_token.length() % 4 != 0:
|
||||
standard_token += "="
|
||||
var decoded := Marshalls.base64_to_raw(standard_token)
|
||||
if decoded.is_empty():
|
||||
return {}
|
||||
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
|
||||
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
|
||||
return {}
|
||||
return envelope["Authorisation"]
|
||||
|
||||
|
||||
func is_join_authorisation_active(token: String) -> bool:
|
||||
return not token.is_empty() and _active_join_peers.has(token)
|
||||
|
||||
|
||||
func configure_connection_lease_callbacks(claim: Callable, disconnect: Callable) -> void:
|
||||
_connection_lease_claim = claim
|
||||
_connection_lease_disconnect = disconnect
|
||||
|
||||
|
||||
func configure_result_submission(callback: Callable) -> void:
|
||||
_result_submit = callback
|
||||
|
||||
|
||||
func submit_authoritative_result(score: Dictionary, integrity_state := "CERTIFIED") -> bool:
|
||||
if not _result_submit.is_valid() or not score.has(0) or not score.has(1):
|
||||
return false
|
||||
_result_submit.call(int(score[0]), int(score[1]), integrity_state)
|
||||
return true
|
||||
|
||||
|
||||
func _claim_join_authorisation(token: String, peer_id: int) -> int:
|
||||
var expected_generation := _available_join_generation(token)
|
||||
if expected_generation < 0:
|
||||
return -1
|
||||
var generation := expected_generation + 1
|
||||
if _connection_lease_claim.is_valid():
|
||||
var response = await _connection_lease_claim.call(_join_identity(token), expected_generation)
|
||||
generation = lease_claim_generation(response, expected_generation)
|
||||
if generation < 0:
|
||||
return -1
|
||||
# The await above deliberately allows one bounded control-plane request.
|
||||
# Re-evaluate every local fact that can change during that suspension before
|
||||
# publishing the reservation. If a durable claim succeeded, close it again.
|
||||
# A concurrent same-token hello can receive the same idempotent claim; its
|
||||
# loser must not close the generation now owned by the local winner.
|
||||
if _active_join_peers.has(token):
|
||||
return -1
|
||||
if not admissions_open or not _valid_join_authorisation(token) or peer_id not in multiplayer.get_peers():
|
||||
if _connection_lease_disconnect.is_valid():
|
||||
_connection_lease_disconnect.call(_join_identity(token), generation)
|
||||
return -1
|
||||
_join_history[token] = {"generation": generation, "lost_at": 0.0}
|
||||
_active_join_peers[token] = peer_id
|
||||
return generation
|
||||
|
||||
|
||||
func _available_join_generation(token: String) -> int:
|
||||
if token.is_empty() or _active_join_peers.has(token):
|
||||
return -1
|
||||
var now := Time.get_unix_time_from_system()
|
||||
var history: Dictionary = _join_history.get(token, {})
|
||||
var lost_at := float(history.get("lost_at", 0.0))
|
||||
if lost_at > 0.0 and (now < lost_at or now - lost_at > RECONNECT_GRACE_SECONDS):
|
||||
return -1
|
||||
return int(history.get("generation", 0))
|
||||
|
||||
|
||||
static func lease_claim_generation(response, expected_generation: int) -> int:
|
||||
if not response is Dictionary or expected_generation < 0:
|
||||
return -1
|
||||
var status := String(response.get("status", ""))
|
||||
if status not in ["claimed", "unavailable"] or not response.get("generation") is int:
|
||||
return -1
|
||||
var generation := int(response["generation"])
|
||||
if status == "unavailable":
|
||||
return generation if expected_generation > 0 and generation == expected_generation + 1 else -1
|
||||
# A durable backend may return a later generation only to a fresh process
|
||||
# recovering an already-disconnected lease. Locally known generations never
|
||||
# skip, and outage fallback never invents a jump.
|
||||
return generation if generation == expected_generation + 1 or (expected_generation == 0 and generation > 1) else -1
|
||||
|
||||
|
||||
func _reserve_join_authorisation(token: String, peer_id: int) -> int:
|
||||
var expected_generation := _available_join_generation(token)
|
||||
if expected_generation < 0:
|
||||
return -1
|
||||
var generation := expected_generation + 1
|
||||
_join_history[token] = {"generation": generation, "lost_at": 0.0}
|
||||
_active_join_peers[token] = peer_id
|
||||
return generation
|
||||
|
||||
|
||||
# Strips control/formatting characters (so a name can't corrupt a log line
|
||||
# or blow out UI layout with e.g. embedded newlines) and clamps to display
|
||||
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
|
||||
@@ -223,17 +590,29 @@ func _set_team(team: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
|
||||
if not _apply_team_change(peer_id, team):
|
||||
return
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
if info.team == team:
|
||||
return
|
||||
info.team = team
|
||||
info.ready = false # switching teams un-readies — the roster you were ready against just changed
|
||||
player_state_changed.emit(peer_id, info.team, info.ready)
|
||||
_state_changed.rpc(peer_id, info.team, info.ready)
|
||||
|
||||
|
||||
func _apply_team_change(peer_id: int, team: int) -> bool:
|
||||
# In allocated matches team and global slot are signed together. Changing
|
||||
# only team would produce a roster that disagrees with the assignment and
|
||||
# leave spawn_index anchored to the old team.
|
||||
if require_join_authorisation:
|
||||
return false
|
||||
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
|
||||
return false
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
if info.team == team:
|
||||
return false
|
||||
info.team = team
|
||||
info.ready = false # switching teams un-readies — the roster you were ready against just changed
|
||||
return true
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _set_ready(ready: bool) -> void:
|
||||
if not multiplayer.is_server():
|
||||
@@ -269,6 +648,12 @@ func _rejected(reason: String) -> void:
|
||||
rejected.emit(reason)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _server_shutdown(reason: String) -> void:
|
||||
last_server_shutdown_reason = _sanitize_shutdown_reason(reason)
|
||||
server_shutdown.emit(last_server_shutdown_reason)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
|
||||
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
|
||||
|
||||
@@ -41,7 +41,7 @@ signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end
|
||||
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
|
||||
signal slot_assigned_received(peer_id: int, slot_index: int)
|
||||
|
||||
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
|
||||
# Input validation (MULTIPLAYER_SPEC.md §3.1 steps 2-3; multiplayer-next.md task 3.4). Deliberately
|
||||
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
|
||||
# level concern independent of any particular match's roster/slot state, and
|
||||
# this autoload already owns the RPC that receives the raw bytes.
|
||||
@@ -192,7 +192,12 @@ func _physics_process(_delta: float) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
var gap := now - _last_physics_ms
|
||||
_last_physics_ms = now
|
||||
if not multiplayer.is_server() or _peer_input_state.is_empty():
|
||||
# NetworkManager.shutdown() swaps in an OfflineMultiplayerPeer before the
|
||||
# smoke harness's deferred quit runs. Querying MultiplayerAPI.is_server()
|
||||
# during that hand-off can call get_unique_id() on an inactive ENet peer and
|
||||
# emit errors every physics frame; the NetworkManager role flag is the safe
|
||||
# lifecycle guard at this boundary.
|
||||
if not NetworkManager.is_server or _peer_input_state.is_empty():
|
||||
return
|
||||
if gap < STALL_DETECT_MS:
|
||||
return
|
||||
@@ -264,6 +269,11 @@ func send_input(bytes: PackedByteArray) -> void:
|
||||
|
||||
|
||||
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
|
||||
# A server-side disconnect can leave peer_id in get_peers() until the
|
||||
# current poll batch settles. Do not enter Godot's RPC path for that stale
|
||||
# target; NetSim repeats this check at fire time for delayed sends.
|
||||
if not NetworkManager.can_send_to_peer(peer_id):
|
||||
return
|
||||
_track_sent(bytes.size())
|
||||
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
|
||||
|
||||
@@ -477,6 +487,7 @@ func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
|
||||
# which does not carry the peer, the reason or a timestamp into the log
|
||||
# stream a container actually captures.
|
||||
ServerLog.warn("peer_kicked", {"peer_id": peer_id, "reason": reason})
|
||||
NetworkManager.invalidate_peer(peer_id)
|
||||
_peer_input_state.erase(peer_id)
|
||||
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class_name MatchState
|
||||
|
||||
# Match lifecycle states (multiplayer-todo.md §6.1, task 5.1).
|
||||
# Match lifecycle states (MULTIPLAYER_SPEC.md §6.1; multiplayer-next.md task 5.1).
|
||||
#
|
||||
# Pure data + a transition table, deliberately with no scene, RPC or
|
||||
# NetworkedMatch dependency — same reason net_codec.gd and
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
extends Control
|
||||
|
||||
const CLIENT_BUILD := "dev"
|
||||
const PROTOCOL_VERSION := 1
|
||||
const HEARTBEAT_SECONDS := 10.0
|
||||
const RECOVERY_POLL_SECONDS := 2.0
|
||||
|
||||
@onready var playlist_dropdown: OptionButton = %PlaylistDropdown
|
||||
@onready var status_label: Label = %StatusLabel
|
||||
@onready var detail_label: Label = %DetailLabel
|
||||
@onready var ranked_profile_label: Label = %RankedProfileLabel
|
||||
@onready var queue_button: Button = %QueueButton
|
||||
@onready var cancel_button: Button = %CancelButton
|
||||
@onready var accept_button: Button = %AcceptButton
|
||||
@onready var decline_button: Button = %DeclineButton
|
||||
@onready var back_button: Button = %BackButton
|
||||
|
||||
var _elapsed_seconds := 0.0
|
||||
var _heartbeat_seconds := 0.0
|
||||
var _recovery_poll_seconds := 0.0
|
||||
# Regions still awaiting RTT evidence, and the queue request deferred until at
|
||||
# least one lands. The matcher ignores a ticket with no predicted RTT, so
|
||||
# queueing before probing produces a search that can never match.
|
||||
var _pending_probe_regions: Array[String] = []
|
||||
var _probed_regions: Array[String] = []
|
||||
var _deferred_queue := {}
|
||||
var _web_api_ticket_handle := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
AudioManager.bind_tree_buttons(self)
|
||||
playlist_dropdown.add_item("Casual")
|
||||
playlist_dropdown.set_item_metadata(0, "casual")
|
||||
playlist_dropdown.add_item("Ranked")
|
||||
playlist_dropdown.set_item_metadata(1, "ranked")
|
||||
playlist_dropdown.item_selected.connect(_on_playlist_selected)
|
||||
ControlPlaneClient.state.changed.connect(_on_state_changed)
|
||||
ControlPlaneClient.request_failed.connect(_on_request_failed)
|
||||
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
|
||||
ControlPlaneClient.session_expired.connect(_on_session_expired)
|
||||
ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received)
|
||||
ControlPlaneClient.probe_recorded.connect(_on_probe_recorded)
|
||||
_ensure_signed_in()
|
||||
_refresh_ranked_profile()
|
||||
_render(ControlPlaneClient.state.snapshot())
|
||||
|
||||
|
||||
# Matchmaking previously opened with an empty token against a loopback default,
|
||||
# so every request failed ERR_UNAUTHORIZED before reaching the network. Point
|
||||
# the client at its configured endpoint and complete Steam sign-in first.
|
||||
func _ensure_signed_in() -> void:
|
||||
if ControlPlaneClient.has_session():
|
||||
return
|
||||
if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""):
|
||||
_on_local_error("Matchmaking endpoint is not configured")
|
||||
return
|
||||
if not SteamBootstrap.supports_web_api_ticket():
|
||||
# Deliberately explicit rather than silently presenting a search that
|
||||
# can never start: online matchmaking requires a verified identity.
|
||||
_on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason())
|
||||
return
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket):
|
||||
steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket)
|
||||
_web_api_ticket_handle = SteamBootstrap.request_web_api_ticket()
|
||||
if _web_api_ticket_handle <= 0:
|
||||
_on_local_error("Could not request a Steam authentication ticket")
|
||||
return
|
||||
ControlPlaneClient.state.set_notice("Signing in...")
|
||||
|
||||
|
||||
func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void:
|
||||
# Steam reports k_EResultOK as 1; anything else means no usable ticket.
|
||||
if result != 1 or ticket.is_empty():
|
||||
_on_local_error("Steam declined to issue an authentication ticket")
|
||||
return
|
||||
var encoded := SteamBootstrap.encode_web_api_ticket(ticket)
|
||||
if encoded.is_empty():
|
||||
_on_local_error("Steam returned an unusable authentication ticket")
|
||||
return
|
||||
var err := ControlPlaneClient.login_steam(encoded)
|
||||
if err != OK:
|
||||
_on_local_error("Could not sign in: %s" % error_string(err))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
# The ticket handle is a Steam resource; releasing it avoids leaking one
|
||||
# per visit to this screen.
|
||||
if _web_api_ticket_handle > 0:
|
||||
SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle)
|
||||
_web_api_ticket_handle = 0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]:
|
||||
_elapsed_seconds += delta
|
||||
_heartbeat_seconds += delta
|
||||
_recovery_poll_seconds += delta
|
||||
if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS:
|
||||
_recovery_poll_seconds = 0.0
|
||||
var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if ControlPlaneClient.state.has_open_proposal() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id)
|
||||
if recovery_err != OK and recovery_err != ERR_BUSY:
|
||||
_on_local_error("State recovery unavailable: %s" % error_string(recovery_err))
|
||||
if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS:
|
||||
_heartbeat_seconds = 0.0
|
||||
var err := ControlPlaneClient.heartbeat(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
|
||||
if err != OK:
|
||||
_on_local_error("Heartbeat unavailable: %s" % error_string(err))
|
||||
_render(ControlPlaneClient.state.snapshot())
|
||||
|
||||
|
||||
func _on_queue_pressed() -> void:
|
||||
if not ControlPlaneClient.has_session():
|
||||
# Queueing without a session would fail at the first request guard.
|
||||
_ensure_signed_in()
|
||||
return
|
||||
if ControlPlaneClient.can_retry_queue_create():
|
||||
var retry_err := ControlPlaneClient.retry_queue_create()
|
||||
if retry_err != OK:
|
||||
_on_local_error("Could not retry matchmaking: %s" % error_string(retry_err))
|
||||
return
|
||||
if ControlPlaneClient.can_retry_last_mutation():
|
||||
var mutation_err := ControlPlaneClient.retry_last_mutation()
|
||||
if mutation_err != OK:
|
||||
_on_local_error("Could not retry matchmaking action: %s" % error_string(mutation_err))
|
||||
return
|
||||
if not _can_start_new_search(ControlPlaneClient.state.phase):
|
||||
return
|
||||
_elapsed_seconds = 0.0
|
||||
_heartbeat_seconds = 0.0
|
||||
_recovery_poll_seconds = 0.0
|
||||
var playlist := String(playlist_dropdown.get_selected_metadata())
|
||||
var ticket_id := "ticket-%s-%s" % [str(Time.get_ticks_usec()), str(randi())]
|
||||
# A ticket with no regional RTT evidence is invisible to the matcher, so
|
||||
# collect it first and queue once the first region reports.
|
||||
if _probed_regions.is_empty():
|
||||
_deferred_queue = {"ticket_id": ticket_id, "playlist": playlist}
|
||||
_start_probe_collection()
|
||||
return
|
||||
var err := ControlPlaneClient.queue_create(ticket_id, playlist, CLIENT_BUILD, PROTOCOL_VERSION)
|
||||
if err != OK:
|
||||
_on_local_error("Could not start matchmaking: %s" % error_string(err))
|
||||
|
||||
|
||||
func _start_probe_collection() -> void:
|
||||
_pending_probe_regions = []
|
||||
for region in ControlPlaneClient.PROBE_REGIONS:
|
||||
_pending_probe_regions.append(String(region))
|
||||
ControlPlaneClient.state.set_notice("Measuring connection quality...")
|
||||
_request_next_probe()
|
||||
|
||||
|
||||
# One request at a time: the client serialises HTTP through a single
|
||||
# HTTPRequest, so a second call would return ERR_BUSY.
|
||||
func _request_next_probe() -> void:
|
||||
if _pending_probe_regions.is_empty():
|
||||
_finish_probe_collection()
|
||||
return
|
||||
var region := _pending_probe_regions[0]
|
||||
var err := ControlPlaneClient.request_probe_challenge(region)
|
||||
if err != OK and err != ERR_BUSY:
|
||||
# A region we cannot probe is not fatal; placement just uses the
|
||||
# regions that did respond.
|
||||
_pending_probe_regions.remove_at(0)
|
||||
_request_next_probe()
|
||||
|
||||
|
||||
func _on_probe_challenge_received(region: String, nonce_base64: String) -> void:
|
||||
var err := ControlPlaneClient.submit_probe_answer(region, nonce_base64, ControlPlaneClient.opaque_location_payload())
|
||||
if err != OK:
|
||||
_drop_pending_probe(region)
|
||||
|
||||
|
||||
func _on_probe_recorded(region: String, _server_rtt_ms: int) -> void:
|
||||
if not _probed_regions.has(region):
|
||||
_probed_regions.append(region)
|
||||
_drop_pending_probe(region)
|
||||
|
||||
|
||||
func _drop_pending_probe(region: String) -> void:
|
||||
var index := _pending_probe_regions.find(region)
|
||||
if index >= 0:
|
||||
_pending_probe_regions.remove_at(index)
|
||||
_request_next_probe()
|
||||
|
||||
|
||||
func _finish_probe_collection() -> void:
|
||||
if _deferred_queue.is_empty():
|
||||
return
|
||||
var queued := _deferred_queue
|
||||
_deferred_queue = {}
|
||||
if _probed_regions.is_empty():
|
||||
# Queueing now would create a ticket the matcher can never select.
|
||||
_on_local_error("Could not measure connection quality to any region; matchmaking is unavailable")
|
||||
return
|
||||
var err := ControlPlaneClient.queue_create(String(queued["ticket_id"]), String(queued["playlist"]), CLIENT_BUILD, PROTOCOL_VERSION)
|
||||
if err != OK:
|
||||
_on_local_error("Could not start matchmaking: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_cancel_pressed() -> void:
|
||||
if not ControlPlaneClient.state.can_cancel():
|
||||
return
|
||||
var err := ControlPlaneClient.cancel_queue(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
|
||||
if err != OK:
|
||||
_on_local_error("Could not cancel matchmaking: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_accept_pressed() -> void:
|
||||
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
|
||||
if err != OK:
|
||||
_on_local_error("Could not accept proposal: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_decline_pressed() -> void:
|
||||
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, false, ControlPlaneClient.state.proposal_revision)
|
||||
if err != OK:
|
||||
_on_local_error("Could not decline proposal: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_playlist_selected(_index: int) -> void:
|
||||
_refresh_ranked_profile()
|
||||
|
||||
|
||||
func _refresh_ranked_profile() -> void:
|
||||
var ranked := String(playlist_dropdown.get_selected_metadata()) == "ranked"
|
||||
ranked_profile_label.visible = ranked
|
||||
if not ranked:
|
||||
return
|
||||
var err := ControlPlaneClient.fetch_ranked_profile()
|
||||
if err != OK and err != ERR_BUSY:
|
||||
ranked_profile_label.text = "Ranked profile unavailable: %s" % error_string(err)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
if ControlPlaneClient.state.can_cancel():
|
||||
status_label.text = "Cancel the active search before leaving"
|
||||
return
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _on_state_changed(snapshot: Dictionary) -> void:
|
||||
_render(snapshot)
|
||||
|
||||
|
||||
func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void:
|
||||
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
|
||||
_render(ControlPlaneClient.state.snapshot())
|
||||
|
||||
|
||||
func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void:
|
||||
detail_label.text = detail
|
||||
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
|
||||
_render(ControlPlaneClient.state.snapshot())
|
||||
|
||||
|
||||
func _on_session_expired() -> void:
|
||||
status_label.text = "Session expired"
|
||||
detail_label.text = "Sign in again before searching for a match"
|
||||
queue_button.disabled = true
|
||||
|
||||
|
||||
func _on_local_error(detail: String) -> void:
|
||||
detail_label.text = detail
|
||||
|
||||
|
||||
static func phase_label(phase: String) -> String:
|
||||
match phase:
|
||||
MatchmakingState.IDLE:
|
||||
return "Ready to search"
|
||||
MatchmakingState.QUEUED:
|
||||
return "Searching for players"
|
||||
MatchmakingState.PROPOSED:
|
||||
return "Match found — confirm"
|
||||
MatchmakingState.ACCEPTED:
|
||||
return "Match accepted — preparing server"
|
||||
MatchmakingState.ALLOCATING:
|
||||
return "Preparing match server"
|
||||
MatchmakingState.PROCESS_READY:
|
||||
return "Match server started"
|
||||
MatchmakingState.ASSIGNMENT_READY:
|
||||
return "Match assigned"
|
||||
MatchmakingState.CONNECTING:
|
||||
return "Connecting to match"
|
||||
MatchmakingState.LIVE:
|
||||
return "Match in progress"
|
||||
MatchmakingState.RESULT_PENDING:
|
||||
return "Recording match result"
|
||||
MatchmakingState.COMPLETED:
|
||||
return "Match complete"
|
||||
MatchmakingState.ASSIGNED:
|
||||
return "Match assigned"
|
||||
MatchmakingState.CANCELLED:
|
||||
return "Search cancelled"
|
||||
MatchmakingState.EXPIRED:
|
||||
return "Search expired"
|
||||
MatchmakingState.FAILED:
|
||||
return "Matchmaking unavailable"
|
||||
_:
|
||||
return "Recovering matchmaking state"
|
||||
|
||||
|
||||
func _render(snapshot: Dictionary) -> void:
|
||||
var phase := String(snapshot.get("phase", MatchmakingState.IDLE))
|
||||
status_label.text = phase_label(phase)
|
||||
if String(snapshot.get("message", "")) != "":
|
||||
detail_label.text = String(snapshot["message"])
|
||||
elif phase == MatchmakingState.QUEUED:
|
||||
var waited := _elapsed_seconds
|
||||
if int(snapshot.get("enqueued_at_unix", 0)) > 0:
|
||||
waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system())))
|
||||
detail_label.text = queue_wait_detail_text(int(waited), int(snapshot.get("revision", 0)))
|
||||
elif phase == MatchmakingState.PROPOSED:
|
||||
detail_label.text = proposal_countdown_text(int(snapshot.get("expires_at_unix", 0)), int(Time.get_unix_time_from_system()))
|
||||
elif phase == MatchmakingState.ACCEPTED:
|
||||
detail_label.text = phase_detail_label(phase)
|
||||
elif phase in [MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED]:
|
||||
detail_label.text = phase_detail_label(phase)
|
||||
elif phase in [MatchmakingState.CONNECTING, MatchmakingState.LIVE]:
|
||||
detail_label.text = "%s · %s" % [phase_detail_label(phase), latency_detail_text(NetworkManager.rtt_ms)]
|
||||
elif phase == MatchmakingState.RESULT_PENDING:
|
||||
detail_label.text = "The server is confirming the final result"
|
||||
elif phase == MatchmakingState.COMPLETED:
|
||||
detail_label.text = "The match result has been recorded"
|
||||
elif phase == MatchmakingState.IDLE:
|
||||
detail_label.text = "Choose a playlist to begin"
|
||||
cancel_button.visible = ControlPlaneClient.state.can_cancel()
|
||||
accept_button.visible = phase == MatchmakingState.PROPOSED
|
||||
decline_button.visible = phase == MatchmakingState.PROPOSED
|
||||
var retry_search := ControlPlaneClient.can_retry_queue_create()
|
||||
var retry_mutation := ControlPlaneClient.can_retry_last_mutation()
|
||||
queue_button.disabled = ControlPlaneClient.auth_expired or not (_can_start_new_search(phase) or retry_search or retry_mutation)
|
||||
queue_button.text = "Retry Search" if retry_search else ("Retry Request" if retry_mutation else "Search")
|
||||
|
||||
|
||||
static func _is_terminal(phase: String) -> bool:
|
||||
return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]
|
||||
|
||||
|
||||
static func phase_detail_label(phase: String) -> String:
|
||||
match phase:
|
||||
MatchmakingState.ACCEPTED:
|
||||
return "All players accepted; preparing the match server"
|
||||
MatchmakingState.ALLOCATING:
|
||||
return "Finding a dedicated match server"
|
||||
MatchmakingState.PROCESS_READY:
|
||||
return "Match server started; preparing player assignments"
|
||||
MatchmakingState.ASSIGNMENT_READY:
|
||||
return "Player assignments are ready"
|
||||
MatchmakingState.ASSIGNED:
|
||||
return "Your match server is ready"
|
||||
MatchmakingState.CONNECTING:
|
||||
return "Connecting to the match server"
|
||||
MatchmakingState.LIVE:
|
||||
return "Match in progress"
|
||||
_:
|
||||
return ""
|
||||
|
||||
|
||||
static func proposal_countdown_text(expires_at_unix: int, now_unix: int) -> String:
|
||||
if expires_at_unix <= 0:
|
||||
return "Review the proposal before the countdown expires"
|
||||
return "Review proposal · %ds remaining" % maxi(0, expires_at_unix - now_unix)
|
||||
|
||||
|
||||
static func queue_wait_detail_text(waited_seconds: int, revision: int) -> String:
|
||||
var waited := maxi(0, waited_seconds)
|
||||
var suffix := "looking for compatible players"
|
||||
if waited >= 30:
|
||||
suffix = "widening skill range while keeping latency limits"
|
||||
elif waited >= 10:
|
||||
suffix = "matching nearby skill and latency"
|
||||
return "Waiting %ds · %s · revision %d" % [waited, suffix, maxi(0, revision)]
|
||||
|
||||
|
||||
static func latency_detail_text(rtt_ms: float) -> String:
|
||||
if not is_finite(rtt_ms) or rtt_ms < 0.0:
|
||||
return "Latency: measuring"
|
||||
var rounded := int(round(rtt_ms))
|
||||
if rtt_ms <= 50.0:
|
||||
return "Latency: %dms · excellent" % rounded
|
||||
if rtt_ms <= 100.0:
|
||||
return "Latency: %dms · good" % rounded
|
||||
return "Latency: %dms · high" % rounded
|
||||
|
||||
|
||||
static func _can_start_new_search(phase: String) -> bool:
|
||||
return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]
|
||||
@@ -0,0 +1,392 @@
|
||||
class_name MatchmakingState
|
||||
extends RefCounted
|
||||
|
||||
# Client-side projection of the authenticated control-plane lifecycle. The
|
||||
# server remains authoritative; this object only decides what the UI may show
|
||||
# and refuses stale, gapped, or conflicting revisions instead of guessing.
|
||||
|
||||
signal changed(snapshot: Dictionary)
|
||||
signal resync_required(resource_id: String)
|
||||
|
||||
const IDLE := "IDLE"
|
||||
const QUEUED := "QUEUED"
|
||||
const PROPOSED := "PROPOSED"
|
||||
const ACCEPTED := "ACCEPTED"
|
||||
const ALLOCATING := "ALLOCATING"
|
||||
const PROCESS_READY := "PROCESS_READY"
|
||||
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
|
||||
const CONNECTING := "CONNECTING"
|
||||
const LIVE := "LIVE"
|
||||
const ASSIGNED := "ASSIGNED"
|
||||
const RESULT_PENDING := "RESULT_PENDING"
|
||||
const COMPLETED := "COMPLETED"
|
||||
const CANCELLED := "CANCELLED"
|
||||
const EXPIRED := "EXPIRED"
|
||||
const FAILED := "FAILED"
|
||||
|
||||
var phase := IDLE
|
||||
var ticket_id := ""
|
||||
var playlist := ""
|
||||
var revision := 0
|
||||
var enqueued_at_unix := 0
|
||||
var expires_at_unix := 0
|
||||
var proposal_id := ""
|
||||
var proposal_revision := 0
|
||||
var proposal_state := ""
|
||||
var message := ""
|
||||
var needs_resync := false
|
||||
|
||||
|
||||
func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
|
||||
if new_ticket_id.is_empty() or (new_playlist != "casual" and new_playlist != "ranked"):
|
||||
return false
|
||||
_reset()
|
||||
ticket_id = new_ticket_id
|
||||
playlist = new_playlist
|
||||
phase = QUEUED
|
||||
_emit_changed()
|
||||
return true
|
||||
|
||||
|
||||
func apply_ticket_update(update: Dictionary, authoritative_snapshot: bool = false) -> bool:
|
||||
if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
|
||||
return _request_resync(self.ticket_id)
|
||||
if update.has("playlist") and not _valid_playlist(String(update["playlist"])):
|
||||
return _request_resync(self.ticket_id)
|
||||
if update.has("enqueued_at_unix") and not _valid_epoch(update["enqueued_at_unix"]):
|
||||
return _request_resync(self.ticket_id)
|
||||
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
|
||||
return _request_resync(self.ticket_id)
|
||||
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
|
||||
return _request_resync(self.ticket_id)
|
||||
var incoming_revision := int(update["revision"])
|
||||
if incoming_revision < revision:
|
||||
return false
|
||||
if incoming_revision == revision:
|
||||
if _ticket_differs(update):
|
||||
return _request_resync(self.ticket_id)
|
||||
# expires_at_unix is deliberately not part of _ticket_differs' conflict
|
||||
# check (see its own comment) but is still adopted here: begin_queue()
|
||||
# has no way to know the server-assigned expiry in advance, so the
|
||||
# very first same-revision confirmation is the only place a freshly
|
||||
# queued ticket's expiry is ever set at all.
|
||||
if update.has("expires_at_unix"):
|
||||
expires_at_unix = int(update["expires_at_unix"])
|
||||
if update.has("enqueued_at_unix"):
|
||||
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
|
||||
return true
|
||||
if incoming_revision > revision + 1 and not authoritative_snapshot:
|
||||
return _request_resync(self.ticket_id)
|
||||
var incoming_state := String(update["state"])
|
||||
if not _is_ticket_state(incoming_state):
|
||||
return _request_resync(self.ticket_id)
|
||||
if authoritative_snapshot:
|
||||
if not _can_reach_ticket_state(phase, incoming_state):
|
||||
return _request_resync(self.ticket_id)
|
||||
elif not _is_legal_ticket_transition(phase, incoming_state):
|
||||
return _request_resync(self.ticket_id)
|
||||
revision = incoming_revision
|
||||
phase = incoming_state
|
||||
if update.has("playlist"):
|
||||
playlist = String(update["playlist"])
|
||||
if update.has("expires_at_unix"):
|
||||
expires_at_unix = int(update["expires_at_unix"])
|
||||
if update.has("enqueued_at_unix"):
|
||||
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
|
||||
if update.has("message"):
|
||||
message = String(update["message"])
|
||||
else:
|
||||
message = ""
|
||||
needs_resync = false
|
||||
_emit_changed()
|
||||
return true
|
||||
|
||||
|
||||
func apply_proposal_update(update: Dictionary) -> bool:
|
||||
if not _has_string(update, "proposal_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
|
||||
return _request_resync(proposal_id)
|
||||
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
|
||||
return _request_resync(proposal_id)
|
||||
var incoming_id := String(update["proposal_id"])
|
||||
if proposal_id.is_empty():
|
||||
proposal_id = incoming_id
|
||||
elif proposal_id != incoming_id:
|
||||
return _request_resync(proposal_id)
|
||||
var incoming_revision := int(update["revision"])
|
||||
if incoming_revision < proposal_revision:
|
||||
return false
|
||||
if incoming_revision == proposal_revision and not proposal_state.is_empty():
|
||||
if String(update["state"]) != proposal_state:
|
||||
return _request_resync(proposal_id)
|
||||
return true
|
||||
if not proposal_state.is_empty() and incoming_revision > proposal_revision + 1:
|
||||
return _request_resync(proposal_id)
|
||||
var incoming_proposal_state := String(update["state"])
|
||||
if incoming_proposal_state == "OPEN":
|
||||
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||
return _request_resync(proposal_id)
|
||||
phase = PROPOSED
|
||||
elif incoming_proposal_state == "ACCEPTED":
|
||||
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||
return _request_resync(proposal_id)
|
||||
phase = ALLOCATING
|
||||
elif incoming_proposal_state == "DECLINED":
|
||||
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||
return _request_resync(proposal_id)
|
||||
if phase == PROPOSED:
|
||||
phase = QUEUED
|
||||
message = "A player declined the match proposal"
|
||||
elif incoming_proposal_state == "EXPIRED":
|
||||
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||
return _request_resync(proposal_id)
|
||||
if phase == PROPOSED:
|
||||
phase = QUEUED
|
||||
message = "The match proposal expired"
|
||||
elif incoming_proposal_state == "CANCELLED":
|
||||
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||
return _request_resync(proposal_id)
|
||||
if phase == PROPOSED:
|
||||
phase = QUEUED
|
||||
message = "The match proposal was cancelled"
|
||||
else:
|
||||
return _request_resync(proposal_id)
|
||||
proposal_revision = incoming_revision
|
||||
proposal_state = incoming_proposal_state
|
||||
needs_resync = false
|
||||
if incoming_proposal_state == "OPEN" or incoming_proposal_state == "ACCEPTED":
|
||||
message = ""
|
||||
if update.has("expires_at_unix"):
|
||||
expires_at_unix = int(update["expires_at_unix"])
|
||||
_emit_changed()
|
||||
return true
|
||||
|
||||
|
||||
func prepare_proposal_recovery(new_proposal_id: String) -> bool:
|
||||
if not _valid_opaque_id(new_proposal_id):
|
||||
return false
|
||||
if proposal_id == new_proposal_id:
|
||||
return true
|
||||
if proposal_state not in ["", "DECLINED", "EXPIRED", "CANCELLED"]:
|
||||
return false
|
||||
proposal_id = new_proposal_id
|
||||
proposal_revision = 0
|
||||
proposal_state = ""
|
||||
return true
|
||||
|
||||
|
||||
func mark_assignment_ready() -> void:
|
||||
phase = ASSIGNMENT_READY
|
||||
message = "Match server is ready"
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func mark_connecting() -> void:
|
||||
phase = CONNECTING
|
||||
message = "Connecting to match server"
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func mark_live() -> void:
|
||||
phase = LIVE
|
||||
message = "Match in progress"
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func fail(reason: String) -> void:
|
||||
phase = FAILED
|
||||
message = reason if not reason.is_empty() else "Matchmaking failed"
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func expire(reason: String = "Matchmaking expired") -> void:
|
||||
phase = EXPIRED
|
||||
message = reason
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func set_notice(notice: String) -> void:
|
||||
message = notice
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func restore_snapshot(saved: Dictionary) -> bool:
|
||||
_reset()
|
||||
if saved.is_empty():
|
||||
return true
|
||||
if saved.has("phase") and not saved["phase"] is String:
|
||||
return false
|
||||
if saved.has("ticket_id") and not saved["ticket_id"] is String:
|
||||
return false
|
||||
if saved.has("playlist") and not saved["playlist"] is String:
|
||||
return false
|
||||
if saved.has("proposal_id") and not saved["proposal_id"] is String:
|
||||
return false
|
||||
if saved.has("proposal_state") and not saved["proposal_state"] is String:
|
||||
return false
|
||||
if saved.has("message") and not saved["message"] is String:
|
||||
return false
|
||||
if saved.has("revision") and not _valid_revision(saved["revision"]):
|
||||
return false
|
||||
for epoch_key in ["enqueued_at_unix", "expires_at_unix"]:
|
||||
if saved.has(epoch_key) and not _valid_epoch(saved[epoch_key]):
|
||||
return false
|
||||
if saved.has("proposal_revision") and not _valid_revision(saved["proposal_revision"]):
|
||||
return false
|
||||
var saved_phase := String(saved.get("phase", IDLE))
|
||||
var saved_ticket_id := String(saved.get("ticket_id", ""))
|
||||
if not _valid_opaque_id(saved_ticket_id) or not _is_ticket_state(saved_phase):
|
||||
return false
|
||||
var saved_playlist := String(saved.get("playlist", ""))
|
||||
if saved_playlist != "casual" and saved_playlist != "ranked":
|
||||
return false
|
||||
var saved_proposal_id := String(saved.get("proposal_id", ""))
|
||||
var saved_proposal_state := String(saved.get("proposal_state", ""))
|
||||
if saved_proposal_state not in ["", "OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] or (not saved_proposal_state.is_empty() and not _valid_opaque_id(saved_proposal_id)) or (saved_proposal_state.is_empty() and not saved_proposal_id.is_empty() and not _valid_opaque_id(saved_proposal_id)):
|
||||
return false
|
||||
ticket_id = saved_ticket_id
|
||||
playlist = saved_playlist
|
||||
phase = saved_phase
|
||||
revision = maxi(0, int(saved.get("revision", 0)))
|
||||
enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0)))
|
||||
expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0)))
|
||||
proposal_id = saved_proposal_id
|
||||
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
|
||||
proposal_state = saved_proposal_state
|
||||
message = "Recovering authoritative matchmaking state"
|
||||
needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED
|
||||
_emit_changed()
|
||||
return true
|
||||
|
||||
|
||||
func can_cancel() -> bool:
|
||||
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
|
||||
|
||||
|
||||
func has_open_proposal() -> bool:
|
||||
return not proposal_id.is_empty() and proposal_state == "OPEN"
|
||||
|
||||
|
||||
func waited_seconds(now_unix: int) -> int:
|
||||
if enqueued_at_unix <= 0:
|
||||
return 0
|
||||
return maxi(0, now_unix - enqueued_at_unix)
|
||||
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "enqueued_at_unix": enqueued_at_unix, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync}
|
||||
|
||||
|
||||
func _ticket_differs(update: Dictionary) -> bool:
|
||||
# expires_at_unix is excluded on purpose: begin_queue()'s optimistic local
|
||||
# state has no way to know the server-assigned expiry before the first
|
||||
# real response arrives, so comparing it here made the very first
|
||||
# same-revision confirmation after every begin_queue() look like a
|
||||
# conflict, unconditionally -- found by an actual client hitting a real
|
||||
# server: apply_ticket_update() kept requesting a resync, whose own
|
||||
# response hit exactly the same false mismatch, forever, which
|
||||
# control_plane_smoke.gd (a live end-to-end test, not a mock) surfaced as
|
||||
# a request that legitimately never terminates. It's still kept current
|
||||
# via the direct assignment below, just not treated as a conflict signal.
|
||||
return String(update["state"]) != phase or (update.has("playlist") and String(update["playlist"]) != playlist)
|
||||
|
||||
|
||||
func _request_resync(resource_id: String) -> bool:
|
||||
needs_resync = true
|
||||
resync_required.emit(resource_id)
|
||||
return false
|
||||
|
||||
|
||||
func _emit_changed() -> void:
|
||||
changed.emit(snapshot())
|
||||
|
||||
|
||||
func _reset() -> void:
|
||||
phase = IDLE
|
||||
playlist = ""
|
||||
revision = 0
|
||||
enqueued_at_unix = 0
|
||||
expires_at_unix = 0
|
||||
proposal_id = ""
|
||||
proposal_revision = 0
|
||||
proposal_state = ""
|
||||
message = ""
|
||||
needs_resync = false
|
||||
|
||||
|
||||
func _is_ticket_state(value: String) -> bool:
|
||||
return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]
|
||||
|
||||
|
||||
func _is_legal_ticket_transition(from: String, to: String) -> bool:
|
||||
if from == to:
|
||||
return true
|
||||
var transitions := {
|
||||
QUEUED: [PROPOSED, CANCELLED, EXPIRED],
|
||||
PROPOSED: [QUEUED, ACCEPTED, CANCELLED, EXPIRED],
|
||||
ACCEPTED: [QUEUED, ALLOCATING, CANCELLED, FAILED],
|
||||
ALLOCATING: [PROCESS_READY, FAILED, CANCELLED],
|
||||
PROCESS_READY: [ASSIGNMENT_READY, FAILED, CANCELLED],
|
||||
ASSIGNMENT_READY: [ASSIGNED, FAILED, CANCELLED],
|
||||
ASSIGNED: [CONNECTING, FAILED, CANCELLED],
|
||||
CONNECTING: [LIVE, FAILED, EXPIRED],
|
||||
LIVE: [RESULT_PENDING, FAILED],
|
||||
RESULT_PENDING: [COMPLETED, FAILED],
|
||||
}
|
||||
return transitions.has(from) and to in transitions[from]
|
||||
|
||||
|
||||
func _can_reach_ticket_state(from: String, to: String) -> bool:
|
||||
if from == to:
|
||||
return true
|
||||
var pending: Array[String] = [from]
|
||||
var visited := {}
|
||||
visited[from] = true
|
||||
while not pending.is_empty():
|
||||
var current: String = pending.pop_front()
|
||||
for candidate in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]:
|
||||
if visited.has(candidate) or not _is_legal_ticket_transition(current, candidate):
|
||||
continue
|
||||
if candidate == to:
|
||||
return true
|
||||
visited[candidate] = true
|
||||
pending.append(candidate)
|
||||
return false
|
||||
|
||||
|
||||
func _is_legal_proposal_transition(from: String, to: String) -> bool:
|
||||
if from.is_empty():
|
||||
return to == "OPEN"
|
||||
if from == to:
|
||||
return true
|
||||
return from == "OPEN" and to in ["ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
|
||||
|
||||
|
||||
func _has_string(value: Dictionary, key: String) -> bool:
|
||||
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
|
||||
|
||||
|
||||
func _valid_revision(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
|
||||
func _valid_playlist(value: String) -> bool:
|
||||
return value == "casual" or value == "ranked"
|
||||
|
||||
|
||||
func _valid_epoch(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
|
||||
func _valid_opaque_id(value: String) -> bool:
|
||||
if value.length() < 16 or value.length() > 128:
|
||||
return false
|
||||
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||
return resource_pattern.search(value) != null
|
||||
@@ -1,6 +1,6 @@
|
||||
extends RefCounted
|
||||
|
||||
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-todo.md).
|
||||
# Plain data holder for one body's snapshot state (§2.4 of MULTIPLAYER_SPEC.md).
|
||||
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
|
||||
# node — NetCodec's pack/unpack must stay callable from pure-function tests
|
||||
# with no live scene. Phase 2's snapshot writer fills one of these per body
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name NetCodec
|
||||
|
||||
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
|
||||
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
|
||||
# packets (§2 of MULTIPLAYER_SPEC.md). Pure functions only — no networking,
|
||||
# no autoload state — so they're testable head-on by tests/test_runner.tscn
|
||||
# without a live connection.
|
||||
#
|
||||
@@ -48,7 +48,7 @@ const BODY_FLAG_STALLED := 1 << 5
|
||||
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
|
||||
|
||||
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
|
||||
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
|
||||
# restated prose; see MULTIPLAYER_SPEC.md for the ArenaBoundary/Ship/Ball
|
||||
# constants these are sized against) ---
|
||||
const POS_RANGE := 64.0 # metres, ±
|
||||
const VEL_RANGE := 64.0 # m/s, ±
|
||||
|
||||
@@ -3,7 +3,7 @@ extends RefCounted
|
||||
|
||||
# Buffers recent snapshot samples for ONE remote body and produces
|
||||
# interpolated states at any requested (possibly fractional) server tick —
|
||||
# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote
|
||||
# used twice per body (MULTIPLAYER_SPEC.md §4.1/§4.6, "dual-time remote
|
||||
# entities"): once at the present-time estimate for the collider, once
|
||||
# further back at present-minus-INTERP_DELAY for $Visual.
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
extends RefCounted
|
||||
|
||||
# Local-ship reconciliation policy (multiplayer-todo.md §4.4). Kept out of
|
||||
# Local-ship reconciliation policy (MULTIPLAYER_SPEC.md §4.4). Kept out of
|
||||
# NetworkedMatch so the decision table is pure-testable; the imperative half
|
||||
# only writes Ship's existing Jolt-safe queued correction hooks.
|
||||
|
||||
@@ -44,6 +44,11 @@ static func decide(comparison: Dictionary, local_frozen: bool, reset_changed: bo
|
||||
# normally against real data.
|
||||
if comparison.get("status", "") == "unsimulated_gap":
|
||||
return {"mode": "skip", "reason": "unsimulated_gap"}
|
||||
if comparison.get("status", "") == "warmup_not_recorded":
|
||||
# Sequence acknowledgements that predate the first local post-step state
|
||||
# are expected during startup. The initial snapshot already placed the
|
||||
# body, so there is no correction to apply and no resync to arm.
|
||||
return {"mode": "skip", "reason": "warmup_not_recorded"}
|
||||
if comparison.get("status", "missing_not_recorded") != "matched":
|
||||
return {"mode": "hard", "reason": comparison.get("status", "missing")}
|
||||
if authoritative == null or authoritative.frozen != local_frozen:
|
||||
|
||||
@@ -89,7 +89,7 @@ func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> voi
|
||||
return
|
||||
# process_always = true: a simulated wire delay must keep counting down
|
||||
# even if the local SceneTree pauses (match_mode.gd's goal-pause does
|
||||
# this today; multiplayer-todo.md §8 already flags get_tree().paused
|
||||
# this today; multiplayer-next.md §8 already flags get_tree().paused
|
||||
# stopping the client's own send/receive loop as a separate refactor
|
||||
# item). Pausing this timer too would let a paused client's in-flight
|
||||
# packets pile up and arrive in a burst on unpause instead of on their
|
||||
|
||||
@@ -3,7 +3,7 @@ extends Node
|
||||
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
|
||||
# hosting, joining, shutdown, and connection-state signals. Lives
|
||||
# at a fixed autoload path so RPC NodePaths never depend on which scene is
|
||||
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
|
||||
# loaded (§1.3 of MULTIPLAYER_SPEC.md's derived decisions).
|
||||
#
|
||||
# server_relay = false is set the moment a peer exists: the default `true`
|
||||
# lets any client rpc() any other client *through the server*, which this
|
||||
@@ -25,7 +25,7 @@ extends Node
|
||||
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
|
||||
# that off; every caller that sends or expects to receive on a tight cadence
|
||||
# must now call NetworkManager.poll() itself. The intended placement per
|
||||
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
|
||||
# multiplayer-next.md §7 task 1.3 (client: end of _physics_process after
|
||||
# sending input, plus top of both _process and _physics_process for receive;
|
||||
# server: tick start to drain, tick end to flush) has no real per-tick caller
|
||||
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
|
||||
@@ -71,6 +71,7 @@ var is_server := false
|
||||
var is_client := false
|
||||
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
||||
var active_transport := ""
|
||||
var _invalidated_peer_ids: Dictionary = {}
|
||||
|
||||
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
|
||||
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
|
||||
@@ -127,6 +128,27 @@ func poll() -> void:
|
||||
multiplayer.poll()
|
||||
|
||||
|
||||
# A peer can be removed from the transport while Godot is still draining the
|
||||
# same poll batch. During that interval get_peers() may still contain it, but
|
||||
# an RPC send already fails because ENet has torn down its channels.
|
||||
func invalidate_peer(peer_id: int) -> void:
|
||||
_invalidated_peer_ids[peer_id] = true
|
||||
|
||||
|
||||
func can_send_to_peer(peer_id: int) -> bool:
|
||||
if _invalidated_peer_ids.has(peer_id):
|
||||
return false
|
||||
if _peer == null or _peer is OfflineMultiplayerPeer:
|
||||
return false
|
||||
# A listening server's peer status is transport/version-specific; the
|
||||
# authoritative server is valid as soon as it owns a peer and the target
|
||||
# appears in get_peers(). Clients, however, must not dispatch while their
|
||||
# connection is still handshaking.
|
||||
if not is_server and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return false
|
||||
return peer_id in multiplayer.get_peers()
|
||||
|
||||
|
||||
func available_transports() -> PackedStringArray:
|
||||
var transports := PackedStringArray([TRANSPORT_ENET])
|
||||
if SteamTransportScript.new().is_available():
|
||||
@@ -185,6 +207,7 @@ func shutdown() -> void:
|
||||
peer.close()
|
||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||
_peer = null
|
||||
_invalidated_peer_ids.clear()
|
||||
active_transport = ""
|
||||
is_server = false
|
||||
is_client = false
|
||||
|
||||
@@ -124,7 +124,8 @@ class SlotInfo:
|
||||
# §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot
|
||||
# keeps its ship and swaps the controller, so body order (and therefore
|
||||
# every snapshot index) stays stable for the whole match.
|
||||
var player_name := "" # identity key for reconnect; peer_id changes across a reconnect
|
||||
var player_name := "" # display name only; never authoritative for allocated reclaim
|
||||
var player_identity := "" # signed allocation identity; peer_id changes across a reconnect
|
||||
var disconnected := false
|
||||
var reserved_until_tick := -1 # server only: slot held for this player until here
|
||||
var interpolator := NetInterpolator.new() # client only
|
||||
@@ -310,12 +311,20 @@ var _late_joiners: Array[Dictionary] = []
|
||||
# to this scene; static because the loop cannot hold a reference to a node that
|
||||
# does not exist yet, and consumed on read so it cannot leak into a later match.
|
||||
static var server_arena_override := ""
|
||||
# Set only by ServerMatchLoop after an allocated casual match passes the
|
||||
# initial-connect policy. It is consumed once while building the authoritative
|
||||
# six-slot lineup, so direct servers and ranked allocations cannot add bots.
|
||||
static var server_bot_fill_override := false
|
||||
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
|
||||
# entirely, negative means unlimited.
|
||||
var _max_spectators := -1
|
||||
var _last_emitted_countdown := -1
|
||||
var _in_overtime := false
|
||||
var _max_overtime_seconds := 900.0
|
||||
var _overtime_deadline_tick := -1
|
||||
var _match_over := false
|
||||
var _planned_server_shutdown := false
|
||||
var _awaiting_result_submission := false
|
||||
# Dedicated-export smoke hook (task 6.2). It is parsed only by the authoritative
|
||||
# server, cannot be triggered by an RPC, and defaults to disabled.
|
||||
var _smoke_force_goal_tick := -1
|
||||
@@ -342,6 +351,7 @@ func _ready() -> void:
|
||||
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only —
|
||||
# a client cannot shorten anyone's match.
|
||||
match_length_seconds = maxf(1.0, float(config.get_value("match-length")))
|
||||
_max_overtime_seconds = maxf(1.0, float(config.get_value("max-overtime-seconds")))
|
||||
var smoke_after := float(config.get_value("smoke-force-goal-after"))
|
||||
if smoke_after >= 0.0:
|
||||
_smoke_force_goal_tick = -2 # arm when PLAYING begins; -1 remains disabled
|
||||
@@ -356,6 +366,10 @@ func _ready() -> void:
|
||||
_replay_log = null
|
||||
else:
|
||||
print("NetworkedMatch: recording replay log to %s" % replay_path)
|
||||
# Result acknowledgement is relevant only to the authority. Clients move
|
||||
# to their lobby on the replicated RESULTS -> LOBBY transition.
|
||||
MatchNet.result_submission_accepted.connect(_on_result_submission_accepted)
|
||||
MatchNet.result_submission_retrying.connect(_on_result_submission_retrying)
|
||||
_start_server()
|
||||
else:
|
||||
for arg: String in OS.get_cmdline_user_args():
|
||||
@@ -386,6 +400,7 @@ func _ready() -> void:
|
||||
# is not connected" errors per run — it only ever left because a test
|
||||
# timer happened to fire.
|
||||
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||
MatchNet.server_shutdown.connect(_on_server_shutdown)
|
||||
_request_match_config_until_received()
|
||||
|
||||
|
||||
@@ -450,22 +465,47 @@ func _start_server() -> void:
|
||||
var teams := PackedInt32Array()
|
||||
var spawn_indices := PackedInt32Array()
|
||||
var team_counts := {0: 0, 1: 0}
|
||||
var sorted_peer_ids: Array = MatchNet.roster.keys()
|
||||
sorted_peer_ids.sort()
|
||||
for peer_id in sorted_peer_ids:
|
||||
var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
|
||||
var spawn_index: int = team_counts.get(info.team, 0)
|
||||
team_counts[info.team] = spawn_index + 1
|
||||
var config := ServerConfig.parse(OS.get_cmdline_user_args(), false)
|
||||
var use_assigned_bot_fill := server_bot_fill_override and bool(config.get_value("allocated-mode"))
|
||||
server_bot_fill_override = false
|
||||
var spawn_entries: Array[Dictionary] = []
|
||||
if use_assigned_bot_fill:
|
||||
var by_identity := {}
|
||||
for peer_id in MatchNet.roster.keys():
|
||||
var roster_info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
|
||||
by_identity[roster_info.player_identity] = {"peer_id": int(peer_id), "info": roster_info}
|
||||
for assigned: Dictionary in MatchNet.assigned_player_slots():
|
||||
var identity := String(assigned["player_identity"])
|
||||
if by_identity.has(identity):
|
||||
var human: Dictionary = by_identity[identity]
|
||||
spawn_entries.append({"peer_id": human["peer_id"], "info": human["info"], "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": false})
|
||||
else:
|
||||
spawn_entries.append({"peer_id": -1, "info": null, "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": true})
|
||||
else:
|
||||
var sorted_peer_ids: Array = MatchNet.roster.keys()
|
||||
sorted_peer_ids.sort()
|
||||
for peer_id in sorted_peer_ids:
|
||||
var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
|
||||
var spawn_index: int = info.spawn_index if info.spawn_index >= 0 else team_counts.get(info.team, 0)
|
||||
if info.spawn_index < 0:
|
||||
team_counts[info.team] = spawn_index + 1
|
||||
spawn_entries.append({"peer_id": peer_id, "info": info, "team": info.team, "spawn_index": spawn_index, "bot": false})
|
||||
for entry: Dictionary in spawn_entries:
|
||||
var peer_id: int = int(entry["peer_id"])
|
||||
var info: MatchNet.PlayerInfo = entry["info"]
|
||||
var team: int = int(entry["team"])
|
||||
var spawn_index: int = int(entry["spawn_index"])
|
||||
var slot := SlotInfo.new()
|
||||
slot.peer_id = peer_id
|
||||
slot.team = info.team
|
||||
slot.team = team
|
||||
slot.spawn_index = spawn_index
|
||||
slot.player_name = info.player_name
|
||||
slot.controller = RLShipController.new()
|
||||
slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
|
||||
slot.player_name = "Bot %d" % spawn_index if bool(entry["bot"]) else info.player_name
|
||||
slot.player_identity = "" if bool(entry["bot"]) else MatchNet.player_identity(peer_id)
|
||||
slot.controller = _build_opponent(bot_model_path, bot_reaction_ticks, bot_action_noise, "NetworkedMatch") if bool(entry["bot"]) else RLShipController.new()
|
||||
slot.ship = spawn_ship(team, spawn_index, slot.controller)
|
||||
_slots.append(slot)
|
||||
peer_ids.append(peer_id)
|
||||
teams.append(info.team)
|
||||
teams.append(team)
|
||||
spawn_indices.append(spawn_index)
|
||||
|
||||
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
|
||||
@@ -648,6 +688,8 @@ func _apply_match_state(new_state: int, at_tick: int) -> void:
|
||||
# Kickoff is over: bodies move again, and the clock resumes.
|
||||
_pending_freeze_tick = -1
|
||||
_set_bodies_frozen(false)
|
||||
if new_state == MatchState.State.OVERTIME:
|
||||
_overtime_deadline_tick = at_tick + int(_max_overtime_seconds * SimConstants.TICK_HZ)
|
||||
# The clock only advances during live play (§6.2 step 9). Derived here
|
||||
# rather than tracked separately so it cannot disagree with the state.
|
||||
var was_running := _clock_running
|
||||
@@ -964,12 +1006,23 @@ func _broadcast_clock_state() -> void:
|
||||
|
||||
|
||||
func _on_disconnected_from_server() -> void:
|
||||
if _planned_server_shutdown:
|
||||
return
|
||||
# Deferred: this arrives from inside NetworkManager's poll, and gotcha 27
|
||||
# requires change_scene_to_file never run synchronously from a callback
|
||||
# mid-traversal.
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _on_server_shutdown(reason: String) -> void:
|
||||
if multiplayer.is_server() or _planned_server_shutdown:
|
||||
return
|
||||
_planned_server_shutdown = true
|
||||
print("NetworkedMatch: server shutdown notice: %s" % reason)
|
||||
NetworkManager.shutdown()
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
|
||||
|
||||
|
||||
func _on_match_bootstrap_received(state: int, at_tick: int, new_score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
|
||||
score = new_score.duplicate()
|
||||
score_changed.emit(score.duplicate())
|
||||
@@ -1007,11 +1060,13 @@ func _update_clock() -> void:
|
||||
|
||||
# --- §6.2 step 10: full time, overtime, results (task 5.5) -----------------
|
||||
|
||||
func _enter_results(winning_team: int) -> void:
|
||||
func _enter_results(winning_team: int, integrity_state := "CERTIFIED") -> void:
|
||||
_match_over = true
|
||||
_clock_running = false
|
||||
_set_bodies_frozen(true)
|
||||
match_ended.emit(winning_team, score.duplicate())
|
||||
if multiplayer.is_server() and MatchNet.submit_authoritative_result(score, integrity_state):
|
||||
_awaiting_result_submission = true
|
||||
ServerLog.info("match_ended", {"score_0": score.get(0, 0), "score_1": score.get(1, 0), "overtime": _in_overtime})
|
||||
_set_match_state(MatchState.State.RESULTS)
|
||||
|
||||
@@ -1053,6 +1108,12 @@ func _update_match_state() -> void:
|
||||
else:
|
||||
_enter_results(_winning_team())
|
||||
return
|
||||
if match_state == MatchState.State.OVERTIME and _overtime_deadline_tick >= 0 and now >= _overtime_deadline_tick:
|
||||
# Golden goal remains clockless to players, but an operational bound is
|
||||
# necessary: a stalled draw must finish while its allocated credential is
|
||||
# valid. REVIEW completes lifecycle delivery without rating either side.
|
||||
_enter_results(-1, "REVIEW")
|
||||
return
|
||||
if _state_deadline_tick < 0 or now < _state_deadline_tick:
|
||||
return
|
||||
match match_state:
|
||||
@@ -1064,6 +1125,8 @@ func _update_match_state() -> void:
|
||||
_set_match_state(MatchState.State.WARMUP)
|
||||
_begin_kickoff()
|
||||
MatchState.State.RESULTS:
|
||||
if _awaiting_result_submission:
|
||||
return
|
||||
# §6.2 step 10: clients return to the LOBBY, never the main menu —
|
||||
# a community server that empties every 2.5 minutes is dead on
|
||||
# arrival. The state change is what moves both sides; the server
|
||||
@@ -1072,6 +1135,18 @@ func _update_match_state() -> void:
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
|
||||
|
||||
|
||||
func _on_result_submission_accepted() -> void:
|
||||
if not multiplayer.is_server() or not _awaiting_result_submission:
|
||||
return
|
||||
_awaiting_result_submission = false
|
||||
_state_deadline_tick = Engine.get_physics_frames()
|
||||
|
||||
|
||||
func _on_result_submission_retrying(http_code: int) -> void:
|
||||
if multiplayer.is_server() and _awaiting_result_submission:
|
||||
ServerLog.warn("result_submission_retrying", {"http_code": http_code})
|
||||
|
||||
|
||||
func _on_state_change_received(state: int, at_tick: int) -> void:
|
||||
# Client path. MatchSim already rejected an unknown state value, and the
|
||||
# server is the only peer allowed to send this (rpc "authority").
|
||||
@@ -1206,12 +1281,12 @@ func _build_takeover_controller() -> ShipController:
|
||||
|
||||
# Called when a peer joins while this match is already running. Returns true if
|
||||
# it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation).
|
||||
func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
|
||||
func _try_reclaim_slot(peer_id: int, player_identity: String, player_name: String) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
var now := Engine.get_physics_frames()
|
||||
for slot in _slots:
|
||||
if not slot.disconnected or slot.player_name == "" or slot.player_name != player_name:
|
||||
if not slot.disconnected or slot.player_name == "" or not MatchNet.reservation_identity_matches(slot.player_identity, player_identity, slot.player_name, player_name):
|
||||
continue
|
||||
if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
|
||||
continue # reservation lapsed; this is a fresh joiner, not a return
|
||||
@@ -1246,7 +1321,7 @@ func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
|
||||
func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
|
||||
if not multiplayer.is_server() or _slots.is_empty():
|
||||
return
|
||||
if _try_reclaim_slot(peer_id, player_name):
|
||||
if _try_reclaim_slot(peer_id, MatchNet.player_identity(peer_id), player_name):
|
||||
return
|
||||
if _max_spectators >= 0 and _spectator_count() > _max_spectators:
|
||||
print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id])
|
||||
@@ -1262,7 +1337,7 @@ func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
|
||||
# in _promote_late_joiners(). Queued in arrival order and consumed from the
|
||||
# front, so waiting is first-come-first-served rather than whichever slot
|
||||
# index happens to free up first.
|
||||
_late_joiners.append({"peer_id": peer_id, "player_name": player_name})
|
||||
_late_joiners.append({"peer_id": peer_id, "player_name": player_name, "player_identity": MatchNet.player_identity(peer_id)})
|
||||
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
|
||||
|
||||
|
||||
@@ -1302,6 +1377,7 @@ func _promote_late_joiners() -> void:
|
||||
var joiner_peer := int(joiner["peer_id"])
|
||||
slot.peer_id = joiner_peer
|
||||
slot.player_name = String(joiner["player_name"])
|
||||
slot.player_identity = String(joiner.get("player_identity", ""))
|
||||
slot.disconnected = false
|
||||
slot.reserved_until_tick = -1
|
||||
# Same reasoning as the reclaim path: the arriving client numbers its
|
||||
@@ -2140,6 +2216,12 @@ func get_net_debug_stats() -> Dictionary:
|
||||
"ball_proxy_moved_before_authority": _ball_proxy_moved_before_authority_count > 0,
|
||||
"ball_proxy_moved_before_authority_count": _ball_proxy_moved_before_authority_count,
|
||||
"ball_authority_changed_since_contact": _ball_authority_changed_since_contact,
|
||||
# p95 alongside p99. A p99 over a few hundred samples is only its worst
|
||||
# handful, so on a loaded host it reports scheduling jitter as much as
|
||||
# interpolation quality. p95 is stable enough to carry a tight bar,
|
||||
# leaving p99 to catch genuine tail blow-ups.
|
||||
"remote_residual_position_p95": _remote_percentile(_remote_position_residuals, 0.95),
|
||||
"remote_residual_rotation_p95": _remote_percentile(_remote_rotation_residuals, 0.95),
|
||||
"remote_residual_position_p99": _remote_percentile(_remote_position_residuals, 0.99),
|
||||
"remote_residual_rotation_p99": _remote_percentile(_remote_rotation_residuals, 0.99),
|
||||
"latest_prediction_error": _last_local_prediction_comparison.get("position_error", Vector3.ZERO),
|
||||
|
||||
@@ -5,7 +5,7 @@ extends CanvasLayer
|
||||
# Performance monitors; never touches rendering or gameplay state. Exists so
|
||||
# 0.17/0.17b's graphics presets and resolution scaling are self-diagnosing —
|
||||
# TIME_PROCESS vs total frame time tells the player whether they're CPU- or
|
||||
# GPU-bound. See multiplayer-todo.md task 0.20.
|
||||
# GPU-bound. See multiplayer-next.md task 0.20.
|
||||
|
||||
# ~2s of history at 60 fps; enough to make p50/p99 meaningful without the
|
||||
# history itself being a rate-dependent quantity.
|
||||
|
||||
@@ -10,30 +10,39 @@ var _action := ShipAction.new()
|
||||
func get_action() -> ShipAction:
|
||||
# Full overwrite per axis (not +=/-=): _action is reused across ticks, so
|
||||
# fields must not depend on starting from a fresh Vector3.ZERO each call.
|
||||
#
|
||||
# Input.get_axis(negative, positive) is strength(positive) -
|
||||
# strength(negative), so these keep the exact sign conventions the digital
|
||||
# version had while becoming proportional on a controller:
|
||||
# get_action_strength() returns a flat 1.0 for a held key but the
|
||||
# normalised past-deadzone deflection for an InputEventJoypadMotion. A
|
||||
# half-pulled trigger is therefore half thrust, and keyboard flight is
|
||||
# unchanged down to the value.
|
||||
|
||||
# Forward/Backward thrust (main engines)
|
||||
_action.thrust.z = (1.0 if Input.is_action_pressed("move_forward") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_back") else 0.0)
|
||||
_action.thrust.z = Input.get_axis("move_back", "move_forward")
|
||||
|
||||
# Strafe thrusters (left/right)
|
||||
_action.thrust.x = (1.0 if Input.is_action_pressed("move_right") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_left") else 0.0)
|
||||
_action.thrust.x = Input.get_axis("move_left", "move_right")
|
||||
|
||||
# Vertical thrusters (up/down)
|
||||
_action.thrust.y = (1.0 if Input.is_action_pressed("move_up") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("move_down") else 0.0)
|
||||
_action.thrust.y = Input.get_axis("move_down", "move_up")
|
||||
|
||||
# Yaw (turn left/right around Y axis)
|
||||
_action.rotation.y = (1.0 if Input.is_action_pressed("turn_left") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("turn_right") else 0.0)
|
||||
_action.rotation.y = Input.get_axis("turn_right", "turn_left")
|
||||
|
||||
# Pitch (nose up/down around X axis)
|
||||
_action.rotation.x = (1.0 if Input.is_action_pressed("pitch_down") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("pitch_up") else 0.0)
|
||||
# Pitch (nose up/down around X axis). Positive rotation.x is nose-UP:
|
||||
# torque about local +X rotates the ship's up vector toward its tail by the
|
||||
# right-hand rule, which lifts the nose (measured, not assumed). The
|
||||
# argument order here used to be reversed, so "pitch_down" pitched up and
|
||||
# the I/K keys were each labelled as the opposite of what they did.
|
||||
# The default binding then gives flight-sim polarity — right stick forward
|
||||
# is pitch_down is nose down — and InputSettings holds the player's
|
||||
# preference for flipping that.
|
||||
_action.rotation.x = Input.get_axis("pitch_down", "pitch_up") * InputSettings.pitch_sign()
|
||||
|
||||
# Roll (bank left/right around Z axis)
|
||||
_action.rotation.z = (1.0 if Input.is_action_pressed("roll_left") else 0.0) \
|
||||
- (1.0 if Input.is_action_pressed("roll_right") else 0.0)
|
||||
_action.rotation.z = Input.get_axis("roll_right", "roll_left")
|
||||
|
||||
_action.turbo = Input.is_action_pressed("turbo")
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
class_name RankedProfileState
|
||||
extends RefCounted
|
||||
|
||||
# Read-only server projection. The client deliberately stores no tier bands
|
||||
# or rating formula: it displays the backend's committed view verbatim after
|
||||
# validating the shape and numeric safety of the response.
|
||||
|
||||
var available := false
|
||||
var rating := 0.0
|
||||
var rd := 0.0
|
||||
var volatility := 0.0
|
||||
var ranked_games := 0
|
||||
var tier := ""
|
||||
var provisional := false
|
||||
var season_id := ""
|
||||
var season_ends_at_unix := 0
|
||||
var error_message := ""
|
||||
|
||||
|
||||
func apply(payload: Dictionary) -> bool:
|
||||
var required := ["rating", "rd", "volatility", "ranked_games", "tier", "provisional"]
|
||||
for key in required:
|
||||
if not payload.has(key):
|
||||
return _reject("Profile response is missing " + key)
|
||||
if not (payload["rating"] is int or payload["rating"] is float) or not (payload["rd"] is int or payload["rd"] is float) or not (payload["volatility"] is int or payload["volatility"] is float) or not (payload["ranked_games"] is int or payload["ranked_games"] is float) or not payload["tier"] is String or not payload["provisional"] is bool:
|
||||
return _reject("Profile response contains invalid types")
|
||||
var next_rating := float(payload["rating"])
|
||||
var next_rd := float(payload["rd"])
|
||||
var next_volatility := float(payload["volatility"])
|
||||
var next_games := int(payload["ranked_games"])
|
||||
var next_tier := String(payload["tier"])
|
||||
if not is_finite(next_rating) or not is_finite(next_rd) or not is_finite(next_volatility) or not _valid_nonnegative_integer(payload["ranked_games"]) or next_rating < 0.0 or next_rd < 0.0 or next_volatility < 0.0 or next_games < 0 or not _valid_tier(next_tier):
|
||||
return _reject("Profile response contains invalid values")
|
||||
rating = next_rating
|
||||
rd = next_rd
|
||||
volatility = next_volatility
|
||||
ranked_games = next_games
|
||||
tier = next_tier
|
||||
provisional = bool(payload["provisional"])
|
||||
season_id = ""
|
||||
if payload.has("season_id"):
|
||||
if not payload["season_id"] is String or not is_valid_opaque_id(String(payload["season_id"])):
|
||||
return _reject("Profile response contains invalid season identifier")
|
||||
season_id = String(payload["season_id"])
|
||||
season_ends_at_unix = 0
|
||||
if payload.has("season_ends_at"):
|
||||
if not payload["season_ends_at"] is String or not is_valid_season_timestamp(String(payload["season_ends_at"])):
|
||||
return _reject("Profile response contains invalid season expiry")
|
||||
var parsed_season_end := Time.get_unix_time_from_datetime_string(String(payload["season_ends_at"]))
|
||||
if parsed_season_end < 0:
|
||||
return _reject("Profile response contains invalid season expiry")
|
||||
season_ends_at_unix = int(parsed_season_end)
|
||||
available = true
|
||||
error_message = ""
|
||||
return true
|
||||
|
||||
|
||||
static func is_valid_season_timestamp(value: String) -> bool:
|
||||
if value.is_empty():
|
||||
return false
|
||||
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
|
||||
return timestamp_pattern.search(value) != null
|
||||
|
||||
|
||||
static func is_valid_opaque_id(value: String) -> bool:
|
||||
if value.length() < 16 or value.length() > 128:
|
||||
return false
|
||||
var id_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||
return id_pattern.search(value) != null
|
||||
|
||||
|
||||
static func _valid_nonnegative_integer(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
|
||||
static func _valid_tier(value: String) -> bool:
|
||||
return value in ["PROVISIONAL", "BRONZE", "SILVER", "GOLD", "PLATINUM", "DIAMOND"]
|
||||
|
||||
|
||||
func set_error(reason: String) -> void:
|
||||
available = false
|
||||
error_message = reason
|
||||
|
||||
|
||||
func display_text(now_unix: int = -1) -> String:
|
||||
if not available:
|
||||
return error_message if not error_message.is_empty() else "Ranked profile unavailable"
|
||||
var status := "Provisional" if provisional else tier
|
||||
var text := "%s · %d ranked game%s" % [status, ranked_games, "" if ranked_games == 1 else "s"]
|
||||
if season_ends_at_unix > 0:
|
||||
var current_unix := int(Time.get_unix_time_from_system()) if now_unix < 0 else now_unix
|
||||
var remaining_days := maxi(0, int(ceil(float(season_ends_at_unix - current_unix) / 86400.0)))
|
||||
text += " · Season ends in %dd" % remaining_days
|
||||
return text
|
||||
|
||||
|
||||
func _reject(reason: String) -> bool:
|
||||
available = false
|
||||
error_message = reason
|
||||
return false
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name ReplayLog
|
||||
extends RefCounted
|
||||
|
||||
# Append-only binary server replay log (multiplayer-todo.md task 5.10).
|
||||
# Append-only binary server replay log (multiplayer-next.md task 5.10).
|
||||
#
|
||||
# The highest-value debuggability investment in Phase 5, and cheap precisely
|
||||
# because the packets are ALREADY flat bytes: this stores them verbatim rather
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class_name ScenePaths
|
||||
|
||||
const MAIN_MENU := "res://scenes/main_menu.tscn"
|
||||
const SETTINGS := "res://scenes/settings.tscn"
|
||||
# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu —
|
||||
# a community server whose players are all dumped back to their own menus
|
||||
# every 2.5 minutes has no way to keep a lobby together.
|
||||
|
||||
+179
-2
@@ -1,5 +1,12 @@
|
||||
extends Node
|
||||
|
||||
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const ServerControlScript = preload("res://scripts/server_control.gd")
|
||||
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
|
||||
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
||||
const ConnectionLeaseClientScript = preload("res://scripts/connection_lease_client.gd")
|
||||
const ServerResultClientScript = preload("res://scripts/server_result_client.gd")
|
||||
|
||||
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
|
||||
# via NetworkManager, logs structured lines, and watches for physics-tick
|
||||
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
|
||||
@@ -24,6 +31,12 @@ extends Node
|
||||
var _last_physics_frame := 0
|
||||
var config: ServerConfig = null
|
||||
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
|
||||
var _control: ServerControl = null
|
||||
var _match_loop: ServerMatchLoop = null
|
||||
var _agones = null
|
||||
var _connection_leases = null
|
||||
var _result_client = null
|
||||
var _drain_requested := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -51,6 +64,93 @@ func _ready() -> void:
|
||||
ServerLog.configure(String(config.get_value("log-level")))
|
||||
var port := int(config.get_value("port"))
|
||||
var max_clients := int(config.get_value("max-clients"))
|
||||
var allocated_mode := bool(config.get_value("allocated-mode"))
|
||||
var assigned_transport := String(config.get_value("transport"))
|
||||
# Hosted SDR is not wired into the Godot transport layer yet. Refuse the
|
||||
# allocated launch rather than silently opening an ENet endpoint that does
|
||||
# not match the signed assignment's transport contract.
|
||||
if allocated_mode and assigned_transport != NetworkManager.TRANSPORT_ENET:
|
||||
printerr("cosmic-clash-server: allocated transport '%s' is not supported by this build" % assigned_transport)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
# Agones injects its HTTP port into every managed game-server container.
|
||||
# Keep lifecycle readiness and health active in the reduced kind smoke even
|
||||
# though that environment intentionally omits allocation/roster semantics.
|
||||
var agones_managed := not OS.get_environment("AGONES_SDK_HTTP_PORT").is_empty()
|
||||
if allocated_mode or agones_managed:
|
||||
_control = ServerControlScript.new()
|
||||
_control.name = "ServerControl"
|
||||
_control.drain_requested.connect(_on_drain_requested)
|
||||
_control.initial_connect_ready.connect(_on_initial_connect_ready)
|
||||
get_tree().root.add_child.call_deferred(_control)
|
||||
var control_err := _control.start(int(config.get_value("readiness-port")), OS.get_environment(String(config.get_value("drain-token-env"))))
|
||||
if control_err != OK:
|
||||
printerr("cosmic-clash-server: refusing to start with invalid readiness control port")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
if agones_managed:
|
||||
_agones = AgonesSDKScript.new()
|
||||
_agones.name = "AgonesSDK"
|
||||
# Configure before parenting, then request health and defer the add like
|
||||
# every other node here (§9 gotcha 27: add_child() on get_tree().root
|
||||
# from inside _ready() is refused because the tree is still attaching
|
||||
# this very node, and the refusal is not catchable from GDScript). The
|
||||
# SDK arms its own timer in _ready(), so nothing depends on the order
|
||||
# these deferred calls happen to flush in.
|
||||
if _agones.configure_from_environment():
|
||||
_agones.start_health()
|
||||
else:
|
||||
# Never silent: without this the log looks identical to a healthy
|
||||
# server right up until Agones recycles it.
|
||||
printerr("cosmic-clash-server: AGONES_SDK_HTTP_PORT is missing or invalid; Agones health pings are disabled")
|
||||
get_tree().root.add_child.call_deferred(_agones)
|
||||
if allocated_mode:
|
||||
var roster_file := String(config.get_value("join-authorisations-file"))
|
||||
var key_file := String(config.get_value("join-authorisations-key-file"))
|
||||
var roster_json := FileAccess.get_file_as_string(roster_file)
|
||||
var signing_keys := _load_join_signing_keys(key_file)
|
||||
var roster_tokens = JSON.parse_string(roster_json)
|
||||
if not roster_tokens is Array or roster_tokens.is_empty() or signing_keys.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
|
||||
"match_id": String(config.get_value("match-id")),
|
||||
"server_id": String(config.get_value("server-id")),
|
||||
"protocol": str(NetCodec.PROTOCOL_VERSION),
|
||||
"protocol_version": NetCodec.PROTOCOL_VERSION,
|
||||
}, signing_keys) or MatchNet.assigned_player_slots().size() != roster_tokens.size():
|
||||
printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
# An allocated process owns exactly the roster issued for this match.
|
||||
# Never let the general-purpose direct-server default (one player) start
|
||||
# an allocated match with only a partial assignment admitted.
|
||||
config.values["min-players"] = required_min_players(true, roster_tokens.size(), int(config.get_value("min-players")))
|
||||
_connection_leases = ConnectionLeaseClientScript.new()
|
||||
_connection_leases.name = "ConnectionLeases"
|
||||
var lease_url := OS.get_environment("COSMIC_CLASH_CONTROL_PLANE_URL")
|
||||
var lease_token := OS.get_environment("COSMIC_CLASH_WORKLOAD_TOKEN")
|
||||
if _connection_leases.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
|
||||
_connection_leases.reconciliation_failed.connect(_on_connection_lease_reconciliation_failed)
|
||||
get_tree().root.add_child.call_deferred(_connection_leases)
|
||||
MatchNet.configure_connection_lease_callbacks(_connection_leases.claim, _connection_leases.record_disconnect)
|
||||
else:
|
||||
_connection_leases.queue_free()
|
||||
_connection_leases = null
|
||||
# Allocated matches must never fall back to an in-memory connection
|
||||
# generation. Doing so would admit a player without the durable fence
|
||||
# that prevents a second process (or a stale peer) from owning the same
|
||||
# ranked slot. Direct/community servers do not enter this branch.
|
||||
printerr("cosmic-clash-server: refusing allocated startup without connection-lease configuration")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
_result_client = ServerResultClientScript.new()
|
||||
_result_client.name = "ServerResults"
|
||||
if not _result_client.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
|
||||
printerr("cosmic-clash-server: refusing allocated startup without result-submission configuration")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
_result_client.accepted.connect(func(): MatchNet.result_submission_accepted.emit())
|
||||
_result_client.retrying.connect(func(http_code): MatchNet.result_submission_retrying.emit(http_code))
|
||||
get_tree().root.add_child.call_deferred(_result_client)
|
||||
MatchNet.configure_result_submission(_result_client.submit)
|
||||
|
||||
NetworkManager.client_connected.connect(_on_client_connected)
|
||||
NetworkManager.client_disconnected.connect(_on_client_disconnected)
|
||||
@@ -62,12 +162,19 @@ func _ready() -> void:
|
||||
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
|
||||
get_tree().quit(1)
|
||||
return
|
||||
if _control != null:
|
||||
_control.set_process_ready(true)
|
||||
_install_match_loop()
|
||||
ServerLog.info("server_started", {
|
||||
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
|
||||
"min_players": int(config.get_value("min-players")),
|
||||
"max_matches": int(config.get_value("max-matches")),
|
||||
"max_matches": 1 if allocated_mode else int(config.get_value("max-matches")),
|
||||
"arena_rotation": String(config.get_value("arena-rotation")),
|
||||
"allocated_mode": allocated_mode,
|
||||
"match_id": String(config.get_value("match-id")) if allocated_mode else "",
|
||||
"server_id": String(config.get_value("server-id")) if allocated_mode else "",
|
||||
"region": String(config.get_value("region")) if allocated_mode else "",
|
||||
"transport": assigned_transport if allocated_mode else NetworkManager.TRANSPORT_ENET,
|
||||
})
|
||||
_last_physics_frame = Engine.get_physics_frames()
|
||||
|
||||
@@ -78,16 +185,29 @@ func _ready() -> void:
|
||||
# it started. Same constraint the smoke-test hooks document.
|
||||
func _install_match_loop() -> void:
|
||||
var loop := ServerMatchLoop.new()
|
||||
_match_loop = loop
|
||||
loop.name = "ServerMatchLoop"
|
||||
loop.min_players = int(config.get_value("min-players"))
|
||||
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
|
||||
loop.max_matches = int(config.get_value("max-matches"))
|
||||
loop.max_matches = 1 if bool(config.get_value("allocated-mode")) else int(config.get_value("max-matches"))
|
||||
loop.rotation_mode = String(config.get_value("arena-rotation"))
|
||||
loop.allocated_mode = bool(config.get_value("allocated-mode"))
|
||||
loop.allocated_playlist = String(config.get_value("playlist"))
|
||||
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
|
||||
loop.allocated_arena_path = String(config.get_value("arena-path"))
|
||||
# The backend's fair timeout starts only after durable assignment-ready.
|
||||
# When a control plane is present, the supervisor arms this loop through
|
||||
# the authenticated local control endpoint after that transition commits.
|
||||
loop.allocated_admission_armed = not loop.allocated_mode or OS.get_environment("COSMIC_CLASH_INITIAL_CONNECT_SIGNAL_REQUIRED") != "1"
|
||||
get_tree().root.add_child.call_deferred(loop)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
if _drain_requested:
|
||||
var scene := get_tree().current_scene
|
||||
if not (is_instance_valid(scene) and scene.is_in_group("game")) and MatchNet.roster.is_empty():
|
||||
get_tree().quit(0)
|
||||
var current := Engine.get_physics_frames()
|
||||
var steps := current - _last_physics_frame
|
||||
_last_physics_frame = current
|
||||
@@ -119,3 +239,60 @@ func _on_player_joined(peer_id: int, player_name: String) -> void:
|
||||
func _on_player_left(peer_id: int) -> void:
|
||||
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
|
||||
|
||||
|
||||
func _on_drain_requested() -> void:
|
||||
_drain_requested = true
|
||||
MatchNet.admissions_open = false
|
||||
MatchNet.broadcast_server_shutdown("server_draining")
|
||||
ServerLog.info("server_draining", {"reason": "control_request"})
|
||||
|
||||
|
||||
func _on_initial_connect_ready() -> void:
|
||||
if _match_loop != null and is_instance_valid(_match_loop):
|
||||
_match_loop.arm_allocated_admission()
|
||||
ServerLog.info("initial_connect_window_started", {"match_id": String(config.get_value("match-id"))})
|
||||
|
||||
|
||||
func _on_connection_lease_reconciliation_failed(reason: String) -> void:
|
||||
# A durable/local divergence means this process can no longer prove that a
|
||||
# future generation is globally current. Preserve the live match but close
|
||||
# admission so it cannot mint additional ambiguous leases.
|
||||
MatchNet.admissions_open = false
|
||||
ServerLog.error("connection_lease_reconciliation_failed", {"reason": reason})
|
||||
|
||||
|
||||
static func valid_connection_report_configuration(base_url: String, workload_token: String, match_id: String, server_id: String, player_id: String) -> bool:
|
||||
return ConnectionLeaseClientScript.valid_configuration(base_url, workload_token, match_id, server_id) and AssignmentState.is_valid_opaque_id(player_id)
|
||||
|
||||
|
||||
static func required_min_players(allocated: bool, roster_size: int, configured: int) -> int:
|
||||
if allocated and roster_size > 0:
|
||||
return roster_size
|
||||
return configured
|
||||
|
||||
|
||||
# The join-signing key file maps key ID -> base64 raw key, so the allocator can
|
||||
# rotate the signing key without invalidating authorisations already issued for
|
||||
# in-flight matches: a rotation publishes the new key alongside the old, and the
|
||||
# old one is dropped only once no live match can still reference it.
|
||||
#
|
||||
# A file containing raw key bytes (no JSON object) is accepted as a single key
|
||||
# under the empty ID, which is what an unrotated deployment and the local smoke
|
||||
# fixtures use.
|
||||
static func _load_join_signing_keys(key_file: String) -> Dictionary:
|
||||
var raw := FileAccess.get_file_as_bytes(key_file)
|
||||
if raw.is_empty():
|
||||
return {}
|
||||
var parsed = JSON.parse_string(raw.get_string_from_utf8())
|
||||
if not parsed is Dictionary or (parsed as Dictionary).is_empty():
|
||||
return {"": raw}
|
||||
var keys := {}
|
||||
for key_id in parsed:
|
||||
var encoded = parsed[key_id]
|
||||
if not encoded is String or String(encoded).is_empty():
|
||||
return {}
|
||||
var decoded := Marshalls.base64_to_raw(String(encoded))
|
||||
if decoded.is_empty():
|
||||
return {}
|
||||
keys[str(key_id)] = decoded
|
||||
return keys
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name ServerConfig
|
||||
extends RefCounted
|
||||
|
||||
# Dedicated-server configuration (multiplayer-todo.md task 6.3): one
|
||||
# Dedicated-server configuration (multiplayer-next.md task 6.3): one
|
||||
# declaration of every server flag, one parser, one `--help`.
|
||||
#
|
||||
# Standalone RefCounted with no scene or RPC dependency — same reason as
|
||||
@@ -56,14 +56,32 @@ static func specs() -> Array[Spec]:
|
||||
out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error"))
|
||||
out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)"))
|
||||
out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds"))
|
||||
out.append(Spec.new("max-overtime-seconds", Kind.FLOAT, 900.0, "match", "Safety cap for sudden death; expiry records a REVIEW result without rating changes"))
|
||||
out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever"))
|
||||
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
|
||||
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
|
||||
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random"))
|
||||
out.append(Spec.new("arena-path", Kind.STRING, "", "match", "Allocated arena scene path; empty uses rotation"))
|
||||
out.append(Spec.new("smoke-force-goal-after", Kind.FLOAT, -1.0, "match", "LOCAL TEST ONLY: force one server-authoritative goal this many seconds after play starts; -1 disables"))
|
||||
out.append(Spec.new("fill-bots", Kind.BOOL, false, "match", "Give a disconnected player's ship to a bot instead of leaving it inert"))
|
||||
out.append(Spec.new("slot-reservation-seconds", Kind.FLOAT, 30.0, "match", "How long a departed player's slot is held for their return"))
|
||||
out.append(Spec.new("config", Kind.STRING, "", "general", "Path to a config file supplying defaults for any flag above"))
|
||||
# Allocated-mode fields are opt-in. Empty defaults intentionally preserve
|
||||
# the direct-IP/community-server path and its existing CLI/config surface.
|
||||
out.append(Spec.new("allocated-mode", Kind.BOOL, false, "allocation", "Enable match-scoped allocation admission and lifecycle"))
|
||||
out.append(Spec.new("match-id", Kind.STRING, "", "allocation", "Opaque allocated match identifier"))
|
||||
out.append(Spec.new("server-id", Kind.STRING, "", "allocation", "Opaque allocated server identifier"))
|
||||
out.append(Spec.new("playlist-version", Kind.STRING, "", "allocation", "Matchmaking playlist contract version"))
|
||||
out.append(Spec.new("playlist", Kind.STRING, "", "allocation", "Allocated playlist: casual or ranked"))
|
||||
out.append(Spec.new("client-build", Kind.STRING, "", "allocation", "Expected immutable client build identifier"))
|
||||
out.append(Spec.new("assignment-expiry-unix", Kind.INT, 0, "allocation", "Unix expiry for the allocated assignment; must be in the future"))
|
||||
out.append(Spec.new("server-image-digest", Kind.STRING, "", "allocation", "Expected immutable server image digest (sha256:...)"))
|
||||
out.append(Spec.new("transport", Kind.STRING, "", "allocation", "Assigned transport: steam_sdr or enet"))
|
||||
out.append(Spec.new("region", Kind.STRING, "", "allocation", "Assigned region: EU or NA"))
|
||||
out.append(Spec.new("join-authorisations-file", Kind.STRING, "", "allocation", "JSON array of control-plane signed join envelopes mounted for this match"))
|
||||
out.append(Spec.new("join-authorisations-key-file", Kind.STRING, "", "allocation", "HMAC-SHA256 key file for verifying mounted join envelopes"))
|
||||
out.append(Spec.new("readiness-port", Kind.INT, 7780, "allocation", "Loopback HTTP port for allocated process-ready and drain control"))
|
||||
out.append(Spec.new("drain-token-env", Kind.STRING, "COSMIC_CLASH_DRAIN_TOKEN", "allocation", "Environment variable containing the allocated drain bearer token"))
|
||||
return out
|
||||
|
||||
|
||||
@@ -231,10 +249,15 @@ func _validate() -> void:
|
||||
var port := int(values["port"])
|
||||
if port < 1 or port > 65535:
|
||||
errors.append("--port must be 1-65535, got %d" % port)
|
||||
var readiness_port := int(values["readiness-port"])
|
||||
if readiness_port < 1 or readiness_port > 65535:
|
||||
errors.append("--readiness-port must be 1-65535, got %d" % readiness_port)
|
||||
if int(values["max-clients"]) < 1:
|
||||
errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"]))
|
||||
if float(values["match-length"]) <= 0.0:
|
||||
errors.append("--match-length must be positive, got %s" % str(values["match-length"]))
|
||||
if float(values["max-overtime-seconds"]) <= 0.0:
|
||||
errors.append("--max-overtime-seconds must be positive, got %s" % str(values["max-overtime-seconds"]))
|
||||
if int(values["max-matches"]) < 0:
|
||||
errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"]))
|
||||
if int(values["min-players"]) < 1:
|
||||
@@ -249,6 +272,53 @@ func _validate() -> void:
|
||||
var rotation := String(values["arena-rotation"])
|
||||
if not rotation in ["sequential", "random"]:
|
||||
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
|
||||
var arena_path := String(values["arena-path"])
|
||||
if not arena_path.is_empty() and not arena_path in ArenaRegistry.rotation_paths():
|
||||
errors.append("--arena-path must be a ranked-eligible ArenaRegistry path, got '%s'" % arena_path)
|
||||
if bool(values["allocated-mode"]):
|
||||
for key in ["match-id", "server-id", "playlist-version", "playlist", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
|
||||
if str(values[key]).is_empty():
|
||||
errors.append("--allocated-mode requires --%s" % key)
|
||||
if not _is_opaque_id(String(values["match-id"])):
|
||||
errors.append("--match-id must be an opaque ID of 16-128 safe characters")
|
||||
if not _is_opaque_id(String(values["server-id"])):
|
||||
errors.append("--server-id must be an opaque ID of 16-128 safe characters")
|
||||
if int(values["assignment-expiry-unix"]) <= int(Time.get_unix_time_from_system()):
|
||||
errors.append("--assignment-expiry-unix must be in the future")
|
||||
if String(values["join-authorisations-file"]).is_empty():
|
||||
errors.append("--join-authorisations-file is required in allocated mode")
|
||||
if String(values["join-authorisations-key-file"]).is_empty():
|
||||
errors.append("--join-authorisations-key-file is required in allocated mode")
|
||||
var digest := String(values["server-image-digest"])
|
||||
if not _is_sha256_digest(digest):
|
||||
errors.append("--server-image-digest must be sha256:<64 hex characters>")
|
||||
var transport := String(values["transport"])
|
||||
if not transport in ["steam_sdr", "enet"]:
|
||||
errors.append("--transport must be steam_sdr or enet, got '%s'" % transport)
|
||||
var region := String(values["region"])
|
||||
if not region in ["EU", "NA"]:
|
||||
errors.append("--region must be EU or NA, got '%s'" % region)
|
||||
var playlist := String(values["playlist"])
|
||||
if not playlist in ["casual", "ranked"]:
|
||||
errors.append("--playlist must be casual or ranked, got '%s'" % playlist)
|
||||
if playlist == "ranked" and arena_path.is_empty():
|
||||
errors.append("--allocated-mode ranked matches require --arena-path")
|
||||
|
||||
|
||||
static func _is_sha256_digest(value: String) -> bool:
|
||||
if not value.begins_with("sha256:") or value.length() != 71:
|
||||
return false
|
||||
for c in value.substr(7):
|
||||
if not c.to_lower() in "0123456789abcdef":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _is_opaque_id(value: String) -> bool:
|
||||
if value.length() < 16 or value.length() > 128:
|
||||
return false
|
||||
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||
return resource_pattern.search(value) != null
|
||||
|
||||
|
||||
static func _kind_name(kind: int) -> String:
|
||||
@@ -276,7 +346,7 @@ static func help_text() -> String:
|
||||
lines.append("")
|
||||
lines.append("The command line overrides the config file, which overrides the defaults")
|
||||
lines.append("shown below. An unknown flag is an error, not a warning.")
|
||||
var sections := ["general", "network", "match", "logging"]
|
||||
var sections := ["general", "network", "match", "logging", "allocation"]
|
||||
var all := specs()
|
||||
for section in sections:
|
||||
lines.append("")
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
class_name ServerControl
|
||||
extends Node
|
||||
|
||||
# Small loopback HTTP control surface for lifecycle-managed servers. The Go
|
||||
# supervisor uses GET /ready as the explicit process-ready probe and POST
|
||||
# /drain during a controlled termination. Direct/community servers outside
|
||||
# Agones do not start this node.
|
||||
|
||||
signal drain_requested
|
||||
signal initial_connect_ready
|
||||
|
||||
var _listener := TCPServer.new()
|
||||
var _peers: Array = []
|
||||
var _ready_for_connections := false
|
||||
var _draining := false
|
||||
var _drain_token := ""
|
||||
|
||||
|
||||
func start(port: int, drain_token: String = "") -> Error:
|
||||
if port < 1 or port > 65535:
|
||||
return ERR_INVALID_PARAMETER
|
||||
_drain_token = drain_token
|
||||
return _listener.listen(port, "127.0.0.1")
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
_listener.stop()
|
||||
for peer in _peers:
|
||||
if is_instance_valid(peer):
|
||||
peer.disconnect_from_host()
|
||||
_peers.clear()
|
||||
|
||||
|
||||
func set_process_ready(value: bool) -> void:
|
||||
_ready_for_connections = value and not _draining
|
||||
|
||||
|
||||
func is_draining() -> bool:
|
||||
return _draining
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
stop()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
while _listener.is_connection_available():
|
||||
_peers.append(_listener.take_connection())
|
||||
for i in range(_peers.size() - 1, -1, -1):
|
||||
var peer: StreamPeerTCP = _peers[i]
|
||||
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
_peers.remove_at(i)
|
||||
continue
|
||||
var available := peer.get_available_bytes()
|
||||
if available <= 0:
|
||||
continue
|
||||
var request := peer.get_utf8_string(available)
|
||||
if "\r\n\r\n" not in request:
|
||||
continue
|
||||
_respond(peer, request)
|
||||
_peers.remove_at(i)
|
||||
|
||||
|
||||
func _respond(peer: StreamPeerTCP, request: String) -> void:
|
||||
var lines := request.split("\r\n")
|
||||
var first := lines[0].split(" ") if not lines.is_empty() else PackedStringArray()
|
||||
var method := String(first[0]) if first.size() > 0 else ""
|
||||
var path := String(first[1]) if first.size() > 1 else ""
|
||||
var status := 404
|
||||
var reason := "Not Found"
|
||||
var body := ""
|
||||
if method in ["GET", "POST"] and path == "/ready":
|
||||
status = 200 if _ready_for_connections else 503
|
||||
reason = "OK" if status == 200 else "Service Unavailable"
|
||||
elif method in ["GET", "POST"] and path == "/health":
|
||||
status = 200
|
||||
reason = "OK"
|
||||
elif method == "POST" and path == "/drain":
|
||||
var supplied := ""
|
||||
for line in lines:
|
||||
if line.begins_with("Authorization: Bearer "):
|
||||
supplied = line.substr("Authorization: Bearer ".length())
|
||||
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
|
||||
status = 401
|
||||
reason = "Unauthorized"
|
||||
else:
|
||||
_draining = true
|
||||
_ready_for_connections = false
|
||||
drain_requested.emit()
|
||||
status = 202
|
||||
reason = "Accepted"
|
||||
elif method == "POST" and path == "/initial-connect-ready":
|
||||
var supplied := ""
|
||||
for line in lines:
|
||||
if line.begins_with("Authorization: Bearer "):
|
||||
supplied = line.substr("Authorization: Bearer ".length())
|
||||
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
|
||||
status = 401
|
||||
reason = "Unauthorized"
|
||||
else:
|
||||
initial_connect_ready.emit()
|
||||
status = 202
|
||||
reason = "Accepted"
|
||||
else:
|
||||
status = 405 if method in ["GET", "POST"] else 400
|
||||
reason = "Method Not Allowed" if status == 405 else "Bad Request"
|
||||
body = "{\"status\":\"%s\"}" % ("ready" if status == 200 else "not_ready")
|
||||
var response := "HTTP/1.1 %d %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [status, reason, body.to_utf8_buffer().size(), body]
|
||||
peer.put_data(response.to_utf8_buffer())
|
||||
peer.disconnect_from_host()
|
||||
|
||||
|
||||
func _constant_time_equal(a: String, b: String) -> bool:
|
||||
var left := a.to_utf8_buffer()
|
||||
var right := b.to_utf8_buffer()
|
||||
var difference := left.size() ^ right.size()
|
||||
var length := mini(left.size(), right.size())
|
||||
for i in length:
|
||||
difference |= left[i] ^ right[i]
|
||||
return difference == 0
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name ServerLog
|
||||
extends RefCounted
|
||||
|
||||
# Structured server logging (multiplayer-todo.md task 6.4).
|
||||
# Structured server logging (multiplayer-next.md task 6.4).
|
||||
#
|
||||
# Extracted from server_boot.gd's private `_log`, which could only ever see
|
||||
# what the boot scene itself observed: connects, disconnects, roster changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name ServerMatchLoop
|
||||
extends Node
|
||||
|
||||
# The dedicated server's match loop (multiplayer-todo.md task 6.5).
|
||||
# The dedicated server's match loop (multiplayer-next.md task 6.5).
|
||||
#
|
||||
# THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs
|
||||
# a full match headless", but nothing in the product ever started a match:
|
||||
@@ -35,17 +35,27 @@ extends Node
|
||||
signal match_starting(arena_path: String, match_index: int)
|
||||
|
||||
const POLL_INTERVAL_MS := 250
|
||||
const ALLOCATED_WAIT := "WAIT"
|
||||
const ALLOCATED_READY := "READY"
|
||||
const ALLOCATED_CANCEL := "CANCEL"
|
||||
const ALLOCATED_START_WITH_BOTS := "START_WITH_BOTS"
|
||||
|
||||
var min_players := 1
|
||||
var start_countdown_seconds := 5.0
|
||||
var max_matches := 0 # 0 = run forever
|
||||
var rotation_mode := "sequential"
|
||||
var allocated_mode := false
|
||||
var allocated_playlist := ""
|
||||
var allocated_roster_size := 0
|
||||
var allocated_arena_path := ""
|
||||
var allocated_admission_armed := true
|
||||
|
||||
var matches_completed := 0
|
||||
var _countdown_started_ms := -1
|
||||
var _match_active := false
|
||||
var _next_poll_ms := 0
|
||||
var _shutting_down := false
|
||||
var _allocated_connect_started_ms := -1
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
@@ -58,7 +68,71 @@ func _process(_delta: float) -> void:
|
||||
if _match_active:
|
||||
_poll_match_end()
|
||||
else:
|
||||
if allocated_mode:
|
||||
_poll_allocated_match_start(now)
|
||||
else:
|
||||
_poll_match_start(now)
|
||||
|
||||
|
||||
func _poll_allocated_match_start(now: int) -> void:
|
||||
if not allocated_admission_armed:
|
||||
return
|
||||
if _allocated_connect_started_ms < 0:
|
||||
_allocated_connect_started_ms = now
|
||||
var connected := MatchNet.roster.size()
|
||||
var has_team_zero := false
|
||||
var has_team_one := false
|
||||
for info: MatchNet.PlayerInfo in MatchNet.roster.values():
|
||||
has_team_zero = has_team_zero or info.team == 0
|
||||
has_team_one = has_team_one or info.team == 1
|
||||
var action := allocated_initial_connect_action(allocated_playlist, now - _allocated_connect_started_ms, connected, allocated_roster_size, has_team_zero, has_team_one)
|
||||
if action == ALLOCATED_READY:
|
||||
_poll_match_start(now)
|
||||
return
|
||||
if action == ALLOCATED_CANCEL:
|
||||
var reason := "ranked_initial_connect_timeout" if allocated_playlist == "ranked" else "casual_initial_connect_ineligible"
|
||||
_cancel_allocated_no_show(reason, connected)
|
||||
return
|
||||
if action == ALLOCATED_START_WITH_BOTS:
|
||||
NetworkedMatch.server_bot_fill_override = true
|
||||
_poll_match_start(now)
|
||||
|
||||
|
||||
func arm_allocated_admission() -> void:
|
||||
allocated_admission_armed = true
|
||||
_allocated_connect_started_ms = -1
|
||||
|
||||
|
||||
static func allocated_initial_connect_action(playlist: String, elapsed_ms: int, connected: int, expected: int, has_team_zero: bool, has_team_one: bool) -> String:
|
||||
if elapsed_ms < 0 or connected < 0 or expected < 1:
|
||||
return ALLOCATED_CANCEL
|
||||
if playlist == "ranked":
|
||||
if expected != 6:
|
||||
return ALLOCATED_CANCEL
|
||||
if connected >= expected:
|
||||
return ALLOCATED_READY
|
||||
return ALLOCATED_CANCEL if elapsed_ms >= 30000 else ALLOCATED_WAIT
|
||||
if playlist == "casual":
|
||||
if expected < 2 or expected > 6:
|
||||
return ALLOCATED_CANCEL
|
||||
if connected >= expected:
|
||||
if expected == 6:
|
||||
return ALLOCATED_READY
|
||||
return ALLOCATED_START_WITH_BOTS if has_team_zero and has_team_one else ALLOCATED_CANCEL
|
||||
if elapsed_ms < 60000:
|
||||
return ALLOCATED_WAIT
|
||||
return ALLOCATED_START_WITH_BOTS if connected >= 2 and has_team_zero and has_team_one else ALLOCATED_CANCEL
|
||||
return ALLOCATED_CANCEL
|
||||
|
||||
func _cancel_allocated_no_show(reason: String, connected: int) -> void:
|
||||
if _shutting_down:
|
||||
return
|
||||
_shutting_down = true
|
||||
ServerLog.info("initial_connect_cancelled", {"reason": reason, "connected": connected, "expected": allocated_roster_size})
|
||||
MatchNet.broadcast_server_shutdown(reason)
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
# A match is over when the match scene is gone. NetworkedMatch returns both
|
||||
@@ -108,7 +182,7 @@ func _poll_match_start(now: int) -> void:
|
||||
|
||||
|
||||
func _start_match() -> void:
|
||||
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
|
||||
var arena_path := allocated_arena_path if allocated_mode and not allocated_arena_path.is_empty() else ArenaRegistry.path_for_match(matches_completed, rotation_mode)
|
||||
# The match scene picks its own arena at random by default. Handing it one
|
||||
# explicitly is what makes rotation a rotation rather than a coincidence.
|
||||
NetworkedMatch.server_arena_override = arena_path
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
class_name ServerResultClient
|
||||
extends Node
|
||||
|
||||
# The allocated server is the sole authority able to finish a match. Keep the
|
||||
# match in RESULTS until the control plane has durably acknowledged this exact,
|
||||
# idempotent payload: exiting first would strand the match in LIVE forever.
|
||||
|
||||
signal accepted
|
||||
signal retrying(http_code: int)
|
||||
|
||||
const RETRY_SECONDS := 1.0
|
||||
|
||||
var _base_url := ""
|
||||
var _workload_token := ""
|
||||
var _match_id := ""
|
||||
var _server_id := ""
|
||||
var _submitting := false
|
||||
|
||||
|
||||
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
|
||||
base_url = base_url.strip_edges().trim_suffix("/")
|
||||
workload_token = workload_token.strip_edges()
|
||||
if not valid_configuration(base_url, workload_token, match_id, server_id):
|
||||
return false
|
||||
_base_url = base_url
|
||||
_workload_token = workload_token
|
||||
_match_id = match_id
|
||||
_server_id = server_id
|
||||
return true
|
||||
|
||||
|
||||
func submit(team_0: int, team_1: int, integrity_state := "CERTIFIED") -> void:
|
||||
if _submitting or team_0 < 0 or team_1 < 0 or not integrity_state in ["CERTIFIED", "REVIEW"]:
|
||||
return
|
||||
_submitting = true
|
||||
var nonce := result_nonce(_match_id, _server_id, team_0, team_1, integrity_state)
|
||||
var key := "server-result-" + nonce
|
||||
var payload := {
|
||||
"match_id": _match_id,
|
||||
"result_nonce": nonce,
|
||||
"score": {"team_0": team_0, "team_1": team_1},
|
||||
"integrity_state": integrity_state,
|
||||
}
|
||||
while is_inside_tree():
|
||||
var response := await _send(payload, key)
|
||||
if response_is_accepted(int(response.get("code", 0))):
|
||||
_submitting = false
|
||||
accepted.emit()
|
||||
return
|
||||
retrying.emit(int(response.get("code", 0)))
|
||||
await get_tree().create_timer(RETRY_SECONDS).timeout
|
||||
_submitting = false
|
||||
|
||||
|
||||
func _send(payload: Dictionary, key: String) -> Dictionary:
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = 5.0
|
||||
add_child(request)
|
||||
var err := request.request("%s/v1/servers/%s/result" % [_base_url, _server_id.uri_encode()], [
|
||||
"Authorization: Bearer " + _workload_token,
|
||||
"Content-Type: application/json",
|
||||
"Idempotency-Key: " + key,
|
||||
], HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if err != OK:
|
||||
request.queue_free()
|
||||
return {"code": 0}
|
||||
var raw: Array = await request.request_completed
|
||||
request.queue_free()
|
||||
if int(raw[0]) != HTTPRequest.RESULT_SUCCESS:
|
||||
return {"code": 0}
|
||||
return {"code": int(raw[1])}
|
||||
|
||||
|
||||
static func result_nonce(match_id: String, server_id: String, team_0: int, team_1: int, integrity_state: String) -> String:
|
||||
# Result score is immutable once NetworkedMatch enters RESULTS. A deterministic
|
||||
# nonce makes retries after a lost response provably the same submission.
|
||||
return "result-" + (match_id + "\n" + server_id + "\n" + str(team_0) + "\n" + str(team_1) + "\n" + integrity_state).sha256_text()
|
||||
|
||||
|
||||
static func response_is_accepted(http_code: int) -> bool:
|
||||
# The documented endpoint acknowledges only after its serializable result
|
||||
# transaction commits. Do not treat a generic 2xx as proof of completion.
|
||||
return http_code == 202
|
||||
|
||||
|
||||
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
|
||||
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#") or base_url.contains("@"):
|
||||
return false
|
||||
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
|
||||
return false
|
||||
return match_id.length() >= 8 and server_id.length() >= 8 and not match_id.contains("/") and not server_id.contains("/")
|
||||
@@ -1,6 +1,10 @@
|
||||
extends Control
|
||||
|
||||
# Settings screen: player-facing video knobs on top of VideoSettings (the
|
||||
# Settings screen root, owning the Video tab and the shared Back button. The
|
||||
# Controls tab has its own script (controls_settings.gd) so this file stays
|
||||
# video-only; both tabs' state is committed in _on_back_pressed below.
|
||||
#
|
||||
# Video tab: player-facing video knobs on top of VideoSettings (the
|
||||
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
|
||||
# scale apply immediately since they're Viewport- or DisplayServer-wide;
|
||||
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
|
||||
@@ -48,6 +52,7 @@ var _populating := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
AudioManager.bind_tree_buttons(self)
|
||||
# An idle settings screen has no reason to render past the display's own
|
||||
# refresh rate; _on_back_pressed only returns to another capped menu, so
|
||||
# no uncap is needed there (contrast main_menu.gd's _leave_to_gameplay).
|
||||
@@ -204,6 +209,10 @@ func _mark_custom_if_user_driven() -> void:
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
VideoSettings.save()
|
||||
# Bindings are applied live as the player rebinds them (InputSettings.apply
|
||||
# runs on every change) but are only committed to disk here, matching how
|
||||
# the video knobs behave.
|
||||
InputSettings.save()
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
|
||||
+23
-11
@@ -15,7 +15,7 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
@export var vertical_thrust = 120.0 # Up/down thruster power
|
||||
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
|
||||
@export var max_speed = 35.0 # Maximum velocity
|
||||
@export var rotation_power = 20.0 # Angular thrust power
|
||||
@export var rotation_acceleration = 20.0 # Angular acceleration, rad/s^2, equal on all three axes (see apply_rotation_forces)
|
||||
@export var max_angular_speed = 3.0 # Maximum rotation speed
|
||||
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
|
||||
@export var angular_drag = 0.95 # Rotational drag
|
||||
@@ -145,7 +145,7 @@ func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3,
|
||||
_has_pending_teleport = true
|
||||
|
||||
|
||||
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
|
||||
# --- Netcode correction hooks (Phase 4; see MULTIPLAYER_SPEC.md §4.4) ---
|
||||
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
|
||||
# hook in _integrate_forces below is a no-op today.
|
||||
# Velocity delta from a soft correction, consumed once then cleared —
|
||||
@@ -197,6 +197,7 @@ signal thrust_changed(thrust_percent: float)
|
||||
signal angular_velocity_changed(angular_speed: float)
|
||||
signal heading_changed(heading_degrees: float)
|
||||
signal ball_contact(intensity: float, world_position: Vector3)
|
||||
signal wall_contact(intensity: float)
|
||||
|
||||
# Performance optimization - track last emitted values to avoid unnecessary signals
|
||||
var _last_speed: float = -1.0
|
||||
@@ -222,7 +223,7 @@ var _engine_lights: Array[OmniLight3D] = []
|
||||
# All rendered geometry (hull, canopy, engine cores/flames/lights, Nose,
|
||||
# TailFin) parents under this instead of the RigidBody3D directly, so a
|
||||
# future prediction correction (task 0.14) can offset the visual without
|
||||
# moving the collider — see multiplayer-todo.md task 0.2. CollisionShape3D
|
||||
# moving the collider — see multiplayer-next.md task 0.2. CollisionShape3D
|
||||
# and the controller child correctly stay on the body itself.
|
||||
@onready var visual: Node3D = $Visual
|
||||
|
||||
@@ -415,6 +416,8 @@ func is_turbo_active() -> bool:
|
||||
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body is StaticBody3D:
|
||||
wall_contact.emit(clampf(linear_velocity.length() / maxf(max_speed, 0.001), 0.0, 1.0))
|
||||
if not body is Ball:
|
||||
return
|
||||
var relative_speed := (linear_velocity - (body as Ball).linear_velocity).length()
|
||||
@@ -554,18 +557,27 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
||||
if rotation_input.length() < 0.01:
|
||||
return
|
||||
|
||||
# Apply torque for rotation - simple and effective
|
||||
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
|
||||
# Also: α = τ / I (angular acceleration = torque / moment of inertia)
|
||||
# Lower inertia = higher angular acceleration for same torque
|
||||
# Scaling each axis by its own inertia makes rotation_acceleration mean
|
||||
# exactly that — α, in rad/s² — so all three axes respond identically.
|
||||
# ship.tscn's inertia is Vector3(7, 1, 7): a flat torque across all three
|
||||
# axes therefore used to give yaw 7x the angular acceleration of pitch and
|
||||
# roll (172 deg/s vs 52 deg/s at steady state). That was an accident of the
|
||||
# inertia tensor rather than a design decision, and it read as "rotation is
|
||||
# sluggish except when turning".
|
||||
var torque = Vector3(
|
||||
rotation_input.x * rotation_power, # Pitch (rotation around X-axis)
|
||||
rotation_input.y * rotation_power, # Yaw (rotation around Y-axis)
|
||||
rotation_input.z * rotation_power # Roll (rotation around Z-axis)
|
||||
rotation_input.x * rotation_acceleration * inertia.x, # Pitch (local X)
|
||||
rotation_input.y * rotation_acceleration * inertia.y, # Yaw (local Y)
|
||||
rotation_input.z * rotation_acceleration * inertia.z # Roll (local Z)
|
||||
)
|
||||
|
||||
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
|
||||
state.apply_torque(torque)
|
||||
# apply_torque() is world-space, and the vector above is in the ship's own
|
||||
# frame, so it MUST be rotated by the hull's basis — exactly as thrust is
|
||||
# (see the -ship_basis.z term in apply_thrust_forces). Without this the
|
||||
# ship rotated about the world axes: roll input became pitch once the ship
|
||||
# had yawed 90 degrees, and both roll and pitch inverted at 180 degrees, so
|
||||
# the controls were correct flying up-field and backwards flying back.
|
||||
state.apply_torque(state.transform.basis * torque)
|
||||
|
||||
|
||||
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)
|
||||
|
||||
@@ -38,6 +38,10 @@ extends AIController3D
|
||||
# stone, matching ball_touch_cooldown_ticks's existing "stepping-stone, not
|
||||
# the objective" framing.
|
||||
@export_range(0.0, 1.0) var ball_touch_direction_floor := 0.3
|
||||
# Fraction of a touch payout shared with teammates. Zero preserves all
|
||||
# existing 1v1/curriculum reward functions; in teamplay the shared amount is
|
||||
# divided across teammates and never exceeds the touching ship's payout.
|
||||
@export_range(0.0, 1.0) var team_touch_credit_weight := 0.0
|
||||
@export var velocity_to_ball_weight := 0.02
|
||||
# Dense reward for approaching the ball *nose first* near the floor. Unlike
|
||||
# velocity_to_ball_weight, sideways/reverse closing velocity earns nothing:
|
||||
@@ -606,6 +610,12 @@ func _on_ship_body_entered(body: Node) -> void:
|
||||
if air_touch_bonus_weight > 0.0 and ball.global_position.y > AIR_TOUCH_HEIGHT:
|
||||
touch_payout += air_touch_bonus_weight * alignment
|
||||
reward += touch_payout
|
||||
if team_touch_credit_weight > 0.0 and not teammates.is_empty():
|
||||
var teammate_credit := team_touch_credit(touch_payout, team_touch_credit_weight, teammates.size())
|
||||
for teammate in teammates:
|
||||
var teammate_agent := teammate.get_node_or_null("ShipAIController") as ShipAIController
|
||||
if is_instance_valid(teammate_agent):
|
||||
teammate_agent.reward += teammate_credit
|
||||
_ticks_since_ball_touch = 0
|
||||
|
||||
# air_touch_fraction/productive_air_touch_fraction (see get_info) share
|
||||
@@ -617,3 +627,9 @@ func _on_ship_body_entered(body: Node) -> void:
|
||||
_air_touches += 1
|
||||
if alignment >= PRODUCTIVE_AIR_TOUCH_ALIGNMENT:
|
||||
_productive_air_touches += 1
|
||||
|
||||
|
||||
static func team_touch_credit(touch_payout: float, weight: float, teammate_count: int) -> float:
|
||||
if touch_payout <= 0.0 or weight <= 0.0 or teammate_count <= 0:
|
||||
return 0.0
|
||||
return touch_payout * clampf(weight, 0.0, 1.0) / teammate_count
|
||||
|
||||
@@ -97,15 +97,22 @@ func _connect_target() -> void:
|
||||
return
|
||||
if not target.ball_contact.is_connected(_on_target_ball_contact):
|
||||
target.ball_contact.connect(_on_target_ball_contact)
|
||||
if not target.wall_contact.is_connected(_on_target_wall_contact):
|
||||
target.wall_contact.connect(_on_target_wall_contact)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
AudioManager.stop_engine()
|
||||
if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact):
|
||||
target.ball_contact.disconnect(_on_target_ball_contact)
|
||||
if is_instance_valid(target) and target.wall_contact.is_connected(_on_target_wall_contact):
|
||||
target.wall_contact.disconnect(_on_target_wall_contact)
|
||||
|
||||
|
||||
func _input(event):
|
||||
if event.is_action_pressed("ui_accept"): # Enter key
|
||||
# A dedicated action rather than ui_accept, so the camera toggle is
|
||||
# rebindable and A stays purely a menu-confirm button. Space / R3.
|
||||
if event.is_action_pressed("toggle_ball_cam"):
|
||||
ball_cam_enabled = !ball_cam_enabled
|
||||
camera_mode_changed.emit(ball_cam_enabled)
|
||||
|
||||
@@ -218,6 +225,8 @@ func _smooth_look_at(point: Vector3, delta: float) -> void:
|
||||
func _update_speed_feel(delta: float) -> void:
|
||||
var feel_t := 1.0 - exp(-feel_smoothing * delta)
|
||||
var turbo_target := 1.0 if target.is_turbo_active() else 0.0
|
||||
var engine_action := target.get_current_action_copy()
|
||||
AudioManager.set_engine_state(maxf(engine_action.thrust.z, 0.0), turbo_target > 0.5)
|
||||
_turbo_blend = lerpf(_turbo_blend, turbo_target, feel_t)
|
||||
_speed_blend = lerpf(_speed_blend, target.get_speed_ratio(), feel_t)
|
||||
var target_fov := base_fov + _speed_blend * speed_fov_add + _turbo_blend * turbo_fov_kick
|
||||
@@ -240,6 +249,10 @@ func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void
|
||||
impact_feedback.emit(intensity)
|
||||
|
||||
|
||||
func _on_target_wall_contact(intensity: float) -> void:
|
||||
AudioManager.play_wall_scrape(intensity)
|
||||
|
||||
|
||||
func _apply_shake(delta: float) -> void:
|
||||
if _shake_strength <= 0.001:
|
||||
_shake_strength = 0.0
|
||||
|
||||
@@ -4,7 +4,7 @@ class_name SimConstants
|
||||
# constant derived from "60 Hz" (Ship._tick_scaled's decay reference,
|
||||
# reaction_ticks' export range, TrainingMode.TICKS_PER_SIM_SECOND) reads this
|
||||
# instead of restating the literal, so changing it changes every derived
|
||||
# constant coherently — see multiplayer-todo.md §5.6 on why a future 120 Hz
|
||||
# constant coherently — see MULTIPLAYER_SPEC.md §5.6 on why a future 120 Hz
|
||||
# simulation needs to be a config change plus a retrain, not a protocol
|
||||
# rewrite hunting down bare 60s.
|
||||
#
|
||||
|
||||
@@ -40,3 +40,53 @@ static func initialize() -> Dictionary:
|
||||
if result is Dictionary and bool(result.get("status", false)):
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
|
||||
|
||||
|
||||
# Web-API auth ticket acquisition (task 7.6). The control plane exchanges this
|
||||
# ticket with Valve's publisher API for a verified Steam identity; the client
|
||||
# never chooses its own identity, which is what makes this the fix for slot
|
||||
# reclaim being keyed on a display name.
|
||||
#
|
||||
# GodotSteam delivers the ticket asynchronously through the
|
||||
# `get_auth_ticket_for_web_api` signal, because the ticket is not usable until
|
||||
# Steam has confirmed it with its backend. Requesting one and reading the
|
||||
# return value alone yields a handle, not a ticket.
|
||||
#
|
||||
# Everything here is called dynamically so stock Godot, which has no GodotSteam
|
||||
# symbols, can still parse and run the project.
|
||||
const WEB_API_IDENTITY := "cosmicclash"
|
||||
|
||||
|
||||
static func supports_web_api_ticket() -> bool:
|
||||
if not is_runtime_available():
|
||||
return false
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
return steam.has_signal("get_auth_ticket_for_web_api") and steam.has_method("getAuthTicketForWebApi")
|
||||
|
||||
|
||||
# Returns the request handle, or 0 when unavailable. The caller must await the
|
||||
# `get_auth_ticket_for_web_api` signal for the ticket itself.
|
||||
static func request_web_api_ticket() -> int:
|
||||
if not supports_web_api_ticket():
|
||||
return 0
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
var handle = steam.call("getAuthTicketForWebApi", WEB_API_IDENTITY)
|
||||
return int(handle) if handle is int or handle is float else 0
|
||||
|
||||
|
||||
static func cancel_web_api_ticket(handle: int) -> void:
|
||||
if handle <= 0 or not is_runtime_available():
|
||||
return
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
if steam.has_method("cancelAuthTicket"):
|
||||
steam.call("cancelAuthTicket", handle)
|
||||
|
||||
|
||||
# GodotSteam hands back raw ticket bytes; the Web API expects them hex encoded.
|
||||
static func encode_web_api_ticket(buffer: PackedByteArray) -> String:
|
||||
if buffer.is_empty():
|
||||
return ""
|
||||
var encoded := ""
|
||||
for byte in buffer:
|
||||
encoded += "%02x" % int(byte)
|
||||
return encoded
|
||||
|
||||
@@ -70,6 +70,11 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
# a real goal and ships start low behind/lateral to it, so a useful touch is
|
||||
# naturally reinforced by the existing goal-directed ball rewards.
|
||||
@export_range(0.0, 1.0) var air_intercept_chance := 0.0
|
||||
# Wall-play and rebound starts are separate: wall-play begins beside a wall
|
||||
# with the ball travelling inward, while rebound begins just before an
|
||||
# outward wall impact. Both default off to preserve existing distributions.
|
||||
@export_range(0.0, 1.0) var wall_play_chance := 0.0
|
||||
@export_range(0.0, 1.0) var rebound_chance := 0.0
|
||||
# Ground-start branch for the generation-5 handling stage: ships spawn level
|
||||
# and resting on the floor with a low, floor-level ball. Every other branch
|
||||
# samples ship Y uniformly across the full 18m volume (see _random_position),
|
||||
@@ -82,8 +87,8 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
|
||||
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
|
||||
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
|
||||
# MAX_OPPONENTS. Plumbing only for this pass — no 2v2+ curriculum/reward
|
||||
# design has been done, so a run above 1 is untested territory.
|
||||
# MAX_OPPONENTS. Team-credit reward and paired 2v2 evaluation are opt-in;
|
||||
# no teamplay training stage is enabled by default.
|
||||
@export_range(1, 5) var team_size: int = 1
|
||||
|
||||
# Placement bounds for randomized episode starts, derived from the standard
|
||||
@@ -100,6 +105,9 @@ const FIELD_MIN_Y := 1.5
|
||||
# spawning interpenetrated with it.
|
||||
const GROUND_START_Y := 0.35
|
||||
const GROUND_START_BALL_Y := 0.55
|
||||
const WALL_PLAY_BALL_CLEARANCE := 1.0
|
||||
const REBOUND_BALL_CLEARANCE := 0.75
|
||||
const WALL_PLAY_SPEED := Vector2(4.0, 9.0)
|
||||
const FIELD_MAX_Y := ArenaBoundary.INNER_HEIGHT - SPAWN_INSET
|
||||
# The corner curves reach at most their chord plane |x| + |z| = INNER_HALF_X
|
||||
# + INNER_HALF_Z - CORNER_RADIUS; spawns keep the same SPAWN_INSET clearance
|
||||
@@ -141,6 +149,7 @@ var _eval_goals := {0: 0, 1: 0}
|
||||
var _eval_draws := 0
|
||||
var _eval_episodes_done := 0
|
||||
var _episode_ticks := 0
|
||||
var _eval_team_size := 1
|
||||
|
||||
# Curriculum mode state (see _parse_curriculum_args). "self_play" (default)
|
||||
# is today's only historical behaviour: both ships are live trainees sharing
|
||||
@@ -176,11 +185,12 @@ func _start() -> void:
|
||||
spawn_ball()
|
||||
if _eval:
|
||||
for team in [0, 1]:
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = _eval_models[team]
|
||||
bot.allow_vertical = _eval_allow_vertical[team]
|
||||
bot.allow_pitch_roll = _eval_allow_pitch_roll[team]
|
||||
spawn_ship(team, 0, bot)
|
||||
for spawn_index in _eval_team_size:
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = _eval_models[team]
|
||||
bot.allow_vertical = _eval_allow_vertical[team]
|
||||
bot.allow_pitch_roll = _eval_allow_pitch_roll[team]
|
||||
spawn_ship(team, spawn_index, bot)
|
||||
return
|
||||
|
||||
var team0_ships: Array[Ship] = []
|
||||
@@ -248,6 +258,7 @@ func _parse_eval_args() -> void:
|
||||
_eval_models[0] = args["eval_model_a"]
|
||||
_eval_models[1] = args["eval_model_b"]
|
||||
_eval_episodes = int(args.get("eval_episodes", str(_eval_episodes)))
|
||||
_eval_team_size = clampi(int(args.get("eval_team_size", str(_eval_team_size))), 1, 2)
|
||||
_eval_allow_vertical[0] = _typed_like(args.get("eval_allow_vertical_a", "true"), true)
|
||||
_eval_allow_vertical[1] = _typed_like(args.get("eval_allow_vertical_b", "true"), true)
|
||||
_eval_allow_pitch_roll[0] = _typed_like(args.get("eval_allow_pitch_roll_a", "true"), true)
|
||||
@@ -261,11 +272,12 @@ const TRAINING_MODE_OVERRIDES := [
|
||||
"goal_reward", "draw_penalty", "kickoff_state_chance",
|
||||
"ball_near_goal_chance", "attack_goal_bias", "air_drill_chance",
|
||||
"air_intercept_chance", "ground_start_chance", "team_size",
|
||||
"wall_play_chance", "rebound_chance",
|
||||
]
|
||||
# ShipAIController @export names a curriculum run may override, read as
|
||||
# --ai_<name>=<value> to avoid colliding with the names above.
|
||||
const SHIP_AI_OVERRIDES := [
|
||||
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor",
|
||||
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor", "team_touch_credit_weight",
|
||||
"velocity_to_ball_weight", "ball_velocity_to_goal_weight", "ball_distance_penalty",
|
||||
"forward_velocity_to_ball_weight", "air_approach_weight", "air_touch_bonus_weight", "wall_contact_penalty", "tilt_penalty",
|
||||
"ground_tilt_penalty", "non_forward_penalty", "grounded_upright_reward",
|
||||
@@ -289,8 +301,8 @@ func _parse_curriculum_args() -> void:
|
||||
if args.has(name):
|
||||
set(name, _typed_like(args[name], get(name)))
|
||||
var start_probability := kickoff_state_chance + ball_near_goal_chance \
|
||||
+ air_drill_chance + air_intercept_chance
|
||||
start_probability += ground_start_chance
|
||||
+ air_drill_chance + air_intercept_chance + ground_start_chance \
|
||||
+ wall_play_chance + rebound_chance
|
||||
if start_probability > 1.0:
|
||||
push_error("TrainingMode: episode-start probabilities sum to %.3f (> 1.0)" % start_probability)
|
||||
|
||||
@@ -323,6 +335,7 @@ func _ai_default(name: String) -> Variant:
|
||||
"ball_touch_reward": return 0.4
|
||||
"ball_touch_cooldown_ticks": return 60
|
||||
"ball_touch_direction_floor": return 0.3
|
||||
"team_touch_credit_weight": return 0.0
|
||||
"velocity_to_ball_weight": return 0.02
|
||||
"forward_velocity_to_ball_weight": return 0.0
|
||||
"air_approach_weight": return 0.0
|
||||
@@ -451,6 +464,12 @@ func _reset_episode() -> void:
|
||||
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
|
||||
+ air_intercept_chance + ground_start_chance:
|
||||
_place_ground_start()
|
||||
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
|
||||
+ air_intercept_chance + ground_start_chance + wall_play_chance:
|
||||
_place_wall_state(false)
|
||||
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
|
||||
+ air_intercept_chance + ground_start_chance + wall_play_chance + rebound_chance:
|
||||
_place_wall_state(true)
|
||||
else:
|
||||
_place_ships_random()
|
||||
_place_ball_random()
|
||||
@@ -560,6 +579,42 @@ func _place_ground_start() -> void:
|
||||
)
|
||||
|
||||
|
||||
# Wall-play/rebound states (see wall_play_chance/rebound_chance). The ball is
|
||||
# placed against a side wall, never in a corner or goal sensor. A wall-play
|
||||
# state starts after the bounce and sends the ball inward; a rebound state
|
||||
# starts before contact and sends it outward so the physics engine supplies
|
||||
# the reflected trajectory. Ships use the ordinary randomized placement, so
|
||||
# the policy has to read the wall/rebound context instead of memorising a
|
||||
# fixed attacker spawn.
|
||||
func _place_wall_state(rebound: bool) -> void:
|
||||
_place_ships_random()
|
||||
var side := -1.0 if randf() < 0.5 else 1.0
|
||||
var clearance := REBOUND_BALL_CLEARANCE if rebound else WALL_PLAY_BALL_CLEARANCE
|
||||
var ball_position := Vector3(
|
||||
side * (ArenaBoundary.INNER_HALF_X - clearance),
|
||||
randf_range(1.0, minf(FIELD_MAX_Y, 7.0)),
|
||||
randf_range(-FIELD_HALF_Z, FIELD_HALF_Z)
|
||||
)
|
||||
var velocity := wall_state_velocity(
|
||||
rebound, side, randf_range(WALL_PLAY_SPEED.x, WALL_PLAY_SPEED.y),
|
||||
randf_range(-0.15, 0.15), randf_range(-0.15, 0.15)
|
||||
)
|
||||
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), velocity, Vector3.ZERO)
|
||||
|
||||
|
||||
# Pure geometry seam for adversarial tests. `side` identifies the selected
|
||||
# wall (+1 or -1); a wall-play vector points into the field and a rebound
|
||||
# vector points into that wall. Normalize the perturbed normal before applying
|
||||
# speed so random tangential components cannot accidentally change the speed
|
||||
# distribution between the two state types.
|
||||
static func wall_state_velocity(rebound: bool, side: float, speed: float, vertical: float, lateral: float) -> Vector3:
|
||||
if speed < 0.0:
|
||||
return Vector3.ZERO
|
||||
var wall_side := -1.0 if side < 0.0 else 1.0
|
||||
var toward_field := Vector3(-wall_side, vertical, lateral).normalized()
|
||||
return (-toward_field if rebound else toward_field) * speed
|
||||
|
||||
|
||||
# Air-intercept drill geometry. These six ranges are not free tuning knobs —
|
||||
# together they decide whether the drill is solvable at all, and the original
|
||||
# values made it arithmetically impossible (see the Round 9 note in
|
||||
|
||||
@@ -24,7 +24,7 @@ extends Node
|
||||
# independently of stretch mode, since it scales the 3D viewport's own internal
|
||||
# resolution before this blit rather than the window itself. Task 0.15b also
|
||||
# found an unexplained ~6% non-uniform width scaling on this project's one
|
||||
# tested (Mac/Retina) machine — see multiplayer-todo.md §5.5.1 — which needs
|
||||
# tested (Mac/Retina) machine — see MULTIPLAYER_SPEC.md §5.5.1 — which needs
|
||||
# understanding before stretch mode is touched, not blindly carrying into a
|
||||
# resolution-dependent change.
|
||||
#
|
||||
@@ -49,7 +49,7 @@ const SETTINGS_PATH := "user://settings.cfg"
|
||||
# preset -> bundle applied to the individual fields below. CUSTOM has no
|
||||
# bundle: selecting it just stops future preset changes from overwriting
|
||||
# whatever the individual fields currently hold. Task 0.15b's measured
|
||||
# per-effect costs (multiplayer-todo.md §5.5.1) were too noisy to rank these
|
||||
# per-effect costs (MULTIPLAYER_SPEC.md §5.5.1) were too noisy to rank these
|
||||
# against each other, so each rung is "meaningfully fewer full-screen passes
|
||||
# than the one above it" rather than a precisely tuned ladder.
|
||||
const PRESET_BUNDLES := {
|
||||
@@ -78,7 +78,7 @@ var shadows_enabled: bool = true
|
||||
var glow_enabled: bool = true
|
||||
|
||||
# FXAA alone, not MSAA_FXAA: 4x MSAA *and* FXAA stacked is redundant blur for
|
||||
# most scenes and costs more than either alone (see multiplayer-todo.md 0.19).
|
||||
# most scenes and costs more than either alone (see multiplayer-next.md 0.19).
|
||||
var aa_mode: AAMode = AAMode.FXAA
|
||||
var glow_scale: float = 1.0
|
||||
var brightness: float = 1.0
|
||||
@@ -252,7 +252,7 @@ func apply_fps_cap() -> void:
|
||||
# Called once by each arena's _ready() (and again on settings_changed, so an
|
||||
# already-loaded arena updates live) to fold the user's glow/brightness
|
||||
# preference into that arena's own baked Environment tuning, and to gate the
|
||||
# preset-controlled full-screen passes (§5.5 of multiplayer-todo.md).
|
||||
# preset-controlled full-screen passes (§5.5 of MULTIPLAYER_SPEC.md).
|
||||
func apply_to_environment(env: Environment) -> void:
|
||||
if env == null:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
extends SceneTree
|
||||
|
||||
# Headless smoke for the Agones SDK bridge. Run by
|
||||
# scripts/verify_multiplayer_local.sh:
|
||||
# godot --headless --path Game --script res://tests/agones_sdk_smoke.gd
|
||||
#
|
||||
# Phase 1 drives each REST call directly. Phase 2 covers what phase 1 cannot:
|
||||
# that start_health() produces a *repeating* ping. That is the property Agones
|
||||
# actually enforces -- one ping proves nothing, because the Fleet recycles any
|
||||
# GameServer that stops pinging for periodSeconds * failureThreshold -- and its
|
||||
# absence is what silently recycled every allocated server.
|
||||
|
||||
const ServerControlScript = preload("res://scripts/server_control.gd")
|
||||
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
|
||||
const PORT := 18081
|
||||
const HEALTH_PORT := 18082
|
||||
# start_health() pings every 2s, so three seconds must contain at least two.
|
||||
const HEALTH_OBSERVATION_SECONDS := 3.0
|
||||
const MINIMUM_EXPECTED_PINGS := 2
|
||||
|
||||
|
||||
# Counting stand-in for the Agones sidecar. ServerControl answers /health but
|
||||
# cannot report how often it was called, and asserting repetition is the whole
|
||||
# point here, so this counts rather than changing production code for a test.
|
||||
class CountingSidecar extends Node:
|
||||
var health_pings := 0
|
||||
var _listener := TCPServer.new()
|
||||
var _peers: Array = []
|
||||
|
||||
func start(port: int) -> Error:
|
||||
return _listener.listen(port, "127.0.0.1")
|
||||
|
||||
func stop() -> void:
|
||||
_listener.stop()
|
||||
for peer in _peers:
|
||||
if is_instance_valid(peer):
|
||||
peer.disconnect_from_host()
|
||||
_peers.clear()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
while _listener.is_connection_available():
|
||||
_peers.append(_listener.take_connection())
|
||||
for i in range(_peers.size() - 1, -1, -1):
|
||||
var peer: StreamPeerTCP = _peers[i]
|
||||
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
_peers.remove_at(i)
|
||||
continue
|
||||
var available := peer.get_available_bytes()
|
||||
if available <= 0:
|
||||
continue
|
||||
var request := peer.get_utf8_string(available)
|
||||
if "\r\n\r\n" not in request:
|
||||
continue
|
||||
if request.begins_with("POST /health"):
|
||||
health_pings += 1
|
||||
var body := "{}"
|
||||
peer.put_data(("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [body.length(), body]).to_utf8_buffer())
|
||||
peer.disconnect_from_host()
|
||||
_peers.remove_at(i)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
if not await _direct_calls_smoke():
|
||||
quit(1)
|
||||
return
|
||||
if not await _repeating_health_smoke():
|
||||
quit(1)
|
||||
return
|
||||
print("Agones SDK smoke passed")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _direct_calls_smoke() -> bool:
|
||||
var fake_sidecar = ServerControlScript.new()
|
||||
root.add_child(fake_sidecar)
|
||||
if fake_sidecar.start(PORT) != OK:
|
||||
printerr("fake sidecar failed to bind")
|
||||
return false
|
||||
fake_sidecar.set_process_ready(true)
|
||||
var sdk = AgonesSDKScript.new()
|
||||
root.add_child(sdk)
|
||||
if not sdk.configure_for_testing("http://127.0.0.1:%d" % PORT):
|
||||
printerr("SDK test configuration failed")
|
||||
return false
|
||||
await process_frame
|
||||
var health_status := await sdk.health()
|
||||
var ready_status := await sdk.mark_ready()
|
||||
var annotation_status := await sdk.set_annotation("match", "result")
|
||||
var shutdown_status := await sdk.shutdown()
|
||||
fake_sidecar.stop()
|
||||
fake_sidecar.queue_free()
|
||||
sdk.queue_free()
|
||||
if health_status != 200 or ready_status != 200 or annotation_status < 400 or shutdown_status < 400:
|
||||
printerr("Agones SDK smoke statuses health=%d ready=%d annotation=%d shutdown=%d" % [health_status, ready_status, annotation_status, shutdown_status])
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _repeating_health_smoke() -> bool:
|
||||
var sidecar := CountingSidecar.new()
|
||||
root.add_child(sidecar)
|
||||
if sidecar.start(HEALTH_PORT) != OK:
|
||||
printerr("counting sidecar failed to bind")
|
||||
return false
|
||||
|
||||
# Configure before parenting and let the node arm its own timer on _ready(),
|
||||
# which is exactly how server_boot.gd wires it in an allocated pod.
|
||||
var sdk = AgonesSDKScript.new()
|
||||
if not sdk.configure_for_testing("http://127.0.0.1:%d" % HEALTH_PORT):
|
||||
printerr("health SDK configuration failed")
|
||||
return false
|
||||
if sdk.start_health():
|
||||
printerr("start_health() reported success while the node was outside the tree")
|
||||
return false
|
||||
root.add_child(sdk)
|
||||
await process_frame
|
||||
if not sdk.health_is_running():
|
||||
printerr("health loop did not arm once the node entered the tree")
|
||||
return false
|
||||
|
||||
await create_timer(HEALTH_OBSERVATION_SECONDS).timeout
|
||||
var observed := sidecar.health_pings
|
||||
sdk.stop_health()
|
||||
sidecar.stop()
|
||||
sdk.queue_free()
|
||||
sidecar.queue_free()
|
||||
if observed < MINIMUM_EXPECTED_PINGS:
|
||||
printerr("Agones health pings in %.1fs = %d, want at least %d; the health loop is not repeating" % [HEALTH_OBSERVATION_SECONDS, observed, MINIMUM_EXPECTED_PINGS])
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1,56 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
|
||||
|
||||
|
||||
func test_sdk_requires_loopback_sidecar_url() -> void:
|
||||
var sdk = AgonesSDKScript.new()
|
||||
assert_true(not sdk.configure_for_testing("https://agones.example"), "remote sidecar URL is rejected")
|
||||
assert_true(not sdk.is_available(), "rejected sidecar is unavailable")
|
||||
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "loopback sidecar URL is accepted")
|
||||
assert_true(sdk.is_available(), "accepted sidecar is available")
|
||||
sdk.queue_free()
|
||||
|
||||
|
||||
func test_annotation_validation_rejects_header_injection_and_oversized_values() -> void:
|
||||
assert_true(AgonesSDKScript.annotation_is_valid("match", "result"), "ordinary annotation is accepted")
|
||||
assert_true(not AgonesSDKScript.annotation_is_valid("bad\nkey", "value"), "annotation key newline is rejected")
|
||||
assert_true(not AgonesSDKScript.annotation_is_valid("key", "bad\rvalue"), "annotation value newline is rejected")
|
||||
assert_true(not AgonesSDKScript.annotation_is_valid("key", "x".repeat(4097)), "oversized annotation is rejected")
|
||||
|
||||
|
||||
# Regression: every allocated GameServer reached Ready and was then recycled by
|
||||
# Agones ~20s later, because start_health() armed a Timer on a node that was
|
||||
# never parented. A Timer only ticks inside the SceneTree, so the process
|
||||
# reported healthy while sending no pings at all, and nothing said so.
|
||||
#
|
||||
# These are deliberately synchronous: test_runner.gd calls test methods without
|
||||
# awaiting, so anything needing a live tree or an HTTP round trip belongs in
|
||||
# tests/agones_sdk_smoke.gd instead. What is asserted here is the contract that
|
||||
# makes the silent case impossible.
|
||||
func test_start_health_refuses_when_not_configured() -> void:
|
||||
var sdk = AgonesSDKScript.new()
|
||||
assert_true(not sdk.start_health(), "health cannot start before a sidecar URL is known")
|
||||
assert_true(not sdk.health_is_running(), "no timer is armed without configuration")
|
||||
sdk.queue_free()
|
||||
|
||||
|
||||
func test_start_health_reports_failure_when_outside_the_tree() -> void:
|
||||
# The exact shape of the production bug: configured, so is_available() is
|
||||
# true and the node looks ready to work, but unparented.
|
||||
var sdk = AgonesSDKScript.new()
|
||||
assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "fixture configures")
|
||||
assert_true(sdk.is_available(), "an unparented node still reports available")
|
||||
assert_true(not sdk.start_health(), "start_health() must not claim success outside the tree")
|
||||
assert_true(not sdk.health_is_running(), "no health loop is running outside the tree")
|
||||
sdk.queue_free()
|
||||
|
||||
|
||||
func test_health_is_not_running_until_a_timer_exists() -> void:
|
||||
# health_is_running() is what a caller should trust, rather than
|
||||
# is_available(), which only says a URL was parsed.
|
||||
var sdk = AgonesSDKScript.new()
|
||||
assert_true(not sdk.health_is_running(), "a fresh SDK is not pinging")
|
||||
sdk.configure_for_testing("http://127.0.0.1:9358")
|
||||
assert_true(not sdk.health_is_running(), "configuration alone does not start pinging")
|
||||
sdk.queue_free()
|
||||
@@ -0,0 +1,36 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
||||
|
||||
|
||||
func test_assignment_projection_accepts_verified_enet_manifest() -> void:
|
||||
var assignment := AssignmentState.new()
|
||||
assert_true(assignment.apply({"match_id": "match_1234567890", "server_id": "server_123456789", "player_id": "player_123456789", "slot": 2, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}, "player_123456789"), "valid assignment applies")
|
||||
assert_true(assignment.available, "assignment becomes available only after validation")
|
||||
assert_eq(assignment.transport, "enet", "transport is explicit")
|
||||
assert_eq(assignment.slot, 2, "slot is preserved")
|
||||
assert_eq(assignment.endpoint, "127.0.0.1:30001", "endpoint is preserved")
|
||||
|
||||
|
||||
func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> void:
|
||||
var assignment := AssignmentState.new()
|
||||
var valid := {"match_id": "match_1234567890", "server_id": "server_123456789", "player_id": "player_123456789", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}
|
||||
var out_of_range := valid.duplicate()
|
||||
out_of_range["slot"] = 6
|
||||
assert_true(not assignment.apply(out_of_range), "out-of-range slot is rejected")
|
||||
var short_id := valid.duplicate()
|
||||
short_id["match_id"] = "match-1"
|
||||
assert_true(not assignment.apply(short_id), "short opaque assignment id is rejected")
|
||||
var fractional_slot := valid.duplicate()
|
||||
fractional_slot["slot"] = 1.5
|
||||
assert_true(not assignment.apply(fractional_slot), "fractional slot is rejected")
|
||||
var fractional_protocol := valid.duplicate()
|
||||
fractional_protocol["protocol_version"] = 1.5
|
||||
assert_true(not assignment.apply(fractional_protocol), "fractional protocol version is rejected")
|
||||
assert_true(not assignment.available, "invalid assignment is not exposed")
|
||||
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "udp", "join_authorisation": "signed"}), "unknown transport is rejected")
|
||||
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": ""}), "empty authorisation is rejected")
|
||||
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-2", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "wrong player assignment is rejected")
|
||||
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2000-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "expired assignment is rejected")
|
||||
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "endpoint": "127.0.0.1", "join_authorisation": "signed"}, "player-1"), "unsafe endpoint is rejected")
|
||||
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "not-a-timestamp", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}, "player-1"), "malformed assignment expiry is rejected")
|
||||
@@ -0,0 +1,46 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const AudioManager = preload("res://scripts/audio_manager.gd")
|
||||
|
||||
|
||||
func test_audio_intensity_fails_closed_and_clamps() -> void:
|
||||
assert_eq(AudioManager.clamp_intensity(-1.0), 0.0, "negative impact is silent")
|
||||
assert_eq(AudioManager.clamp_intensity(INF), 0.0, "infinite impact is silent")
|
||||
assert_eq(AudioManager.clamp_intensity(0.5), 0.5, "normal impact is retained")
|
||||
assert_eq(AudioManager.clamp_intensity(4.0), 1.0, "oversized impact is capped")
|
||||
|
||||
|
||||
func test_countdown_frequency_has_bounded_monotonic_mapping() -> void:
|
||||
assert_eq(AudioManager.countdown_frequency(0), 495.0, "zero uses the first safe tone")
|
||||
assert_eq(AudioManager.countdown_frequency(3), 605.0, "countdown tone is deterministic")
|
||||
assert_eq(AudioManager.countdown_frequency(99), 935.0, "large countdown values are capped")
|
||||
assert_true(AudioManager.countdown_frequency(2) < AudioManager.countdown_frequency(3), "countdown tones rise predictably")
|
||||
|
||||
|
||||
func test_button_binding_is_idempotent() -> void:
|
||||
var manager := AudioManager.new()
|
||||
var button := Button.new()
|
||||
manager.bind_button(button)
|
||||
manager.bind_button(button)
|
||||
assert_eq(button.pressed.get_connections().size(), 1, "UI click hook is not duplicated")
|
||||
button.free()
|
||||
manager.free()
|
||||
|
||||
|
||||
func test_engine_mix_is_bounded_and_turbo_is_audible() -> void:
|
||||
assert_eq(AudioManager.engine_pitch(-1.0, false), 0.75, "negative thrust uses the idle pitch")
|
||||
assert_true(AudioManager.engine_pitch(1.0, true) > AudioManager.engine_pitch(1.0, false), "turbo raises engine pitch")
|
||||
assert_true(AudioManager.engine_volume(1.0, true) > AudioManager.engine_volume(1.0, false), "turbo raises engine volume")
|
||||
assert_true(AudioManager.engine_volume(100.0, true) <= 0.1, "engine volume remains bounded")
|
||||
|
||||
|
||||
func test_turbo_state_is_only_a_rising_edge_for_the_engine_cue() -> void:
|
||||
assert_true(AudioManager.should_play_turbo_cue(false, true, 0.8), "turbo engagement emits a cue")
|
||||
assert_true(not AudioManager.should_play_turbo_cue(true, true, 0.8), "held turbo does not retrigger")
|
||||
assert_true(not AudioManager.should_play_turbo_cue(false, true, 0.0), "turbo at idle thrust is silent")
|
||||
assert_true(not AudioManager.should_play_turbo_cue(false, false, 0.8), "ordinary thrust emits no turbo cue")
|
||||
|
||||
|
||||
func test_wall_scrape_intensity_reuses_the_same_safe_bounds() -> void:
|
||||
assert_eq(AudioManager.clamp_intensity(-2.0), 0.0, "reverse wall intensity is silent")
|
||||
assert_eq(AudioManager.clamp_intensity(2.0), 1.0, "wall intensity is capped")
|
||||
@@ -0,0 +1,37 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const LeaseClient = preload("res://scripts/connection_lease_client.gd")
|
||||
|
||||
|
||||
func test_connection_lease_response_classification_is_fail_closed() -> void:
|
||||
var success_body := JSON.stringify({"generation": 2}).to_utf8_buffer()
|
||||
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, success_body), {"status": "claimed", "generation": 2}, "exact next generation is accepted")
|
||||
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer())["status"], "rejected", "skipped generation is rejected")
|
||||
assert_eq(LeaseClient.classify_response("connect", 0, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer(), true), {"status": "claimed", "generation": 3}, "a fresh process accepts a durable recovery generation")
|
||||
assert_eq(LeaseClient.classify_response("connect", 0, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": 3}).to_utf8_buffer())["status"], "rejected", "queued outage reconciliation cannot skip generations")
|
||||
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 200, JSON.stringify({"generation": "2"}).to_utf8_buffer())["status"], "rejected", "string generation is not coerced")
|
||||
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_CANT_CONNECT, 0, PackedByteArray())["status"], "unavailable", "transport outage permits bounded local fallback")
|
||||
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 503, PackedByteArray())["status"], "unavailable", "service outage permits bounded local fallback")
|
||||
assert_eq(LeaseClient.classify_response("connect", 1, HTTPRequest.RESULT_SUCCESS, 409, PackedByteArray())["status"], "rejected", "durable conflict is terminal")
|
||||
assert_eq(LeaseClient.classify_response("disconnect", 2, HTTPRequest.RESULT_SUCCESS, 204, PackedByteArray()), {"status": "claimed", "generation": 2}, "disconnect acknowledgement preserves exact generation")
|
||||
|
||||
|
||||
func test_connection_lease_configuration_and_keys_are_bound() -> void:
|
||||
assert_true(LeaseClient.valid_configuration("https://control.invalid", "workload-token", "match-1234567890", "server-123456789"), "valid workload configuration is accepted")
|
||||
assert_true(not LeaseClient.valid_configuration("https://control.invalid?token=leak", "workload-token", "match-1234567890", "server-123456789"), "query-bearing endpoint is rejected")
|
||||
assert_true(not LeaseClient.valid_configuration("https://control@evil.invalid", "workload-token", "match-1234567890", "server-123456789"), "userinfo-bearing endpoint is rejected")
|
||||
assert_true(not LeaseClient.valid_configuration("https://control.invalid", "bad\ntoken", "match-1234567890", "server-123456789"), "header injection is rejected")
|
||||
var initial := LeaseClient.event_key("match-123456789", "player-12345678", "connect", 0)
|
||||
assert_true(initial != LeaseClient.event_key("match-123456789", "player-12345678", "disconnect", 1), "operation and generation bind the key")
|
||||
assert_true(initial != LeaseClient.event_key("match-000000000", "player-12345678", "connect", 0), "match identity binds the key")
|
||||
|
||||
|
||||
func test_match_net_rejects_malformed_or_skipped_backend_generations() -> void:
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 2}, 1), 2, "exact backend generation is accepted")
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 2}, 1), 2, "local fallback retains the exact next generation")
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 1}, 0), -1, "a fresh process cannot guess a generation during an outage")
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "rejected", "generation": 2}, 1), -1, "backend conflict rejects admission")
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 3}, 1), -1, "generation skips are fenced")
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": 3}, 0), 3, "fresh process adopts durable recovery generation")
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "unavailable", "generation": 3}, 0), -1, "offline fallback cannot invent a skipped generation")
|
||||
assert_eq(MatchNet.lease_claim_generation({"status": "claimed", "generation": "2"}, 1), -1, "string generation is fenced")
|
||||
@@ -0,0 +1,582 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const ControlPlaneClient = preload("res://scripts/control_plane_client.gd")
|
||||
const RankedProfileState = preload("res://scripts/ranked_profile_state.gd")
|
||||
|
||||
|
||||
func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void:
|
||||
assert_true(ControlPlaneClient.is_valid_base_url("http://127.0.0.1:8080"), "local HTTP endpoint is valid")
|
||||
assert_true(ControlPlaneClient.is_valid_base_url("https://match.example"), "HTTPS endpoint is valid")
|
||||
assert_true(not ControlPlaneClient.is_valid_base_url("match.example"), "scheme is required")
|
||||
assert_true(not ControlPlaneClient.is_valid_base_url("http://match.example/"), "trailing slash is normalized before validation")
|
||||
assert_true(not ControlPlaneClient.is_valid_base_url("http://match example"), "whitespace is rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_base_url("https://user:pass@match.example"), "userinfo is rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_base_url("https://match.example?token=secret"), "query strings are rejected")
|
||||
|
||||
var client := ControlPlaneClient.new()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures")
|
||||
assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected")
|
||||
assert_true(ControlPlaneClient.is_valid_web_api_ticket("ticket-value"), "ordinary Steam Web API ticket is accepted")
|
||||
assert_true(not ControlPlaneClient.is_valid_web_api_ticket("ticket\nforged"), "ticket header characters are rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_web_api_ticket(""), "empty Steam ticket is rejected")
|
||||
assert_true(ControlPlaneClient.is_valid_access_token("session-id:opaque-token"), "opaque session format is accepted")
|
||||
assert_true(not ControlPlaneClient.is_valid_access_token(":opaque-token"), "missing session identifier is rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_access_token("session-id:token\nforged"), "session header injection is rejected")
|
||||
assert_eq(ControlPlaneClient.websocket_url("https://match.example"), "wss://match.example", "TLS control plane uses secure WebSocket")
|
||||
assert_eq(ControlPlaneClient.websocket_url("http://127.0.0.1:8080"), "ws://127.0.0.1:8080", "local control plane uses WebSocket")
|
||||
assert_eq(ControlPlaneClient.websocket_url("match.example"), "", "unscoped URL cannot become a WebSocket URL")
|
||||
var unconfigured := ControlPlaneClient.new()
|
||||
assert_eq(unconfigured.connect_event_stream(), ERR_UNAUTHORIZED, "event stream requires an authenticated session")
|
||||
|
||||
|
||||
func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
|
||||
var payload := {"ticket_id": "ticket-1", "state": "QUEUED", "expires_at": "2026-08-31T12:00:00Z"}
|
||||
var normalized := ControlPlaneClient.normalize_ticket(payload)
|
||||
assert_eq(normalized["ticket_id"], "ticket-1", "normalization preserves ticket identity")
|
||||
assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection")
|
||||
assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch")
|
||||
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
|
||||
|
||||
|
||||
func test_ticket_normalization_derives_authoritative_enqueue_time() -> void:
|
||||
var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"})
|
||||
assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch")
|
||||
assert_eq(ControlPlaneClient.normalize_ticket({"enqueued_at": "not-a-timestamp"})["enqueued_at_unix"], -1, "malformed enqueue time remains visibly invalid")
|
||||
assert_eq(ControlPlaneClient.normalize_ticket({"expires_at": 123})["expires_at_unix"], -1, "non-string expiry remains visibly invalid")
|
||||
|
||||
|
||||
func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void:
|
||||
assert_true(not ControlPlaneClient.is_session_expired("", 1000), "legacy sessions without an expiry remain compatible")
|
||||
assert_true(not ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 999), "session remains valid before expiry")
|
||||
assert_true(ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 1000), "session expires at the exact boundary")
|
||||
assert_true(ControlPlaneClient.is_session_expired("not-a-timestamp", 1000), "malformed non-empty expiry fails closed")
|
||||
assert_true(ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00.123Z"), "fractional RFC3339 timestamp is accepted")
|
||||
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-02-30T12:00:00Z"), "impossible calendar date is rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-13-01T12:00:00Z"), "impossible month is rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31 12:00:00Z"), "space-separated timestamp is rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00"), "timezone-less timestamp is rejected")
|
||||
var valid_session := {"player_id": "player_1234567890", "access_token": "session-id:opaque-token", "expires_at": "2099-08-31T12:00:00Z"}
|
||||
assert_true(ControlPlaneClient.is_valid_session_response(valid_session), "future session response is accepted")
|
||||
var missing_expiry := valid_session.duplicate()
|
||||
missing_expiry.erase("expires_at")
|
||||
assert_true(not ControlPlaneClient.is_valid_session_response(missing_expiry), "session without expiry is rejected")
|
||||
var malformed_expiry := valid_session.duplicate()
|
||||
malformed_expiry["expires_at"] = "tomorrow"
|
||||
assert_true(not ControlPlaneClient.is_valid_session_response(malformed_expiry), "malformed session expiry is rejected")
|
||||
var expired_session := valid_session.duplicate()
|
||||
expired_session["expires_at"] = "2000-01-01T00:00:00Z"
|
||||
assert_true(not ControlPlaneClient.is_valid_session_response(expired_session), "expired session response is rejected")
|
||||
|
||||
|
||||
func test_reconfiguration_discards_the_previous_session_expiry() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client.session_expires_at = "1970-01-01T00:00:01Z"
|
||||
assert_true(client.configure("https://match.example", "new-session:opaque-token"), "new session configures successfully")
|
||||
assert_eq(client.session_expires_at, "", "new credentials do not inherit the old expiry")
|
||||
|
||||
|
||||
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
|
||||
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket_123456789", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
|
||||
var accepted := envelope.duplicate()
|
||||
accepted["state"] = "ACCEPTED"
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted")
|
||||
for phase in ["ASSIGNED", "RESULT_PENDING", "COMPLETED"]:
|
||||
var lifecycle := envelope.duplicate()
|
||||
lifecycle["state"] = phase
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(lifecycle), "post-match queue event is accepted: " + phase)
|
||||
var bad_state := envelope.duplicate()
|
||||
bad_state["state"] = "SECRET"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected")
|
||||
var assignment := {"event": "assignment_changed", "revision": 0, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "match_id": "match_1234567890", "server_id": "server_123456789"}
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
|
||||
var short_assignment_id := assignment.duplicate()
|
||||
short_assignment_id["server_id"] = "server-1"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(short_assignment_id), "short assignment server id is rejected")
|
||||
assignment.erase("server_id")
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
|
||||
var fractional := envelope.duplicate()
|
||||
fractional["revision"] = 1.5
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected")
|
||||
var negative := envelope.duplicate()
|
||||
negative["revision"] = -1
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected")
|
||||
var malformed_time := envelope.duplicate()
|
||||
malformed_time["occurred_at"] = "yesterday"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(malformed_time), "malformed event timestamp is rejected")
|
||||
var short_resource := envelope.duplicate()
|
||||
short_resource["resource_id"] = "short"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(short_resource), "short resource identifier is rejected")
|
||||
var unsafe_resource := envelope.duplicate()
|
||||
unsafe_resource["resource_id"] = "ticket_123456789/secret"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected")
|
||||
var match_state := {"event": "state_changed", "revision": 4, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_1234567890"}
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(match_state), "match-scoped lifecycle event is accepted")
|
||||
match_state["match_id"] = "different_match_123"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(match_state), "match lifecycle identity must equal its resource identity")
|
||||
|
||||
|
||||
func test_match_assignment_ready_event_recovers_ticket_and_schedules_assignment_fetch() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket_assignment_1", "casual"), "queue setup succeeds")
|
||||
client._operation = "queue_heartbeat"
|
||||
var event := {"event": "state_changed", "revision": 4, "resource_id": "match_assignment_1", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_assignment_1"}
|
||||
client._handle_websocket_packet(JSON.stringify(event).to_utf8_buffer())
|
||||
assert_eq(client._pending_resync_resource_id, "ticket_assignment_1", "match event requests authoritative ticket recovery")
|
||||
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "assignment lookup no longer depends on a prior assignment GET")
|
||||
assert_eq(client.state.ticket_id, "ticket_assignment_1", "match resource is never projected as a ticket identity")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_recovered_assignment_ready_ticket_schedules_fetch_after_missed_revisions() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
client.player_id = "player_1234567890"
|
||||
client.state.begin_queue("ticket_assignment_1", "casual")
|
||||
assert_true(client.state.apply_ticket_update({"ticket_id": "ticket_assignment_1", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "proposal setup applies")
|
||||
client._operation = "queue_recover"
|
||||
var recovered := {"ticket_id": "ticket_assignment_1", "player_id": "player_1234567890", "match_id": "match_assignment_1", "playlist": "casual", "state": "ASSIGNMENT_READY", "revision": 5, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(recovered).to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.ASSIGNMENT_READY, "REST recovery applies a forward authoritative snapshot")
|
||||
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "recovered snapshot supplies the assignment lookup key")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket-reconnect", "casual"), "queue setup succeeds")
|
||||
client._operation = "queue_heartbeat"
|
||||
client._set_websocket_status("CONNECTED")
|
||||
assert_eq(client._pending_resync_resource_id, "ticket-reconnect", "reconnect recovery is retained until the mutation completes")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_transient_rest_recovery_failure_does_not_end_matchmaking() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
client.state.begin_queue("ticket_recovery_123", "casual")
|
||||
client._operation = "queue_recover"
|
||||
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
|
||||
assert_eq(client.state.phase, MatchmakingState.QUEUED, "network failure during recovery keeps the active search")
|
||||
assert_true(client.state.message.contains("retrying"), "recovery failure remains visible and retryable")
|
||||
client._operation = "proposal_recover"
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), "[]".to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.QUEUED, "malformed transient recovery response does not become terminal")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_resync_of_terminal_proposal_recovers_the_ticket() -> void:
|
||||
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", false), "ticket-terminal-resync", "terminal proposal resync targets the requeued ticket")
|
||||
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", true), "proposal-terminal-resync", "open proposal resync retains the proposal target")
|
||||
|
||||
|
||||
func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
|
||||
assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable")
|
||||
assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable")
|
||||
assert_true(ControlPlaneClient.is_retryable_mutation_response(429), "rate limit is retryable")
|
||||
assert_true(ControlPlaneClient.is_retryable_mutation_response(503), "server failure is retryable")
|
||||
assert_true(not ControlPlaneClient.is_retryable_mutation_response(401), "authentication failure is not blindly replayed")
|
||||
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
|
||||
|
||||
|
||||
# multiplayer-next.md 8.43 named "duplicate-action recovery beyond proposals"
|
||||
# and "regional outage retry UI" as remaining. Both mechanisms (can_retry_last_mutation /
|
||||
# retry_last_mutation, and matchmaking.gd's queue button falling back to them)
|
||||
# already existed in the client, but had no test coverage proving the
|
||||
# generic (non-proposal) mutation path actually recovers end to end -- only
|
||||
# is_retryable_mutation_response's pure classification was covered above.
|
||||
func test_generic_mutation_retry_recovers_after_a_transient_failure() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
assert_true(client.state.begin_queue("ticket-retry-generic", "casual"), "queue setup succeeds")
|
||||
# Simulate what _start_request itself would already have recorded before
|
||||
# a real network call was in flight, the same way the pre-existing
|
||||
# conflict-handler tests above set _operation directly.
|
||||
client._operation = "queue_heartbeat"
|
||||
client._last_mutation = {"operation": "queue_heartbeat", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-generic/heartbeat", "payload": {"revision": 0}, "key": "heartbeat-retry-key-123456", "expected_revision": 0}
|
||||
assert_true(not client.can_retry_last_mutation(), "a mutation still in flight is never offered as retryable")
|
||||
|
||||
# A regional outage: the transport itself failed rather than returning a
|
||||
# decoded HTTP status -- exactly the "regional outage retry" case. This
|
||||
# transition is the actual previously-uncovered boundary: nothing tested
|
||||
# that a generic (non-proposal) mutation ever becomes retryable at all,
|
||||
# only is_retryable_mutation_response's pure classification above.
|
||||
# retry_last_mutation's own dispatch is not exercised here: it reaches
|
||||
# HTTPRequest.request(), which needs the node inside a live SceneTree,
|
||||
# and test_runner.tscn runs every test method from within its own
|
||||
# _ready() while the tree is still being built, so that is out of reach
|
||||
# for this harness -- the "not offered at all" boundary below covers the
|
||||
# part of retry_last_mutation this environment can exercise safely.
|
||||
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
|
||||
assert_true(client.can_retry_last_mutation(), "a transport failure on a non-proposal mutation is offered as retryable")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_generic_mutation_retry_is_not_offered_for_unsafe_failures() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
assert_true(client.state.begin_queue("ticket-retry-unsafe", "casual"), "queue setup succeeds")
|
||||
client._operation = "queue_cancel"
|
||||
client._last_mutation = {"operation": "queue_cancel", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-unsafe/cancel", "payload": {}, "key": "cancel-retry-key-123456", "expected_revision": 0}
|
||||
# A 409 is a revision/idempotency conflict, not a transient failure --
|
||||
# should_recover_queue_after_conflict owns recovering it instead, and a
|
||||
# blind resend would replay a mutation whose precondition already failed.
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
|
||||
assert_true(not client.can_retry_last_mutation(), "a conflict response is never offered as a blind retry")
|
||||
assert_eq(client.retry_last_mutation(), ERR_INVALID_DATA, "retrying when not offered fails closed rather than resending a stale mutation")
|
||||
client.free()
|
||||
|
||||
|
||||
# connect_to_assignment() already existed, fully validated, with its own
|
||||
# assignment_connection_started/assignment_connection_failed signals -- but
|
||||
# nothing anywhere in the client ever called it. A player reaching the
|
||||
# ASSIGNED phase (server confirms the complete roster) with a fetched, fresh
|
||||
# assignment would simply sit on "Your match server is ready" forever,
|
||||
# because the transport was never actually started. This is the wiring fix,
|
||||
# not just new test coverage for existing behavior.
|
||||
func test_client_starts_the_transport_once_the_ticket_reaches_assigned() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
client.player_id = "player_1234567890"
|
||||
assert_true(client.state.begin_queue("ticket-connect-ready", "casual"), "queue setup succeeds")
|
||||
|
||||
# The assignment fetch (triggered independently, earlier, by
|
||||
# ASSIGNMENT_READY) has already completed by the time ASSIGNED arrives --
|
||||
# the common case.
|
||||
client._operation = "assignment"
|
||||
var assignment_payload := {"match_id": "match_connect_1234567890", "server_id": "server_connect_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65500", "join_authorisation": "opaque-join-token"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
|
||||
assert_true(client.assignment.available, "assignment fetch applies")
|
||||
|
||||
var connect_started := [false]
|
||||
var connect_failed := [false]
|
||||
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
|
||||
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
|
||||
|
||||
client._operation = "queue_recover"
|
||||
var ticket_payload := {"ticket_id": "ticket-connect-ready", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_connect_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
|
||||
|
||||
# connect_to_assignment() itself calls state.mark_connecting() as part of a
|
||||
# successful attempt, so by the time control returns here phase has
|
||||
# already advanced past ASSIGNED to CONNECTING -- that advancement is
|
||||
# itself the proof the connect was actually attempted.
|
||||
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "reaching ASSIGNED with a ready assignment actually started the transport, rather than sitting idle")
|
||||
assert_true(connect_started[0] or connect_failed[0], "connect_to_assignment's own signal fired")
|
||||
assert_true(client._pending_connect_match_id.is_empty(), "an attempted connect is not left pending")
|
||||
|
||||
# A duplicate/replayed ASSIGNED event for the same match (e.g. an
|
||||
# at-least-once outbox redelivery) must not fire a second connection
|
||||
# attempt. Called directly against the guarded function rather than
|
||||
# through another full _on_request_completed round-trip: phase has
|
||||
# already moved on to CONNECTING, so both of _connect_when_assigned's own
|
||||
# guards (phase != ASSIGNED, and the _connect_attempted_match_id match)
|
||||
# now independently refuse a second attempt for this match.
|
||||
connect_started[0] = false
|
||||
connect_failed[0] = false
|
||||
client._connect_when_assigned("match_connect_1234567890")
|
||||
assert_true(not connect_started[0] and not connect_failed[0], "a duplicate connect attempt for an already-attempted match is not reattempted")
|
||||
|
||||
NetworkManager.shutdown()
|
||||
client.free()
|
||||
|
||||
|
||||
func test_client_defers_the_connect_until_the_assignment_fetch_completes() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
assert_true(client.state.begin_queue("ticket-connect-deferred", "casual"), "queue setup succeeds")
|
||||
|
||||
var connect_started := [false]
|
||||
var connect_failed := [false]
|
||||
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
|
||||
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
|
||||
|
||||
# ASSIGNED arrives before the assignment fetch (triggered earlier by
|
||||
# ASSIGNMENT_READY) has actually completed -- the ordering the deferred
|
||||
# path exists for. client.assignment is still the default, unavailable one.
|
||||
client._operation = "queue_recover"
|
||||
var ticket_payload := {"ticket_id": "ticket-connect-deferred", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_deferred_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
|
||||
|
||||
assert_eq(client.state.phase, MatchmakingState.ASSIGNED, "ticket state machine still reaches ASSIGNED")
|
||||
assert_eq(client._pending_connect_match_id, "match_deferred_1234567890", "the connect attempt is deferred until the assignment is actually available")
|
||||
assert_true(not connect_started[0] and not connect_failed[0], "no connection attempt is made before the assignment is ready -- nothing to connect to yet")
|
||||
client.free()
|
||||
|
||||
|
||||
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
|
||||
# synchronous failures previously only emitted assignment_connection_failed,
|
||||
# a signal nothing in the client listened to -- state.phase stayed stuck at
|
||||
# ASSIGNED, the UI kept showing "Your match server is ready" forever, and
|
||||
# there was no way back to a fresh search.
|
||||
func test_synchronous_assignment_connection_failure_surfaces_as_a_failed_search() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket-connect-unavailable", "casual"), "queue setup succeeds")
|
||||
# client.assignment is still the default, unavailable one.
|
||||
var err := client.connect_to_assignment()
|
||||
assert_eq(err, ERR_UNAUTHORIZED, "connect fails closed when the assignment isn't ready")
|
||||
assert_eq(client.state.phase, MatchmakingState.FAILED, "the failure is surfaced as a failed search rather than leaving the UI stuck at ASSIGNED")
|
||||
assert_true(client.state.message.to_lower().contains("unavailable") or client.state.message.to_lower().contains("expired"), "the failure detail is retained: %s" % client.state.message)
|
||||
client.free()
|
||||
|
||||
|
||||
# The likelier real-world failure than the synchronous one above:
|
||||
# NetworkManager.join() returns OK immediately (the attempt started), but the
|
||||
# actual ENet handshake fails asynchronously later -- unreachable server,
|
||||
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
|
||||
# main_menu.gd's own _on_connection_failed exists to cover for the
|
||||
# direct-join flow; nothing covered it for a matchmaking-driven connect.
|
||||
func test_async_network_connection_failure_after_assignment_ready_surfaces_as_a_failed_search() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
client.player_id = "player_1234567890"
|
||||
assert_true(client.state.begin_queue("ticket-connect-asyncfail", "casual"), "queue setup succeeds")
|
||||
client._operation = "assignment"
|
||||
var assignment_payload := {"match_id": "match_asyncfail_1234567890", "server_id": "server_asyncfail_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65501", "join_authorisation": "opaque-join-token"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
|
||||
client._operation = "queue_recover"
|
||||
var ticket_payload := {"ticket_id": "ticket-connect-asyncfail", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_asyncfail_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "the transport attempt started")
|
||||
|
||||
NetworkManager.connection_failed.emit()
|
||||
assert_eq(client.state.phase, MatchmakingState.FAILED, "the async handshake failure is surfaced rather than leaving CONNECTING stuck forever")
|
||||
|
||||
NetworkManager.shutdown()
|
||||
client.free()
|
||||
|
||||
|
||||
func test_network_connection_failure_is_ignored_outside_a_matchmaking_driven_connect() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket-unrelated-failure", "casual"), "queue setup succeeds")
|
||||
# state.phase is QUEUED, not CONNECTING -- this connection_failed belongs
|
||||
# to something else (e.g. main_menu.gd's own direct-join flow) and must
|
||||
# not be misattributed to matchmaking.
|
||||
NetworkManager.connection_failed.emit()
|
||||
assert_eq(client.state.phase, MatchmakingState.QUEUED, "an unrelated connection_failed does not fail an active queue search")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void:
|
||||
assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted")
|
||||
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected")
|
||||
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket_1234567890/path"), "path separator is rejected")
|
||||
|
||||
|
||||
func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void:
|
||||
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket")
|
||||
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket")
|
||||
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_create", 409, "ticket-1"), "create conflict uses its own idempotency path")
|
||||
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 503, "ticket-1"), "transient outage remains retryable instead of being treated as a revision conflict")
|
||||
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, ""), "missing ticket cannot trigger recovery")
|
||||
|
||||
|
||||
func test_queue_conflict_response_handler_defers_ticket_recovery() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket-handler", "casual"), "queue setup succeeds")
|
||||
client._operation = "queue_heartbeat"
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
|
||||
assert_eq(client._pending_resync_resource_id, "ticket-handler", "heartbeat conflict queues ticket recovery")
|
||||
client._operation = "queue_cancel"
|
||||
client._pending_resync_resource_id = ""
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
|
||||
assert_eq(client._pending_resync_resource_id, "ticket-handler", "cancel conflict queues ticket recovery")
|
||||
client.free()
|
||||
|
||||
|
||||
# Covers §8.43's "version-mismatch-specific client messaging": a 426 Upgrade
|
||||
# Required on queue_create (the server-side floor added alongside this test)
|
||||
# must surface a distinct, actionable message rather than the server's raw
|
||||
# generic error string, and must not offer a futile "Retry Search" -- the
|
||||
# same client build will fail again identically every time.
|
||||
func test_outdated_client_receives_a_distinct_message_and_no_retry_offer() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
client._operation = "queue_create"
|
||||
client._last_queue_create = {"ticket_id": "ticket-outdated", "playlist": "casual", "client_build": "build-1", "protocol_version": 4, "key": "outdated-key-123456"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, HTTPClient.RESPONSE_UPGRADE_REQUIRED, PackedStringArray(), JSON.stringify({"error": "client_outdated"}).to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.FAILED, "outdated client fails the search")
|
||||
assert_true(client.state.message.to_lower().contains("update"), "message tells the player to update rather than repeating the raw server error: %s" % client.state.message)
|
||||
assert_true(not client.can_retry_queue_create(), "retrying with the same outdated client build is never offered")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_rest_responses_reject_malformed_resource_identifiers() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
client._operation = "queue_recover"
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"ticket_id": "short", "playlist": "casual", "revision": 0, "state": "QUEUED"}).to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed queue response is not projected")
|
||||
client._operation = "proposal_recover"
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"proposal_id": "proposal/unsafe", "revision": 0, "state": "OPEN"}).to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed proposal response is not projected")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_queue_response_requires_the_complete_contract_shape() -> void:
|
||||
var valid := {"ticket_id": "ticket_1234567890", "player_id": "player_1234567890", "playlist": "casual", "state": "QUEUED", "revision": 0, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
|
||||
assert_true(ControlPlaneClient._valid_queue_response(valid), "complete queue response is accepted")
|
||||
var missing_expiry := valid.duplicate()
|
||||
missing_expiry.erase("expires_at")
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(missing_expiry), "queue response without expiry is rejected")
|
||||
var fractional_revision := valid.duplicate()
|
||||
fractional_revision["revision"] = 1.5
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(fractional_revision), "fractional queue revision is rejected")
|
||||
var malformed_player := valid.duplicate()
|
||||
malformed_player["player_id"] = "player/unsafe"
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected")
|
||||
var assigned := valid.duplicate()
|
||||
assigned["state"] = "ASSIGNMENT_READY"
|
||||
assigned["match_id"] = "match_1234567890"
|
||||
assert_true(ControlPlaneClient._valid_queue_response(assigned), "recovered assignment-ready ticket carries its match lookup identity")
|
||||
var premature_match := valid.duplicate()
|
||||
premature_match["match_id"] = "match_1234567890"
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(premature_match), "pre-match ticket cannot smuggle a match identity")
|
||||
assigned["match_id"] = "match/unsafe"
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(assigned), "unsafe recovered match identity is rejected")
|
||||
var proposed := valid.duplicate()
|
||||
proposed["state"] = "PROPOSED"
|
||||
proposed["proposal_id"] = "proposal_12345678"
|
||||
assert_true(ControlPlaneClient._valid_queue_response(proposed), "recovered proposed ticket carries its proposal lookup identity")
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
client.state.begin_queue("ticket_1234567890", "casual")
|
||||
client._queue_proposal_if_ready(proposed)
|
||||
assert_eq(client._pending_proposal_id, "proposal_12345678", "recovered proposal is queued for authoritative fetch")
|
||||
assert_eq(client.state.proposal_id, "proposal_12345678", "recovered proposal identity becomes the active projection")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_proposal_response_requires_structured_unique_participants() -> void:
|
||||
var base := {"proposal_id": "proposal_1234567890", "expires_at": "2099-08-31T12:00:00Z", "participants": [
|
||||
{"player_id": "player_1234567890", "response": "PENDING", "team": 0, "slot": 0},
|
||||
{"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3}
|
||||
]}
|
||||
assert_true(ControlPlaneClient._valid_proposal_response(base), "structured proposal participants are accepted")
|
||||
var duplicate := base.duplicate(true)
|
||||
duplicate["participants"][1]["player_id"] = "player_1234567890"
|
||||
assert_true(not ControlPlaneClient._valid_proposal_response(duplicate), "duplicate participant identity is rejected")
|
||||
var fractional_slot := base.duplicate(true)
|
||||
fractional_slot["participants"][0]["slot"] = 0.5
|
||||
assert_true(not ControlPlaneClient._valid_proposal_response(fractional_slot), "fractional participant slot is rejected")
|
||||
var malformed_expiry := base.duplicate(true)
|
||||
malformed_expiry["expires_at"] = "tomorrow"
|
||||
assert_true(not ControlPlaneClient._valid_proposal_response(malformed_expiry), "malformed proposal expiry is rejected")
|
||||
var missing_expiry := base.duplicate(true)
|
||||
missing_expiry.erase("expires_at")
|
||||
assert_true(not ControlPlaneClient._valid_proposal_response(missing_expiry), "missing proposal expiry is rejected")
|
||||
assert_true(int(ControlPlaneClient.normalize_proposal(base)["expires_at_unix"]) > 0, "proposal expiry is normalized")
|
||||
|
||||
|
||||
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
|
||||
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
|
||||
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
|
||||
assert_eq(endpoint["port"], 31001, "assignment port is parsed as an integer")
|
||||
for unsafe in ["127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:31001/path", "https://127.0.0.1:31001"]:
|
||||
assert_true(ControlPlaneClient._split_assignment_endpoint(unsafe).is_empty(), "unsafe endpoint is rejected: %s" % unsafe)
|
||||
|
||||
|
||||
func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void:
|
||||
var profile := RankedProfileState.new()
|
||||
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "season_1234567890"}), "valid profile applies")
|
||||
assert_eq(profile.display_text(), "Provisional · 3 ranked games", "provisional status overrides tier presentation")
|
||||
assert_true(not profile.apply({"rating": -1.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": false}), "negative rating is rejected")
|
||||
assert_true(not profile.available, "unsafe response is not displayed")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "MASTER", "provisional": false}), "unknown tier is rejected")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3.5, "tier": "GOLD", "provisional": false}), "fractional ranked games is rejected")
|
||||
|
||||
|
||||
func test_ranked_profile_projects_and_bounds_season_countdown() -> void:
|
||||
var profile := RankedProfileState.new()
|
||||
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "season_1234567890", "season_ends_at": "1970-01-03T00:00:00Z"}), "season end applies")
|
||||
assert_true(profile.display_text(1000).contains("Season ends in 2d"), "countdown rounds up remaining season time")
|
||||
assert_true(profile.display_text(300000).contains("Season ends in 0d"), "expired season countdown is clamped")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": "not-a-timestamp"}), "malformed season expiry is rejected")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": 123}), "non-string season expiry is rejected")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "short"}), "short season identifier is rejected")
|
||||
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": 123}), "non-string season identifier is rejected")
|
||||
|
||||
|
||||
# The client had no probe support at all, so even with the backend wired a real
|
||||
# player could never acquire the RTT evidence the matcher requires.
|
||||
func test_probe_region_validation_rejects_unknown_regions() -> void:
|
||||
assert_true(ControlPlaneClient.is_valid_probe_region("EU"), "EU is a placement region")
|
||||
assert_true(ControlPlaneClient.is_valid_probe_region("NA"), "NA is a placement region")
|
||||
for region in ["", "eu", "APAC", "EU/NA", "../EU"]:
|
||||
assert_true(not ControlPlaneClient.is_valid_probe_region(region), "rejects %s" % region)
|
||||
|
||||
|
||||
func test_probe_requests_require_a_session() -> void:
|
||||
var client = ControlPlaneClient.new()
|
||||
client.base_url = "http://127.0.0.1:8080"
|
||||
client.access_token = ""
|
||||
assert_eq(client.request_probe_challenge("EU"), ERR_UNAUTHORIZED, "probing without a session is refused")
|
||||
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", "bG9j"), ERR_UNAUTHORIZED, "answering without a session is refused")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_probe_answer_rejects_empty_nonce_or_location() -> void:
|
||||
var client = ControlPlaneClient.new()
|
||||
client.base_url = "http://127.0.0.1:8080"
|
||||
client.access_token = "session-1234567890:token-1234567890"
|
||||
assert_eq(client.submit_probe_answer("EU", "", "bG9j"), ERR_INVALID_PARAMETER, "an empty nonce is refused")
|
||||
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", ""), ERR_INVALID_PARAMETER, "an empty location is refused")
|
||||
assert_eq(client.request_probe_challenge("APAC"), ERR_INVALID_PARAMETER, "an unknown region is refused")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_opaque_location_payload_is_never_empty() -> void:
|
||||
# The backend rejects an empty opaque location, and without a Steam runtime
|
||||
# there is nothing real to report -- but the RTT the backend measures is
|
||||
# what actually drives placement, so the probe must still be answerable.
|
||||
var payload := ControlPlaneClient.opaque_location_payload()
|
||||
assert_true(not payload.is_empty(), "a probe answer always carries a location blob")
|
||||
assert_true(not Marshalls.base64_to_raw(payload).is_empty(), "the location blob is valid base64")
|
||||
|
||||
|
||||
func test_probe_challenge_response_without_a_nonce_is_a_failure() -> void:
|
||||
var client = ControlPlaneClient.new()
|
||||
client.base_url = "http://127.0.0.1:8080"
|
||||
client.access_token = "session-1234567890:token-1234567890"
|
||||
var failures: Array = []
|
||||
client.request_failed.connect(func(operation: String, _code: int, detail: String): failures.append([operation, detail]))
|
||||
client._operation = "probe_challenge_EU"
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer())
|
||||
assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure")
|
||||
client.free()
|
||||
|
||||
|
||||
# The game started with an empty token against a loopback default and no
|
||||
# production code ever called configure() or login_steam(), so every
|
||||
# matchmaking request failed ERR_UNAUTHORIZED before reaching the network.
|
||||
func test_has_session_reflects_token_and_expiry() -> void:
|
||||
var client = ControlPlaneClient.new()
|
||||
assert_true(not client.has_session(), "a fresh client has no session")
|
||||
client.access_token = "session-1234567890:token-1234567890"
|
||||
client.session_expires_at = "2099-01-01T00:00:00Z"
|
||||
assert_true(client.has_session(), "a valid unexpired token is a session")
|
||||
client.session_expires_at = "2000-01-01T00:00:00Z"
|
||||
assert_true(not client.has_session(), "an expired token is not a session")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_configured_base_url_falls_back_to_the_development_default() -> void:
|
||||
# Release builds set COSMIC_CLASH_CONTROL_PLANE_URL; without it the
|
||||
# loopback default keeps local development working.
|
||||
var resolved := ControlPlaneClient.configured_base_url()
|
||||
assert_true(ControlPlaneClient.is_valid_base_url(resolved), "the resolved endpoint is always usable")
|
||||
if OS.get_environment(ControlPlaneClient.BASE_URL_ENV).strip_edges().is_empty():
|
||||
assert_eq(resolved, ControlPlaneClient.DEFAULT_BASE_URL, "falls back to the development default")
|
||||
@@ -0,0 +1,450 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Guards the input map and the InputSettings remap layer.
|
||||
#
|
||||
# The defect this file exists for: project.godot bound joypad events to only 5
|
||||
# of the 13 flight actions, so a controller could yaw/pitch/roll/turbo but could
|
||||
# not translate at all. Nothing failed, because nothing asserted that a *pair*
|
||||
# of bindings exists — the actions were all present and the game booted fine.
|
||||
# test_every_action_has_both_a_keyboard_and_a_joypad_binding is that assertion,
|
||||
# and it can tell "bound on both devices" from "bound on one", which is the
|
||||
# distinction that was actually missing.
|
||||
#
|
||||
# Several of these tests write to the global InputMap through InputSettings, so
|
||||
# each one must restore it before returning or it corrupts every later case in
|
||||
# the run (the runner shares one process). reset_all() is the restore.
|
||||
|
||||
const CONTROLLER_ONLY_ACTIONS := ["toggle_ball_cam", "reset_ball"]
|
||||
|
||||
|
||||
func _joypad_events(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_JOYPAD:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
|
||||
func _keyboard_events(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_KEYBOARD:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
|
||||
func test_every_rebindable_action_exists() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
assert_true(InputMap.has_action(entry["action"]), "InputMap has action %s" % entry["action"])
|
||||
|
||||
|
||||
func test_every_action_has_both_a_keyboard_and_a_joypad_binding() -> void:
|
||||
# The regression itself: a controller player must be able to reach every
|
||||
# action without touching the keyboard, and vice versa.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
assert_true(not _keyboard_events(action).is_empty(), "%s has a keyboard binding" % action)
|
||||
assert_true(not _joypad_events(action).is_empty(), "%s has a joypad binding" % action)
|
||||
|
||||
|
||||
func test_apply_is_lossless_against_the_project_defaults() -> void:
|
||||
# InputSettings stores one binding per device per action, so apply()
|
||||
# rewrites each action's event list to exactly [keyboard, joypad]. If
|
||||
# project.godot ever gains a second keyboard event for a rebindable action,
|
||||
# booting the game would silently drop it — the action would still work, on
|
||||
# fewer keys than the file says. Asserting apply() is a no-op over the
|
||||
# defaults is what distinguishes "bindings intact" from "bindings quietly
|
||||
# trimmed", which counting events cannot do.
|
||||
InputSettings.reset_all()
|
||||
var before := {}
|
||||
for entry in InputSettings.ACTIONS:
|
||||
before[entry["action"]] = InputMap.action_get_events(entry["action"]).size()
|
||||
|
||||
InputSettings.apply()
|
||||
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
assert_eq(
|
||||
InputMap.action_get_events(action).size(),
|
||||
before[action],
|
||||
"%s keeps every event across apply()" % action
|
||||
)
|
||||
assert_eq(before[action], 2, "%s has exactly one keyboard and one joypad event" % action)
|
||||
|
||||
|
||||
func test_no_two_actions_share_a_joypad_binding() -> void:
|
||||
# Two flight actions sharing one input is silently unplayable rather than an
|
||||
# error, and it is easy to reintroduce: an early draft of this layout had A
|
||||
# as both turbo and thrust-up, and B as both thrust-down and ui_cancel.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_JOYPAD)
|
||||
assert_true(bound != null, "%s resolves a joypad binding" % action)
|
||||
if bound == null:
|
||||
continue
|
||||
var conflicts := InputSettings.find_conflicts(bound, action)
|
||||
assert_true(
|
||||
conflicts.is_empty(),
|
||||
"%s's joypad binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
|
||||
)
|
||||
|
||||
|
||||
func test_no_two_actions_share_a_keyboard_binding() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_KEYBOARD)
|
||||
assert_true(bound != null, "%s resolves a keyboard binding" % action)
|
||||
if bound == null:
|
||||
continue
|
||||
var conflicts := InputSettings.find_conflicts(bound, action)
|
||||
assert_true(
|
||||
conflicts.is_empty(),
|
||||
"%s's keyboard binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
|
||||
)
|
||||
|
||||
|
||||
func _joypad_buttons(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton:
|
||||
out.append(event.button_index)
|
||||
return out
|
||||
|
||||
|
||||
func test_menus_are_usable_with_a_controller() -> void:
|
||||
# Godot 4.7 ships ui_up/down/left/right with D-pad and stick events but
|
||||
# gives ui_accept and ui_cancel NO joypad binding at all (verified against a
|
||||
# pristine project). A controller could therefore move the highlight around
|
||||
# the main menu and never press anything — the menu looked responsive, which
|
||||
# is exactly why it went unnoticed. project.godot binds them explicitly.
|
||||
assert_true(JOY_BUTTON_A in _joypad_buttons("ui_accept"), "A confirms in menus")
|
||||
assert_true(JOY_BUTTON_B in _joypad_buttons("ui_cancel"), "B goes back in menus")
|
||||
# Navigation is the engine default, but assert it so a future override of
|
||||
# these actions cannot silently strand a controller player again.
|
||||
for action in ["ui_up", "ui_down", "ui_left", "ui_right"]:
|
||||
var pad := 0
|
||||
for event in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
|
||||
pad += 1
|
||||
assert_true(pad > 0, "%s is reachable on a controller" % action)
|
||||
|
||||
|
||||
func test_leaving_gameplay_is_not_on_a_face_button() -> void:
|
||||
# game_mode.gd exits to the main menu on leave_gameplay, deliberately NOT on
|
||||
# ui_cancel: ui_cancel carries B so menus behave conventionally, and B is far
|
||||
# too easy to hit by accident to also mean "abandon this match". The two
|
||||
# actions being distinct is the whole point, so assert they really differ.
|
||||
assert_true(InputMap.has_action("leave_gameplay"), "leave_gameplay exists")
|
||||
var buttons := _joypad_buttons("leave_gameplay")
|
||||
assert_true(JOY_BUTTON_START in buttons, "Start leaves gameplay")
|
||||
for face in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y]:
|
||||
assert_true(face not in buttons, "leave_gameplay must not use a face button")
|
||||
|
||||
|
||||
func test_ball_cam_has_its_own_action_off_ui_accept() -> void:
|
||||
# Ball-cam used to ride ui_accept, which project.godot now binds to A for
|
||||
# menu confirmation. A dedicated action keeps A purely a menu button and
|
||||
# lets the camera toggle be rebound like anything else.
|
||||
assert_true(InputMap.has_action("toggle_ball_cam"), "toggle_ball_cam exists")
|
||||
var joypad := _joypad_events("toggle_ball_cam")
|
||||
assert_true(joypad.size() == 1, "toggle_ball_cam has one joypad binding")
|
||||
if joypad.size() == 1:
|
||||
assert_true(
|
||||
joypad[0] is InputEventJoypadButton and joypad[0].button_index != JOY_BUTTON_A,
|
||||
"toggle_ball_cam is not on A (menu confirm)"
|
||||
)
|
||||
|
||||
|
||||
func test_thrust_is_on_the_triggers() -> void:
|
||||
# The requested layout, asserted where it is load-bearing: triggers are the
|
||||
# only analog inputs on the thrust axis, so binding them to buttons instead
|
||||
# would silently cost proportional throttle without failing anything.
|
||||
var forward := _joypad_events("move_forward")
|
||||
var back := _joypad_events("move_back")
|
||||
assert_true(forward.size() == 1 and forward[0] is InputEventJoypadMotion, "move_forward is an axis")
|
||||
assert_true(back.size() == 1 and back[0] is InputEventJoypadMotion, "move_back is an axis")
|
||||
if forward.size() == 1 and forward[0] is InputEventJoypadMotion:
|
||||
assert_eq(forward[0].axis, JOY_AXIS_TRIGGER_RIGHT, "move_forward axis")
|
||||
if back.size() == 1 and back[0] is InputEventJoypadMotion:
|
||||
assert_eq(back[0].axis, JOY_AXIS_TRIGGER_LEFT, "move_back axis")
|
||||
|
||||
|
||||
func test_pitch_is_on_the_left_stick_nose_down_when_pushed_forward() -> void:
|
||||
var down := _joypad_events("pitch_down")
|
||||
var up := _joypad_events("pitch_up")
|
||||
assert_true(down.size() == 1 and down[0] is InputEventJoypadMotion, "pitch_down is an axis")
|
||||
assert_true(up.size() == 1 and up[0] is InputEventJoypadMotion, "pitch_up is an axis")
|
||||
if down.size() == 1 and down[0] is InputEventJoypadMotion:
|
||||
assert_eq(down[0].axis, JOY_AXIS_LEFT_Y, "pitch_down axis")
|
||||
# Godot reports a stick pushed away from the player as negative Y.
|
||||
assert_true(down[0].axis_value < 0.0, "stick forward pitches the nose down")
|
||||
if up.size() == 1 and up[0] is InputEventJoypadMotion:
|
||||
assert_eq(up[0].axis, JOY_AXIS_LEFT_Y, "pitch_up axis")
|
||||
assert_true(up[0].axis_value > 0.0, "stick back pitches the nose up")
|
||||
|
||||
|
||||
func test_all_rotation_lives_on_the_left_stick() -> void:
|
||||
# Yaw and pitch belong on the same stick. Splitting them across two sticks
|
||||
# (yaw left, pitch right) is playable in the sense that every input works,
|
||||
# so nothing here failed when it was wrong — it just felt broken, because
|
||||
# each stick had a dead axis. Asserting both are on the right stick is what
|
||||
# pins the 6DOF convention down.
|
||||
for action in ["turn_left", "turn_right"]:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(events.size() == 1 and events[0] is InputEventJoypadMotion, "%s is an axis" % action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, JOY_AXIS_LEFT_X, "%s axis" % action)
|
||||
for action in ["pitch_up", "pitch_down"]:
|
||||
var events := _joypad_events(action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, JOY_AXIS_LEFT_Y, "%s axis" % action)
|
||||
|
||||
|
||||
func test_translation_is_analog_on_the_right_stick_and_triggers() -> void:
|
||||
# Six degrees of freedom onto the pad's six analog axes. Strafe and
|
||||
# vertical were digital buttons at first, which cost proportional control
|
||||
# without failing anything — a button binding here still "works", it just
|
||||
# gives full power or nothing, so only checking the event type catches it.
|
||||
var expected := {
|
||||
"move_left": JOY_AXIS_RIGHT_X, "move_right": JOY_AXIS_RIGHT_X,
|
||||
"move_up": JOY_AXIS_RIGHT_Y, "move_down": JOY_AXIS_RIGHT_Y,
|
||||
"move_forward": JOY_AXIS_TRIGGER_RIGHT, "move_back": JOY_AXIS_TRIGGER_LEFT,
|
||||
}
|
||||
for action in expected:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(
|
||||
events.size() == 1 and events[0] is InputEventJoypadMotion,
|
||||
"%s is analog, not a button" % action
|
||||
)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, expected[action], "%s axis" % action)
|
||||
|
||||
|
||||
func test_pushing_the_right_stick_up_thrusts_up() -> void:
|
||||
# Godot reports a stick pushed away from the player as negative Y, so the
|
||||
# intuitive direction needs the negative half — easy to get backwards, and
|
||||
# inverted vertical thrust is not something any other assertion notices.
|
||||
var up := _joypad_events("move_up")
|
||||
var down := _joypad_events("move_down")
|
||||
if up.size() == 1 and up[0] is InputEventJoypadMotion:
|
||||
assert_true(up[0].axis_value < 0.0, "stick up thrusts up")
|
||||
if down.size() == 1 and down[0] is InputEventJoypadMotion:
|
||||
assert_true(down[0].axis_value > 0.0, "stick down thrusts down")
|
||||
|
||||
|
||||
func test_roll_is_on_the_shoulder_buttons() -> void:
|
||||
var expected := {"roll_left": JOY_BUTTON_LEFT_SHOULDER, "roll_right": JOY_BUTTON_RIGHT_SHOULDER}
|
||||
for action in expected:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(events.size() == 1 and events[0] is InputEventJoypadButton, "%s is a button" % action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadButton:
|
||||
assert_eq(events[0].button_index, expected[action], "%s button" % action)
|
||||
|
||||
|
||||
func test_the_face_buttons_are_free_for_menus() -> void:
|
||||
# A/B/X/Y carry no flight action, which is what lets ui_accept keep A and
|
||||
# keeps a stray face-button press from doing something during a match.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var bound := InputSettings.get_binding(entry["action"], InputSettings.DEVICE_JOYPAD)
|
||||
if bound is InputEventJoypadButton:
|
||||
assert_true(
|
||||
bound.button_index not in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y],
|
||||
"%s must not use a face button" % entry["action"]
|
||||
)
|
||||
|
||||
|
||||
func test_event_dict_round_trip_preserves_every_default() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
|
||||
var original := InputSettings.get_default_binding(entry["action"], device)
|
||||
assert_true(original != null, "%s/%s has a default" % [entry["action"], device])
|
||||
if original == null:
|
||||
continue
|
||||
var restored := InputSettings.event_from_dict(InputSettings.event_to_dict(original))
|
||||
assert_true(restored != null, "%s/%s round-trips to an event" % [entry["action"], device])
|
||||
if restored != null:
|
||||
assert_true(
|
||||
InputSettings.events_match(original, restored),
|
||||
"%s/%s round-trips to an equal event" % [entry["action"], device]
|
||||
)
|
||||
|
||||
|
||||
func test_event_from_dict_rejects_junk() -> void:
|
||||
# A save file from a newer build, or a hand-edited one, must degrade to
|
||||
# "unbound" rather than taking the game down before the player can reach
|
||||
# the Controls tab to fix it.
|
||||
assert_true(InputSettings.event_from_dict({}) == null, "empty dict is not an event")
|
||||
assert_true(InputSettings.event_from_dict({"type": "mouse"}) == null, "unknown type is not an event")
|
||||
assert_true(InputSettings.event_from_dict({"type": "key"}) == null, "keycode-less key is not an event")
|
||||
assert_true(
|
||||
InputSettings.event_from_dict({"type": "joy_axis", "axis": 3, "value": 0.0}) == null,
|
||||
"a centred axis is not an event"
|
||||
)
|
||||
|
||||
|
||||
func test_axis_bindings_are_distinguished_by_direction() -> void:
|
||||
# events_match must NOT collapse the two halves of one axis, or binding
|
||||
# pitch-up would silently unbind pitch-down as a "conflict".
|
||||
var up := InputEventJoypadMotion.new()
|
||||
up.axis = JOY_AXIS_RIGHT_Y
|
||||
up.axis_value = 1.0
|
||||
var down := InputEventJoypadMotion.new()
|
||||
down.axis = JOY_AXIS_RIGHT_Y
|
||||
down.axis_value = -1.0
|
||||
assert_true(not InputSettings.events_match(up, down), "opposite axis halves are different bindings")
|
||||
assert_true(InputSettings.events_match(up, up), "an axis binding matches itself")
|
||||
|
||||
|
||||
func test_set_binding_changes_the_live_input_map() -> void:
|
||||
var rebound := InputEventKey.new()
|
||||
rebound.physical_keycode = KEY_F # not used by any default binding
|
||||
InputSettings.set_binding("move_forward", rebound)
|
||||
|
||||
var found := false
|
||||
for event in InputMap.action_get_events("move_forward"):
|
||||
if event is InputEventKey and event.physical_keycode == KEY_F:
|
||||
found = true
|
||||
assert_true(found, "the rebound key reaches InputMap")
|
||||
assert_true(InputSettings.has_override("move_forward"), "the rebind is recorded as an override")
|
||||
|
||||
# The joypad half must survive a keyboard-only rebind.
|
||||
assert_true(not _joypad_events("move_forward").is_empty(), "rebinding the key keeps the trigger")
|
||||
|
||||
InputSettings.reset_all()
|
||||
|
||||
|
||||
func test_set_binding_displaces_the_conflicting_action() -> void:
|
||||
# Binding X to an input already in use must report and clear the previous
|
||||
# owner, not leave both bound and let the player wonder why two things fire.
|
||||
var shared := InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD)
|
||||
assert_true(shared != null, "move_left has a keyboard binding to steal")
|
||||
if shared == null:
|
||||
return
|
||||
|
||||
var displaced := InputSettings.set_binding("move_right", shared)
|
||||
assert_true(displaced.has("move_left"), "the displaced action is reported")
|
||||
assert_true(
|
||||
InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD) == null,
|
||||
"the displaced action is actually unbound"
|
||||
)
|
||||
|
||||
InputSettings.reset_all()
|
||||
|
||||
|
||||
func test_reset_all_restores_the_project_defaults() -> void:
|
||||
var before := InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD)
|
||||
var rebound := InputEventJoypadButton.new()
|
||||
rebound.button_index = JOY_BUTTON_BACK
|
||||
InputSettings.set_binding("move_up", rebound)
|
||||
assert_true(
|
||||
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD) != before,
|
||||
"the rebind took effect"
|
||||
)
|
||||
|
||||
InputSettings.reset_all()
|
||||
assert_eq(
|
||||
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD),
|
||||
before,
|
||||
"reset_all restores the default binding"
|
||||
)
|
||||
assert_true(not InputSettings.has_override("move_up"), "reset_all clears the override")
|
||||
|
||||
|
||||
func test_bindings_survive_a_save_and_reload() -> void:
|
||||
# The end-to-end persistence path, which nothing else covers: a rebind that
|
||||
# does not survive a restart is the single most visible way this feature can
|
||||
# fail, and it fails silently — the game runs fine, just on the defaults.
|
||||
#
|
||||
# This writes the real user://input.cfg, so the player's own file is saved
|
||||
# and put back. Restoring it is not optional: the test suite shares a
|
||||
# user:// directory with the game.
|
||||
var had_file := FileAccess.file_exists(InputSettings.SETTINGS_PATH)
|
||||
var original := ""
|
||||
if had_file:
|
||||
original = FileAccess.get_file_as_string(InputSettings.SETTINGS_PATH)
|
||||
|
||||
var rebound := InputEventJoypadButton.new()
|
||||
rebound.button_index = JOY_BUTTON_BACK
|
||||
InputSettings.set_binding("turbo", rebound)
|
||||
InputSettings.invert_pitch = true
|
||||
InputSettings.save()
|
||||
|
||||
# Drop the in-memory state the way a fresh launch would, then reload.
|
||||
InputSettings.reset_all()
|
||||
assert_true(not InputSettings.has_override("turbo"), "state cleared before reload")
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
|
||||
assert_true(InputSettings.has_override("turbo"), "the override came back from disk")
|
||||
var loaded := InputSettings.get_binding("turbo", InputSettings.DEVICE_JOYPAD)
|
||||
assert_true(loaded != null, "the reloaded binding is an event")
|
||||
if loaded != null:
|
||||
assert_true(InputSettings.events_match(loaded, rebound), "the reloaded binding matches what was saved")
|
||||
assert_true(InputSettings.invert_pitch, "invert_pitch survives a reload")
|
||||
|
||||
# A deliberately-cleared binding must stay cleared across a restart. This is
|
||||
# the case that distinguishes a real "unbound" record from an absent one:
|
||||
# ConfigFile.set_value() erases a key whose value is null, so a naive
|
||||
# implementation silently restores the default here instead.
|
||||
InputSettings.clear_binding("roll_left", InputSettings.DEVICE_JOYPAD)
|
||||
InputSettings.save()
|
||||
InputSettings.reset_all()
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
assert_true(
|
||||
InputSettings.get_binding("roll_left", InputSettings.DEVICE_JOYPAD) == null,
|
||||
"an unbound action stays unbound across a reload"
|
||||
)
|
||||
assert_true(
|
||||
_joypad_events("roll_left").is_empty(),
|
||||
"the unbound action has no joypad event in InputMap after a reload"
|
||||
)
|
||||
|
||||
# And it must actually be live in InputMap, not merely remembered.
|
||||
var live := false
|
||||
for event in InputMap.action_get_events("turbo"):
|
||||
if event is InputEventJoypadButton and event.button_index == JOY_BUTTON_BACK:
|
||||
live = true
|
||||
assert_true(live, "the reloaded binding is applied to InputMap")
|
||||
|
||||
InputSettings.reset_all()
|
||||
if had_file:
|
||||
var restore := FileAccess.open(InputSettings.SETTINGS_PATH, FileAccess.WRITE)
|
||||
if restore != null:
|
||||
restore.store_string(original)
|
||||
restore.close()
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
else:
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(InputSettings.SETTINGS_PATH))
|
||||
|
||||
|
||||
func test_invert_pitch_drives_pitch_sign() -> void:
|
||||
var restore := InputSettings.invert_pitch
|
||||
InputSettings.invert_pitch = false
|
||||
assert_eq(InputSettings.pitch_sign(), 1.0, "default pitch sign")
|
||||
InputSettings.invert_pitch = true
|
||||
assert_eq(InputSettings.pitch_sign(), -1.0, "inverted pitch sign")
|
||||
InputSettings.invert_pitch = restore
|
||||
|
||||
|
||||
func test_every_default_binding_has_readable_text() -> void:
|
||||
# A rebind row showing "" or "Joypad Button 9 (Left Shoulder)" is a UI bug
|
||||
# that no other assertion here would catch.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
|
||||
var text := InputSettings.binding_text(entry["action"], device)
|
||||
assert_true(
|
||||
text != "" and text != "Unbound",
|
||||
"%s/%s has a readable label (got %s)" % [entry["action"], device, text]
|
||||
)
|
||||
|
||||
|
||||
func test_device_kind_of_rejects_events_it_cannot_bind() -> void:
|
||||
assert_eq(InputSettings.device_kind_of(InputEventMouseButton.new()), "", "mouse is not a bindable device")
|
||||
assert_eq(InputSettings.device_kind_of(InputEventKey.new()), InputSettings.DEVICE_KEYBOARD, "key device")
|
||||
assert_eq(
|
||||
InputSettings.device_kind_of(InputEventJoypadMotion.new()),
|
||||
InputSettings.DEVICE_JOYPAD,
|
||||
"joypad motion device"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwbkb6wa46yh1
|
||||
@@ -33,7 +33,197 @@ func test_empty_or_whitespace_only_falls_back_to_default() -> void:
|
||||
assert_eq(MatchNet._sanitize_player_name(""), "Player", "empty string falls back")
|
||||
assert_eq(MatchNet._sanitize_player_name(" "), "Player", "whitespace-only falls back")
|
||||
assert_eq(MatchNet._sanitize_player_name("\n\t\r"), "Player", "control-characters-only falls back")
|
||||
assert_eq(MatchNet._sanitize_shutdown_reason("\n maintenance \t"), "maintenance", "shutdown reason strips controls")
|
||||
assert_eq(MatchNet._sanitize_shutdown_reason(""), "server_shutdown", "empty shutdown reason gets a safe fallback")
|
||||
|
||||
|
||||
func test_leading_trailing_whitespace_trimmed() -> void:
|
||||
assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed")
|
||||
|
||||
|
||||
func test_server_shutdown_message_is_bounded_and_emitted() -> void:
|
||||
var instance = MatchNet.new()
|
||||
var received := [""]
|
||||
var callback := func(reason: String) -> void: received[0] = reason
|
||||
instance.server_shutdown.connect(callback)
|
||||
instance._server_shutdown(" planned maintenance " + "x".repeat(200))
|
||||
instance.server_shutdown.disconnect(callback)
|
||||
assert_eq(received[0].length(), 96, "shutdown reason is bounded before presentation")
|
||||
assert_eq(instance.last_server_shutdown_reason.length(), 96, "bounded shutdown reason is retained for UI")
|
||||
|
||||
|
||||
func test_drain_fences_new_hello_admissions() -> void:
|
||||
assert_eq(MatchNet.admission_rejection(true), "", "an active server accepts new hello requests")
|
||||
assert_eq(MatchNet.admission_rejection(false), "server is draining", "a draining server rejects new hello requests")
|
||||
|
||||
|
||||
func test_draining_disconnect_still_releases_roster_and_join_token() -> void:
|
||||
var match_net := MatchNet.new()
|
||||
var token := "opaque-join-token"
|
||||
match_net.admissions_open = false
|
||||
match_net.roster[42] = MatchNet.PlayerInfo.new(42, "Alice", 0, false, "player-1")
|
||||
match_net._active_join_peers[token] = 42
|
||||
match_net._join_history[token] = {"generation": 1}
|
||||
match_net._cleanup_disconnected_peer(42)
|
||||
assert_true(not match_net.roster.has(42), "drain does not retain a disconnected roster entry")
|
||||
assert_true(not match_net._active_join_peers.has(token), "drain releases the disconnected peer's join token")
|
||||
assert_true(float(match_net._join_history[token].get("lost_at", 0.0)) > 0.0, "disconnect records the reclaim boundary during drain")
|
||||
|
||||
|
||||
func test_signed_assignment_locks_team_and_spawn_slot_together() -> void:
|
||||
var match_net := MatchNet.new()
|
||||
var info := MatchNet.PlayerInfo.new(42, "Alice", 0, true, "player-1")
|
||||
info.spawn_index = 2
|
||||
match_net.roster[42] = info
|
||||
match_net.require_join_authorisation = true
|
||||
assert_true(not match_net._apply_team_change(42, 1), "allocated clients cannot override their signed team")
|
||||
assert_eq(info.team, 0, "signed team is unchanged")
|
||||
assert_eq(info.spawn_index, 2, "signed spawn index remains paired with its team")
|
||||
assert_true(info.ready, "rejected mutation does not alter readiness")
|
||||
|
||||
match_net.require_join_authorisation = false
|
||||
assert_true(match_net._apply_team_change(42, 1), "direct lobbies retain team switching")
|
||||
assert_eq(info.team, 1, "direct team switch applies")
|
||||
assert_true(not info.ready, "direct team switch still clears readiness")
|
||||
|
||||
|
||||
func test_reservation_reclaim_requires_stable_identity() -> void:
|
||||
assert_true(MatchNet.reservation_identity_matches("player-a", "player-a", "Alice", "Impostor"), "the verified identity can reclaim despite a changed display name")
|
||||
assert_true(not MatchNet.reservation_identity_matches("player-a", "player-b", "Alice", "Alice"), "a same-name peer cannot reclaim another identity's slot")
|
||||
assert_true(not MatchNet.reservation_identity_matches("player-a", "", "Alice", "Alice"), "an unauthenticated peer cannot reclaim an allocated slot")
|
||||
assert_true(MatchNet.reservation_identity_matches("", "", "Alice", "Alice"), "direct servers retain the legacy display-name fallback")
|
||||
|
||||
|
||||
func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void:
|
||||
var claims := {
|
||||
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
|
||||
"SteamID": "steam-1", "Slot": 5, "Team": 1, "Protocol": "1",
|
||||
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
|
||||
}
|
||||
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
||||
var match_net := MatchNet.new()
|
||||
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "valid roster configures")
|
||||
var assigned := match_net.assigned_player_slots()
|
||||
assert_eq(assigned.size(), 1, "configured roster exposes one assigned player")
|
||||
assert_eq(assigned[0]["player_identity"], "player-1", "assigned roster preserves player identity")
|
||||
assert_eq(assigned[0]["team"], 1, "assigned roster preserves team")
|
||||
assert_eq(assigned[0]["slot"], 5, "assigned roster preserves slot")
|
||||
assert_true(match_net._valid_join_authorisation(token), "allowlisted matching token is accepted")
|
||||
var malformed_claims := claims.duplicate()
|
||||
malformed_claims["ExpiresAt"] = "tomorrow"
|
||||
var malformed_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": malformed_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
||||
assert_true(not match_net._valid_join_authorisation(malformed_token), "malformed expiry claim is rejected before admission")
|
||||
var string_slot_claims := claims.duplicate()
|
||||
string_slot_claims["Slot"] = "5"
|
||||
var string_slot_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": string_slot_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
||||
assert_true(not match_net._valid_join_authorisation(string_slot_token), "string slot claim is rejected instead of coerced")
|
||||
assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected")
|
||||
var wrong_claims := claims.duplicate()
|
||||
wrong_claims["ServerID"] = "other-server"
|
||||
var wrong_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": wrong_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
||||
assert_true(not match_net._valid_join_authorisation(wrong_token), "wrong server claim is rejected")
|
||||
assert_eq(match_net._reserve_join_authorisation(token, 42), 1, "first admission receives generation one")
|
||||
assert_true(match_net.is_join_authorisation_active(token), "admitted token is active")
|
||||
assert_eq(match_net._reserve_join_authorisation(token, 43), -1, "active token cannot be admitted concurrently")
|
||||
match_net._remove_player(42)
|
||||
assert_true(not match_net.is_join_authorisation_active(token), "disconnect releases active token")
|
||||
assert_eq(match_net._reserve_join_authorisation(token, 43), 2, "reclaim receives the next server-owned generation")
|
||||
match_net._remove_player(43)
|
||||
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() - MatchNet.RECONNECT_GRACE_SECONDS - 1.0
|
||||
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "reclaim after the grace window is fenced")
|
||||
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() + 60.0
|
||||
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "clock-reversed reclaim is fenced")
|
||||
var malformed_context := {"match_id": 123, "server_id": "server-1", "protocol": "1", "protocol_version": 1}
|
||||
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "numeric context identity is rejected")
|
||||
malformed_context = {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1.5}
|
||||
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "fractional context protocol is rejected")
|
||||
|
||||
|
||||
func test_allocated_join_authorisation_rejects_inconsistent_team_and_slot() -> void:
|
||||
var claims := {
|
||||
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
|
||||
"SteamID": "steam-1", "Slot": 3, "Team": 0, "Protocol": "1",
|
||||
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
|
||||
}
|
||||
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
||||
var match_net := MatchNet.new()
|
||||
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "fixture configures")
|
||||
assert_true(not match_net._valid_join_authorisation(token), "a slot assigned to team 1 cannot claim team 0")
|
||||
|
||||
|
||||
func test_assigned_roster_rejects_duplicate_identity_or_slot_shape() -> void:
|
||||
var claims := {
|
||||
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
|
||||
"SteamID": "steam-1", "Slot": 0, "Team": 0, "Protocol": "1",
|
||||
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
|
||||
}
|
||||
var first := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "one"}).to_utf8_buffer())
|
||||
var duplicate := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "two"}).to_utf8_buffer())
|
||||
var match_net := MatchNet.new()
|
||||
assert_true(match_net.configure_join_authorisations([first, duplicate], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "duplicate fixture configures for structural inspection")
|
||||
assert_eq(match_net.assigned_player_slots().size(), 0, "duplicate identity/slot roster fails closed")
|
||||
|
||||
|
||||
func test_allocated_join_authorisation_verifies_canonical_hmac() -> void:
|
||||
# This envelope is generated from server/domain.JoinAuthorisationBytes with
|
||||
# HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go
|
||||
# canonical representation rather than merely checking token membership.
|
||||
# Regenerate it whenever JoinAuthorisationBytes changes; a stale token here
|
||||
# is exactly how a silent cross-language format drift would be caught.
|
||||
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
|
||||
var match_net := MatchNet.new()
|
||||
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "HMAC roster configures")
|
||||
assert_true(match_net._valid_join_authorisation(token), "Go-compatible canonical HMAC is accepted")
|
||||
var tampered_payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(token).get_string_from_utf8())
|
||||
tampered_payload["Signature"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
var tampered_token := Marshalls.raw_to_base64(JSON.stringify(tampered_payload).to_utf8_buffer())
|
||||
var tampered_match_net := MatchNet.new()
|
||||
assert_true(tampered_match_net.configure_join_authorisations([tampered_token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "tampered roster fixture configures")
|
||||
assert_true(not tampered_match_net._valid_join_authorisation(tampered_token), "allowlisted but forged signature is rejected")
|
||||
|
||||
|
||||
# Rotation contract: the allocator signs with one key while allocated servers
|
||||
# accept the set of currently-valid keys, so rotating does not invalidate
|
||||
# authorisations already issued for in-flight matches. All three envelopes are
|
||||
# generated from server/domain.JoinAuthorisationBytes.
|
||||
const ROTATION_CONTEXT := {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}
|
||||
const TOKEN_SIGNED_WITH_OLD_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOCJ9LCJTaWduYXR1cmUiOiI5TW42eldERGNwR1pmblY2NXdreXNCYTduUnk3OG1QQkZPT29JN2F1UkdJPSJ9"
|
||||
const TOKEN_SIGNED_WITH_NEW_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
|
||||
const TOKEN_SIGNED_WITH_RETIRED_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNy0wMSJ9LCJTaWduYXR1cmUiOiJRemFYLzB5T0pObE1oRXRPZ1BBcUpRNGJueHZRb1BVU09CR0p2Mm9nQVdnPSJ9"
|
||||
|
||||
|
||||
func test_join_authorisation_accepts_every_key_in_the_rotation_set() -> void:
|
||||
# Mid-rotation: both keys are published, so authorisations issued before
|
||||
# and after the switch must both still admit their player.
|
||||
var keys := {
|
||||
"key-2026-08": "old-key".to_utf8_buffer(),
|
||||
"key-2026-09": "test-key".to_utf8_buffer(),
|
||||
}
|
||||
for token in [TOKEN_SIGNED_WITH_OLD_KEY, TOKEN_SIGNED_WITH_NEW_KEY]:
|
||||
var match_net := MatchNet.new()
|
||||
assert_true(match_net.configure_join_authorisations([token], ROTATION_CONTEXT, keys), "rotation fixture configures")
|
||||
assert_true(match_net._valid_join_authorisation(token), "a token signed by any currently-valid key is accepted")
|
||||
|
||||
|
||||
func test_join_authorisation_rejects_a_key_id_outside_the_set() -> void:
|
||||
# Rotation completed: the retired key is dropped, so anything still signed
|
||||
# with it must stop being admitted.
|
||||
var keys := {"key-2026-09": "test-key".to_utf8_buffer()}
|
||||
var match_net := MatchNet.new()
|
||||
assert_true(match_net.configure_join_authorisations([TOKEN_SIGNED_WITH_RETIRED_KEY], ROTATION_CONTEXT, keys), "retired-key fixture configures")
|
||||
assert_true(not match_net._valid_join_authorisation(TOKEN_SIGNED_WITH_RETIRED_KEY), "a token naming a key outside the set is rejected")
|
||||
|
||||
|
||||
func test_join_authorisation_key_id_cannot_be_repointed_at_another_key() -> void:
|
||||
# KeyID is inside the signed bytes, so swapping it to name a key the server
|
||||
# does hold must fail verification rather than selecting that key.
|
||||
var payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(TOKEN_SIGNED_WITH_OLD_KEY).get_string_from_utf8())
|
||||
payload["Authorisation"]["KeyID"] = "key-2026-09"
|
||||
var repointed := Marshalls.raw_to_base64(JSON.stringify(payload).to_utf8_buffer())
|
||||
var keys := {
|
||||
"key-2026-08": "old-key".to_utf8_buffer(),
|
||||
"key-2026-09": "test-key".to_utf8_buffer(),
|
||||
}
|
||||
var match_net := MatchNet.new()
|
||||
assert_true(match_net.configure_join_authorisations([repointed], ROTATION_CONTEXT, keys), "repointed fixture configures")
|
||||
assert_true(not match_net._valid_join_authorisation(repointed), "the key ID is covered by the signature")
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
|
||||
|
||||
|
||||
func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
assert_true(state.begin_queue("ticket-1", "ranked"), "valid queue starts in QUEUED")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "next revision applies")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "proposal is visible")
|
||||
assert_true(state.can_cancel(), "authoritative cancel remains available before allocation")
|
||||
|
||||
|
||||
func test_ticket_projection_accepts_authoritative_accepted_phase() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "proposal phase applies")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 2, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid")
|
||||
assert_eq(state.phase, MatchmakingState.ACCEPTED, "accepted phase remains visible instead of forcing resync")
|
||||
assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket")
|
||||
|
||||
|
||||
func test_ticket_projection_accepts_post_match_lifecycle_states() -> void:
|
||||
for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
|
||||
var state := MatchmakingState.new()
|
||||
assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase)
|
||||
var revision := 1
|
||||
for next_phase in ["PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": revision, "state": next_phase, "playlist": "casual"}), "lifecycle phase applies: " + next_phase)
|
||||
revision += 1
|
||||
if next_phase == phase:
|
||||
break
|
||||
assert_eq(state.phase, phase, "post-match phase remains visible: " + phase)
|
||||
assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase)
|
||||
|
||||
|
||||
func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds")
|
||||
assert_eq(state.ticket_id, "ticket-1", "queue setup retains ticket identity")
|
||||
var resync_ids: Array[String] = [""]
|
||||
state.resync_required.connect(func(id: String) -> void: resync_ids[0] = id)
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-2", "revision": 1, "state": "PROPOSED"}), "another player's ticket is rejected")
|
||||
assert_eq(resync_ids[0], "ticket-1", "wrong resource requests recovery for current ticket")
|
||||
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid update cannot mutate phase")
|
||||
assert_true(state.needs_resync, "invalid identity is visible to recovery")
|
||||
|
||||
state.needs_resync = false
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 3, "state": "ALLOCATING"}), "revision gap is rejected")
|
||||
assert_eq(state.phase, MatchmakingState.QUEUED, "gap cannot skip authoritative state")
|
||||
|
||||
|
||||
func test_duplicate_conflict_and_stale_updates_are_safe() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "casual")
|
||||
var update := {"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "casual", "expires_at_unix": 100}
|
||||
assert_true(state.apply_ticket_update(update), "first update applies")
|
||||
assert_true(state.apply_ticket_update(update), "identical duplicate is idempotent")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "QUEUED", "playlist": "casual", "expires_at_unix": 100}), "same-revision conflict requests recovery")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "conflicting replay cannot rewind state")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 0, "state": "QUEUED"}), "stale update is ignored")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state")
|
||||
|
||||
|
||||
func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-transition", "casual")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "legal transition applies")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "LIVE", "playlist": "casual"}), "higher revision cannot jump phases")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "illegal jump cannot mutate phase")
|
||||
state.needs_resync = false
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "ACCEPTED", "playlist": "casual"}), "legal next transition applies")
|
||||
state.needs_resync = false
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 3, "state": "PROPOSED", "playlist": "casual"}), "higher revision cannot rewind after acceptance")
|
||||
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
|
||||
|
||||
|
||||
func test_authoritative_ticket_snapshot_can_cross_missed_forward_revisions_but_not_rewind() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-snapshot", "casual")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "incremental proposal applies")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 5, "state": "ASSIGNMENT_READY", "playlist": "casual"}, true), "owner-scoped REST snapshot crosses missed forward states")
|
||||
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "authoritative recovery reaches assignment readiness")
|
||||
state.needs_resync = false
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 6, "state": "QUEUED", "playlist": "casual"}, true), "authoritative snapshot cannot rewind an assigned match")
|
||||
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "rejected snapshot cannot mutate phase")
|
||||
|
||||
|
||||
func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-revision", "casual")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-revision", "revision": 1.5, "state": "PROPOSED"}), "fractional ticket revision is rejected")
|
||||
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision is rejected")
|
||||
|
||||
|
||||
func test_ticket_update_rejects_invalid_playlist_without_mutation() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-playlist", "casual")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-playlist", "revision": 1, "state": "PROPOSED", "playlist": "admin"}), "unknown playlist is rejected")
|
||||
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid playlist cannot change phase")
|
||||
assert_eq(state.playlist, "casual", "invalid playlist cannot change playlist")
|
||||
|
||||
|
||||
func test_ticket_and_proposal_epoch_metadata_rejects_malformed_values() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-epoch", "casual")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "expires_at_unix": "not-a-time"}), "malformed ticket expiry is rejected")
|
||||
assert_eq(state.phase, MatchmakingState.QUEUED, "malformed ticket expiry cannot change phase")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "enqueued_at_unix": -1}), "negative enqueue time is rejected")
|
||||
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-epoch", "revision": 1, "state": "OPEN", "expires_at_unix": 1.25}), "fractional proposal expiry is rejected")
|
||||
|
||||
|
||||
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "casual")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 1, "state": "OPEN"}), "open proposal applies")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "open proposal is visible")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 2, "state": "DECLINED"}), "declined proposal applies")
|
||||
assert_eq(state.phase, MatchmakingState.QUEUED, "decline returns the requeued ticket to search")
|
||||
assert_true(state.can_cancel(), "requeued ticket can be cancelled")
|
||||
|
||||
var expired := MatchmakingState.new()
|
||||
expired.begin_queue("ticket-2", "casual")
|
||||
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 1, "state": "OPEN"}), "second proposal opens")
|
||||
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 2, "state": "EXPIRED"}), "expired proposal applies")
|
||||
assert_eq(expired.phase, MatchmakingState.QUEUED, "expiry returns the requeued ticket to search")
|
||||
assert_true(expired.can_cancel(), "requeued ticket can be cancelled")
|
||||
|
||||
var cancelled := MatchmakingState.new()
|
||||
cancelled.begin_queue("ticket-3", "casual")
|
||||
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 1, "state": "OPEN"}), "third proposal opens")
|
||||
cancelled.phase = MatchmakingState.CANCELLED
|
||||
assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 2, "state": "DECLINED"}), "decline after ticket cancellation is accepted")
|
||||
assert_eq(cancelled.phase, MatchmakingState.CANCELLED, "proposal decline cannot resurrect a cancelled ticket")
|
||||
|
||||
|
||||
func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-proposal-transition", "casual")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 1, "state": "OPEN"}), "proposal opens")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 2, "state": "ACCEPTED"}), "proposal accepts")
|
||||
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 3, "state": "OPEN"}), "accepted proposal cannot reopen")
|
||||
assert_eq(state.proposal_state, "ACCEPTED", "illegal proposal transition cannot mutate state")
|
||||
state.needs_resync = false
|
||||
var declined := MatchmakingState.new()
|
||||
declined.begin_queue("ticket-proposal-declined", "casual")
|
||||
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 1, "state": "OPEN"}), "second proposal opens")
|
||||
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 2, "state": "DECLINED"}), "second proposal declines")
|
||||
assert_true(not declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 3, "state": "ACCEPTED"}), "declined proposal cannot accept")
|
||||
|
||||
|
||||
func test_terminal_proposal_is_not_an_active_recovery_target() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-terminal-proposal", "casual")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 1, "state": "OPEN"}), "proposal opens")
|
||||
assert_true(state.has_open_proposal(), "open proposal is an active recovery target")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 2, "state": "EXPIRED"}), "proposal expires")
|
||||
assert_true(not state.has_open_proposal(), "terminal proposal uses ticket recovery instead")
|
||||
assert_true(state.prepare_proposal_recovery("proposal_second_123"), "a later proposal can replace a terminal proposal identity")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal_second_123", "revision": 4, "state": "OPEN"}), "recovered later proposal accepts its authoritative revision")
|
||||
assert_true(state.has_open_proposal(), "later proposal becomes the active recovery target")
|
||||
assert_true(not state.prepare_proposal_recovery("proposal_third_1234"), "an open proposal cannot be replaced by another identity")
|
||||
|
||||
|
||||
func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "ranked")
|
||||
state.mark_assignment_ready()
|
||||
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "assignment readiness is visible")
|
||||
state.mark_connecting()
|
||||
assert_eq(state.phase, MatchmakingState.CONNECTING, "transport connection is visible")
|
||||
state.mark_live()
|
||||
assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible")
|
||||
|
||||
|
||||
func test_expiry_is_distinct_from_generic_failure_and_remains_visible() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "casual")
|
||||
state.expire("Queue ticket expired")
|
||||
assert_eq(state.phase, MatchmakingState.EXPIRED, "expired ticket has a terminal expiry state")
|
||||
assert_eq(state.message, "Queue ticket expired", "expiry reason is visible")
|
||||
assert_true(not state.can_cancel(), "expired ticket cannot be cancelled")
|
||||
|
||||
|
||||
func test_restart_restore_requires_valid_identity_and_requests_authoritative_recovery() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket_1234567890", "playlist": "casual", "revision": 2}), "valid active snapshot restores")
|
||||
assert_true(state.needs_resync, "restored active state must recover from the server")
|
||||
assert_eq(state.revision, 2, "revision is retained for diagnostics")
|
||||
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected")
|
||||
assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state")
|
||||
assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected")
|
||||
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": "2"}), "string revision is rejected")
|
||||
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "enqueued_at_unix": 1.5}), "fractional epoch is rejected")
|
||||
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "proposal_state": "OPEN"}), "proposal state without identity is rejected")
|
||||
|
||||
|
||||
func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
assert_true(state.begin_queue("ticket_wait_123456", "casual"), "queue setup succeeds")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket_wait_123456", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies")
|
||||
assert_eq(state.waited_seconds(1065), 65, "wait uses server enqueue time")
|
||||
var restored := MatchmakingState.new()
|
||||
assert_true(restored.restore_snapshot(state.snapshot()), "snapshot restores")
|
||||
assert_eq(restored.waited_seconds(1065), 65, "authoritative wait survives restore")
|
||||
assert_true(not restored.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-short", "playlist": "casual"}), "short snapshot ticket id is rejected")
|
||||
assert_true(not restored.restore_snapshot({"phase": "PROPOSED", "ticket_id": "ticket_wait_123456", "playlist": "casual", "proposal_id": "proposal/unsafe", "proposal_state": "OPEN"}), "unsafe snapshot proposal id is rejected")
|
||||
@@ -0,0 +1,46 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const Matchmaking = preload("res://scripts/matchmaking.gd")
|
||||
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
|
||||
|
||||
|
||||
func test_every_backend_phase_has_a_nonempty_user_message() -> void:
|
||||
for phase in [MatchmakingState.IDLE, MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE, MatchmakingState.RESULT_PENDING, MatchmakingState.COMPLETED, MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED]:
|
||||
assert_true(not Matchmaking.phase_label(phase).is_empty(), "phase %s has visible copy" % phase)
|
||||
|
||||
|
||||
func test_terminal_state_policy_does_not_leave_cancel_or_proposal_actions_enabled() -> void:
|
||||
for phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
|
||||
assert_true(Matchmaking._is_terminal(phase), "phase %s is terminal" % phase)
|
||||
assert_true(not Matchmaking._is_terminal(MatchmakingState.QUEUED), "queued search remains active")
|
||||
assert_true(not Matchmaking._is_terminal(MatchmakingState.PROPOSED), "proposal remains actionable")
|
||||
assert_true(Matchmaking._can_start_new_search(MatchmakingState.FAILED), "failed search can be retried")
|
||||
assert_true(not Matchmaking._can_start_new_search(MatchmakingState.LIVE), "live match cannot start a second search")
|
||||
assert_true(Matchmaking._can_start_new_search(MatchmakingState.COMPLETED), "completed match can start a new search")
|
||||
|
||||
|
||||
func test_proposal_countdown_uses_authoritative_expiry_and_clamps_after_expiry() -> void:
|
||||
assert_eq(Matchmaking.proposal_countdown_text(1100, 1000), "Review proposal · 100s remaining", "proposal countdown uses server expiry")
|
||||
assert_eq(Matchmaking.proposal_countdown_text(1000, 1001), "Review proposal · 0s remaining", "expired proposal countdown clamps to zero")
|
||||
assert_eq(Matchmaking.proposal_countdown_text(0, 1000), "Review the proposal before the countdown expires", "missing expiry retains compatible copy")
|
||||
|
||||
|
||||
func test_allocation_lifecycle_phases_have_specific_detail_copy() -> void:
|
||||
for phase in [MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE]:
|
||||
assert_true(not Matchmaking.phase_detail_label(phase).is_empty(), "phase %s has lifecycle detail copy" % phase)
|
||||
assert_true(Matchmaking.phase_detail_label(MatchmakingState.ALLOCATING).contains("dedicated"), "allocation explains dedicated server provisioning")
|
||||
assert_true(Matchmaking.phase_detail_label(MatchmakingState.CONNECTING).contains("Connecting"), "connecting explains the active transport step")
|
||||
|
||||
|
||||
func test_queue_wait_copy_explains_progress_without_trusting_negative_input() -> void:
|
||||
assert_eq(Matchmaking.queue_wait_detail_text(-4, -2), "Waiting 0s · looking for compatible players · revision 0", "negative metadata is clamped")
|
||||
assert_true(Matchmaking.queue_wait_detail_text(10, 3).contains("skill and latency"), "mid-wait explains the compatibility search")
|
||||
assert_true(Matchmaking.queue_wait_detail_text(30, 4).contains("keeping latency limits"), "long waits explain bounded widening")
|
||||
|
||||
|
||||
func test_latency_copy_fails_closed_and_explains_quality_boundaries() -> void:
|
||||
assert_eq(Matchmaking.latency_detail_text(-1.0), "Latency: measuring", "missing latency remains honest")
|
||||
assert_eq(Matchmaking.latency_detail_text(INF), "Latency: measuring", "infinite latency fails closed")
|
||||
assert_eq(Matchmaking.latency_detail_text(50.0), "Latency: 50ms · excellent", "excellent boundary is inclusive")
|
||||
assert_eq(Matchmaking.latency_detail_text(100.0), "Latency: 100ms · good", "good boundary is inclusive")
|
||||
assert_eq(Matchmaking.latency_detail_text(100.1), "Latency: 100ms · high", "high latency is surfaced")
|
||||
@@ -0,0 +1,136 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Guards the menu screens against the overflow bug that made the main menu
|
||||
# unusable: the layout runs in a hard-fixed 1920x1080 logical viewport
|
||||
# (window/stretch/mode="viewport"), and a CenterContainer centres its child
|
||||
# rather than clipping it, so once the content's minimum height passed 1080 the
|
||||
# top and bottom spilled off-screen with no way to reach them. In a debug build
|
||||
# the main menu's DevSection pushed it to roughly 1120px, cutting off both the
|
||||
# title and the last button.
|
||||
#
|
||||
# What this file can and cannot do, stated plainly: it asserts the *structure*
|
||||
# that makes overflow reachable — the bottom-most control of each screen sits
|
||||
# inside a ScrollContainer, and that container follows focus so keyboard and
|
||||
# controller navigation cannot strand the player on an off-screen row. It does
|
||||
# not measure anything, so it cannot prove nothing visually clips; that check is
|
||||
# manual, at several window sizes. It exists to stop the wrapper being removed
|
||||
# or a new section being added outside it.
|
||||
#
|
||||
# Scenes are inspected through PackedScene.get_state() rather than instantiated.
|
||||
# main_menu.gd and lobby.gd connect NetworkManager signals and scan res://bots
|
||||
# in _ready(), so instantiating them in a unit test would be doing real work to
|
||||
# answer a question about the scene file.
|
||||
|
||||
# scene path -> the control furthest down that screen, i.e. the first thing to
|
||||
# be lost to overflow. Naming a specific leaf rather than "some ScrollContainer
|
||||
# exists" is what makes this assertion say something: a wrapper that does not
|
||||
# actually contain the content would still pass the weaker version.
|
||||
const DEEPEST_CONTROLS := {
|
||||
"res://scenes/main_menu.tscn": "SpectateButton",
|
||||
"res://scenes/settings.tscn": "ResetButton",
|
||||
"res://scenes/lobby.tscn": "LeaveButton",
|
||||
"res://scenes/matchmaking.tscn": "BackButton",
|
||||
}
|
||||
|
||||
|
||||
# Returns node index -> "Parent/Path/Name" for every node in the scene state,
|
||||
# reconstructing full paths from SceneState's parent-relative storage.
|
||||
func _node_paths(state: SceneState) -> Dictionary:
|
||||
var paths := {}
|
||||
for i in state.get_node_count():
|
||||
var parent := state.get_node_path(i, true)
|
||||
var name := String(state.get_node_name(i))
|
||||
var parent_str := String(parent)
|
||||
if parent_str == "." or parent_str == "":
|
||||
paths[i] = name
|
||||
else:
|
||||
paths[i] = "%s/%s" % [parent_str, name]
|
||||
return paths
|
||||
|
||||
|
||||
func _property(state: SceneState, index: int, wanted: String, fallback):
|
||||
for p in state.get_node_property_count(index):
|
||||
if String(state.get_node_property_name(index, p)) == wanted:
|
||||
return state.get_node_property_value(index, p)
|
||||
return fallback
|
||||
|
||||
|
||||
func test_every_menu_scene_loads() -> void:
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene := load(scene_path)
|
||||
assert_true(scene is PackedScene, "%s loads as a PackedScene" % scene_path)
|
||||
|
||||
|
||||
func test_the_bottom_of_each_menu_sits_inside_a_scroll_container() -> void:
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
assert_true(false, "%s failed to load" % scene_path)
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
var paths := _node_paths(state)
|
||||
|
||||
# Collect the paths of every ScrollContainer in the scene...
|
||||
var scroll_paths := []
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) == "ScrollContainer":
|
||||
scroll_paths.append(paths[i])
|
||||
assert_true(not scroll_paths.is_empty(), "%s has a ScrollContainer" % scene_path)
|
||||
|
||||
# ...then require the deepest control to live under one of them.
|
||||
var wanted: String = DEEPEST_CONTROLS[scene_path]
|
||||
var found_path := ""
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_name(i)) == wanted:
|
||||
found_path = paths[i]
|
||||
break
|
||||
assert_true(found_path != "", "%s contains %s" % [scene_path, wanted])
|
||||
if found_path == "":
|
||||
continue
|
||||
|
||||
var scrolled := false
|
||||
for scroll_path in scroll_paths:
|
||||
if found_path.begins_with(String(scroll_path) + "/"):
|
||||
scrolled = true
|
||||
break
|
||||
assert_true(scrolled, "%s's %s is inside a ScrollContainer (at %s)" % [scene_path, wanted, found_path])
|
||||
|
||||
|
||||
func test_menu_scroll_containers_follow_focus() -> void:
|
||||
# Without follow_focus, grab_focus() on a control below the fold (main_menu
|
||||
# focuses FreePlayButton on ready) leaves the view showing something else,
|
||||
# and controller navigation walks focus off-screen silently.
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
var checked := 0
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) != "ScrollContainer":
|
||||
continue
|
||||
checked += 1
|
||||
assert_true(
|
||||
_property(state, i, "follow_focus", false) == true,
|
||||
"%s/%s has follow_focus" % [scene_path, state.get_node_name(i)]
|
||||
)
|
||||
assert_true(checked > 0, "%s has at least one ScrollContainer to check" % scene_path)
|
||||
|
||||
|
||||
func test_menu_scroll_containers_do_not_scroll_horizontally() -> void:
|
||||
# Horizontal scrolling is disabled so content is clamped to the window
|
||||
# width instead of growing a second scrollbar — the dev bot dropdowns are
|
||||
# filled from filenames and would otherwise widen the whole menu.
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) != "ScrollContainer":
|
||||
continue
|
||||
assert_eq(
|
||||
_property(state, i, "horizontal_scroll_mode", ScrollContainer.SCROLL_MODE_AUTO),
|
||||
ScrollContainer.SCROLL_MODE_DISABLED,
|
||||
"%s/%s disables horizontal scrolling" % [scene_path, state.get_node_name(i)]
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs8c31fs0tfhe
|
||||
@@ -97,7 +97,7 @@ func test_snapshot_roundtrip_seven_bodies() -> void:
|
||||
|
||||
var packet := NetCodec.pack_snapshot(555, -2, 1234, segment)
|
||||
assert_eq(packet.size(), NetCodec.SNAPSHOT_CLIENT_HEADER_SIZE + segment.size(), "full packet size")
|
||||
assert_eq(packet.size(), 169, "matches multiplayer-todo.md §2.4's 169 B payload figure for 7 bodies")
|
||||
assert_eq(packet.size(), 169, "matches MULTIPLAYER_SPEC.md §2.4's 169 B payload figure for 7 bodies")
|
||||
|
||||
var decoded := NetCodec.unpack_snapshot(packet)
|
||||
assert_eq(decoded["last_input_seq"], 555, "last_input_seq")
|
||||
|
||||
@@ -99,6 +99,14 @@ func test_genuine_missing_history_is_still_a_hard_snap() -> void:
|
||||
assert_eq(decision["mode"], "hard", "%s must still hard-correct" % status)
|
||||
|
||||
|
||||
func test_warmup_ack_before_first_prediction_is_skipped() -> void:
|
||||
var history := LocalPredictionHistory.new()
|
||||
var authority := _authoritative()
|
||||
var comparison := history.compare_authoritative(0, authority)
|
||||
assert_eq(comparison["status"], "warmup_not_recorded", "pre-history acknowledgement is startup, not loss")
|
||||
assert_eq(NetShipPredictor.decide(comparison, false, false)["mode"], "skip", "startup acknowledgement must not hard-snap")
|
||||
|
||||
|
||||
func test_a_reset_still_wins_over_an_unsimulated_gap() -> void:
|
||||
# Ordering guard: reset_gen is an epoch boundary and outranks everything,
|
||||
# including the new skip path — otherwise a gap landing on the reset
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Covers PlayerShipController's translation of input actions into a ShipAction.
|
||||
#
|
||||
# The point of most of these is the *analog* path. The controller used to read
|
||||
# is_action_pressed(), which is a bool, so a half-pulled trigger and a fully
|
||||
# pulled one produced identical full thrust. A test that only ever pressed
|
||||
# actions at full strength could not tell the two implementations apart — so
|
||||
# these press at fractional strength, which only the get_action_strength()
|
||||
# version can reproduce.
|
||||
#
|
||||
# Input.action_press writes to the global input state, so every test must
|
||||
# release what it pressed before returning or it leaks into later cases.
|
||||
|
||||
const ACTIONS_USED := [
|
||||
"move_forward", "move_back", "move_left", "move_right", "move_up", "move_down",
|
||||
"turn_left", "turn_right", "pitch_up", "pitch_down", "roll_left", "roll_right",
|
||||
"turbo",
|
||||
]
|
||||
|
||||
|
||||
func _controller() -> PlayerShipController:
|
||||
return PlayerShipController.new()
|
||||
|
||||
|
||||
func _release_all() -> void:
|
||||
for action in ACTIONS_USED:
|
||||
Input.action_release(action)
|
||||
|
||||
|
||||
func test_full_strength_matches_the_historical_digital_values() -> void:
|
||||
# The keyboard path must be unchanged by the move to analog: a held key
|
||||
# reports strength 1.0, so every axis lands on exactly ±1.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 1.0)
|
||||
Input.action_press("move_right", 1.0)
|
||||
Input.action_press("move_up", 1.0)
|
||||
var action := controller.get_action()
|
||||
assert_almost_eq(action.thrust.z, 1.0, 0.001, "forward thrust")
|
||||
assert_almost_eq(action.thrust.x, 1.0, 0.001, "right thrust")
|
||||
assert_almost_eq(action.thrust.y, 1.0, 0.001, "up thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_back", 1.0)
|
||||
Input.action_press("move_left", 1.0)
|
||||
Input.action_press("move_down", 1.0)
|
||||
action = controller.get_action()
|
||||
assert_almost_eq(action.thrust.z, -1.0, 0.001, "backward thrust")
|
||||
assert_almost_eq(action.thrust.x, -1.0, 0.001, "left thrust")
|
||||
assert_almost_eq(action.thrust.y, -1.0, 0.001, "down thrust")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_rotation_sign_conventions_are_unchanged() -> void:
|
||||
# Each action must move the ship the way its NAME says. The physics
|
||||
# directions were measured by driving a real Ship through ship.tscn rather
|
||||
# than reasoned about, because the right-hand rule is exactly the kind of
|
||||
# thing that reads as obvious and comes out backwards:
|
||||
#
|
||||
# rotation.x > 0 -> nose UP (torque about local +X)
|
||||
# rotation.y > 0 -> nose LEFT (torque about local +Y)
|
||||
# rotation.z > 0 -> banks LEFT (torque about local +Z)
|
||||
#
|
||||
# pitch was inverted against this for a long time — get_axis's arguments
|
||||
# were the wrong way round, so "pitch_down" raised the nose and the I/K keys
|
||||
# each did the opposite of their label. Nothing caught it because the sign
|
||||
# was self-consistent everywhere it was used; only comparing against the
|
||||
# physics reveals it.
|
||||
var controller := _controller()
|
||||
var restore := InputSettings.invert_pitch
|
||||
InputSettings.invert_pitch = false
|
||||
|
||||
Input.action_press("turn_left", 1.0)
|
||||
Input.action_press("pitch_up", 1.0)
|
||||
Input.action_press("roll_left", 1.0)
|
||||
var action := controller.get_action()
|
||||
assert_almost_eq(action.rotation.y, 1.0, 0.001, "yaw left is positive")
|
||||
assert_almost_eq(action.rotation.x, 1.0, 0.001, "pitch UP is positive (nose up)")
|
||||
assert_almost_eq(action.rotation.z, 1.0, 0.001, "roll left is positive")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("turn_right", 1.0)
|
||||
Input.action_press("pitch_down", 1.0)
|
||||
Input.action_press("roll_right", 1.0)
|
||||
action = controller.get_action()
|
||||
assert_almost_eq(action.rotation.y, -1.0, 0.001, "yaw right is negative")
|
||||
assert_almost_eq(action.rotation.x, -1.0, 0.001, "pitch DOWN is negative (nose down)")
|
||||
assert_almost_eq(action.rotation.z, -1.0, 0.001, "roll right is negative")
|
||||
_release_all()
|
||||
|
||||
InputSettings.invert_pitch = restore
|
||||
|
||||
|
||||
func test_partial_strength_produces_partial_thrust() -> void:
|
||||
# The analog assertion. A digital is_action_pressed() implementation would
|
||||
# return 1.0 here and fail.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 0.5)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "half trigger is half thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_up", 0.25)
|
||||
assert_almost_eq(controller.get_action().thrust.y, 0.25, 0.001, "quarter deflection is quarter thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("turn_left", 0.3)
|
||||
assert_almost_eq(controller.get_action().rotation.y, 0.3, 0.001, "partial stick is partial yaw")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_opposing_inputs_subtract_rather_than_saturate() -> void:
|
||||
# Both halves of one stick axis can report a strength at once; the result
|
||||
# must be their difference, not whichever was read last.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 0.75)
|
||||
Input.action_press("move_back", 0.25)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "opposed thrust subtracts")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_forward", 0.4)
|
||||
Input.action_press("move_back", 0.4)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.0, 0.001, "equal opposed thrust cancels")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_no_input_is_a_zero_action() -> void:
|
||||
var controller := _controller()
|
||||
_release_all()
|
||||
var action := controller.get_action()
|
||||
assert_eq(action.thrust, Vector3.ZERO, "idle thrust")
|
||||
assert_eq(action.rotation, Vector3.ZERO, "idle rotation")
|
||||
assert_true(not action.turbo, "idle turbo")
|
||||
|
||||
|
||||
func test_invert_pitch_flips_only_the_pitch_axis() -> void:
|
||||
var controller := _controller()
|
||||
var restore := InputSettings.invert_pitch
|
||||
|
||||
Input.action_press("pitch_down", 1.0)
|
||||
Input.action_press("turn_left", 1.0)
|
||||
|
||||
InputSettings.invert_pitch = false
|
||||
var normal := controller.get_action().copy()
|
||||
InputSettings.invert_pitch = true
|
||||
var inverted := controller.get_action().copy()
|
||||
|
||||
assert_almost_eq(inverted.rotation.x, -normal.rotation.x, 0.001, "invert flips pitch")
|
||||
assert_almost_eq(inverted.rotation.y, normal.rotation.y, 0.001, "invert leaves yaw alone")
|
||||
|
||||
InputSettings.invert_pitch = restore
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_turbo_is_a_boolean() -> void:
|
||||
var controller := _controller()
|
||||
Input.action_press("turbo", 1.0)
|
||||
assert_true(controller.get_action().turbo, "turbo held")
|
||||
Input.action_release("turbo")
|
||||
assert_true(not controller.get_action().turbo, "turbo released")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_the_returned_action_is_reused_between_ticks() -> void:
|
||||
# get_action() documents that it returns a reused instance and overwrites
|
||||
# every axis. Callers that keep an action past its tick must copy() it —
|
||||
# local_input_timeline.gd and the prediction ring rely on that contract, so
|
||||
# assert both halves of it.
|
||||
var controller := _controller()
|
||||
Input.action_press("move_forward", 1.0)
|
||||
var first := controller.get_action()
|
||||
_release_all()
|
||||
var second := controller.get_action()
|
||||
assert_true(first == second, "the same ShipAction instance is returned each tick")
|
||||
assert_almost_eq(second.thrust.z, 0.0, 0.001, "releasing clears the axis rather than leaving it stale")
|
||||
@@ -0,0 +1 @@
|
||||
uid://xpr311fjhw2m
|
||||
@@ -71,19 +71,30 @@ func test_physics_engine_is_jolt() -> void:
|
||||
func test_required_autoloads_are_registered() -> void:
|
||||
# NetworkManager in particular is reached by name from many scripts; losing
|
||||
# it from [autoload] fails only at the point of use, deep in a smoke test.
|
||||
for autoload_name in ["GameSettings", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
|
||||
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "InputSettings", "NetworkManager", "MatchNet", "MatchSim"]:
|
||||
assert_true(
|
||||
ProjectSettings.has_setting("autoload/" + autoload_name),
|
||||
"autoload/%s registered" % autoload_name
|
||||
)
|
||||
|
||||
|
||||
func test_matchmaking_scene_is_the_control_plane_entry_point() -> void:
|
||||
var scene := load("res://scenes/matchmaking.tscn")
|
||||
assert_true(scene != null, "matchmaking scene exists")
|
||||
assert_true(FileAccess.file_exists("res://scripts/matchmaking.gd"), "matchmaking controller exists")
|
||||
|
||||
|
||||
func test_test_hook_autoloads_are_not_shipped() -> void:
|
||||
# main_menu_test_hooks / lobby_test_hooks are added to [autoload] by hand
|
||||
# when running those scene-level smoke tests, and must be removed again —
|
||||
# see CLAUDE.md. Shipping one registered would run test code in the real
|
||||
# game, so fail here rather than discovering it in a build.
|
||||
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks"]:
|
||||
# McpInteractionServer is registered automatically by the vendored godot-mcp
|
||||
# tooling whenever it launches the project, and is left behind in
|
||||
# project.godot afterwards. It is a debug channel into a running game, so
|
||||
# shipping it registered is worse than a stray test hook, and it arrives
|
||||
# without anyone having typed it.
|
||||
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks", "McpInteractionServer"]:
|
||||
assert_true(
|
||||
not ProjectSettings.has_setting("autoload/" + hook_name),
|
||||
"test hook autoload/%s must not be registered" % hook_name
|
||||
|
||||
@@ -26,6 +26,7 @@ func test_defaults_apply_when_nothing_is_given() -> void:
|
||||
assert_true(config.is_valid(), "an empty command line is valid")
|
||||
assert_eq(config.get_value("port"), 7777, "default port")
|
||||
assert_eq(config.get_value("max-matches"), 0, "0 means run forever")
|
||||
assert_eq(config.get_value("max-overtime-seconds"), 900.0, "allocated sudden death has a finite safety cap")
|
||||
assert_eq(config.get_value("log-level"), "info", "default log level")
|
||||
|
||||
|
||||
@@ -98,8 +99,11 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void:
|
||||
assert_true(not _parse(["--port=70000"]).is_valid(), "port 70000 is out of range")
|
||||
assert_true(not _parse(["--max-clients=0"]).is_valid(), "a server for nobody is rejected")
|
||||
assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected")
|
||||
assert_true(not _parse(["--max-overtime-seconds=0"]).is_valid(), "an unbounded allocated overtime cap is rejected")
|
||||
assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected")
|
||||
assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected")
|
||||
assert_true(not _parse(["--arena-path=res://scenes/arena_01_elevated.tscn"]).is_valid(), "an elevated arena cannot be selected for allocated ranked play")
|
||||
assert_true(_parse(["--arena-path=res://scenes/arena_01.tscn"]).is_valid(), "a ranked-eligible arena path is accepted")
|
||||
assert_true(not _parse(["--smoke-force-goal-after=-2"]).is_valid(), "only -1 disables the deterministic smoke goal")
|
||||
# Control: the same flags at legal values all pass together.
|
||||
var ok = _parse(["--port=7000", "--max-clients=6", "--match-length=90", "--log-level=warn", "--arena-rotation=random"])
|
||||
@@ -126,3 +130,59 @@ func test_help_is_requested_without_needing_a_valid_command_line() -> void:
|
||||
assert_true(config.help_requested, "--help is recognised")
|
||||
var short = _parse(["-h"])
|
||||
assert_true(short.help_requested, "-h too")
|
||||
|
||||
|
||||
func test_allocated_mode_is_opt_in_and_requires_compatibility_manifest() -> void:
|
||||
var community = _parse([])
|
||||
assert_true(community.is_valid(), "community defaults remain valid")
|
||||
assert_eq(community.get_value("allocated-mode"), false, "allocation is opt-in")
|
||||
var incomplete = _parse(["--allocated-mode", "--transport=enet"])
|
||||
assert_true(not incomplete.is_valid(), "allocated mode cannot start without its manifest")
|
||||
var valid = _parse([
|
||||
"--allocated-mode", "--match-id=match_1234567890123456", "--server-id=server_1234567890123456",
|
||||
"--playlist-version=2026-08-31", "--client-build=client-2026-08-31", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600), "--server-image-digest=sha256:" + "a".repeat(64),
|
||||
"--playlist=casual", "--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
|
||||
])
|
||||
assert_true(valid.is_valid(), "a complete allocated compatibility manifest is accepted: %s" % str(valid.errors))
|
||||
|
||||
|
||||
func test_allocated_mode_rejects_invalid_transport_region_or_digest() -> void:
|
||||
var args := [
|
||||
"--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v",
|
||||
"--client-build=client", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600),
|
||||
"--server-image-digest=sha256:" + "g".repeat(64), "--transport=udp", "--region=AP"
|
||||
]
|
||||
var config = _parse(args)
|
||||
assert_true(not config.is_valid(), "invalid compatibility values are rejected")
|
||||
var unsafe_id = _parse([
|
||||
"--allocated-mode", "--match-id=short", "--server-id=server/unsafe", "--playlist-version=v",
|
||||
"--client-build=client", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600),
|
||||
"--server-image-digest=sha256:" + "a".repeat(64), "--playlist=casual", "--transport=enet", "--region=EU",
|
||||
"--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
|
||||
])
|
||||
assert_true(not unsafe_id.is_valid(), "short or unsafe allocated identifiers are rejected")
|
||||
|
||||
|
||||
func test_allocated_mode_rejects_missing_or_expired_assignment_manifest_fields() -> void:
|
||||
var missing = _parse(["--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v", "--server-image-digest=sha256:" + "a".repeat(64), "--transport=enet", "--region=EU"])
|
||||
assert_true(not missing.is_valid(), "client build and expiry are required")
|
||||
var expired = _parse(["--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v", "--client-build=client", "--assignment-expiry-unix=1", "--server-image-digest=sha256:" + "a".repeat(64), "--transport=enet", "--region=EU"])
|
||||
assert_true(not expired.is_valid(), "expired assignment is rejected")
|
||||
|
||||
|
||||
func test_allocated_start_floor_is_the_verified_roster_size() -> void:
|
||||
var boot = preload("res://scripts/server_boot.gd")
|
||||
assert_eq(boot.required_min_players(true, 6, 1), 6, "allocated six-player roster cannot start with one player")
|
||||
assert_eq(boot.required_min_players(true, 2, 6), 2, "allocated casual roster uses its complete size")
|
||||
assert_eq(boot.required_min_players(false, 1, 1), 1, "direct server keeps its configured floor")
|
||||
|
||||
|
||||
func test_connection_reporting_requires_safe_workload_configuration() -> void:
|
||||
var boot = preload("res://scripts/server_boot.gd")
|
||||
assert_true(boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "allocated workload configuration is accepted")
|
||||
assert_true(not boot.valid_connection_report_configuration("", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "allocated startup fails closed without a control-plane lease URL")
|
||||
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "", "match-1234567890", "server-123456789", "player-123456789"), "allocated startup fails closed without a workload lease credential")
|
||||
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080?token=leak", "signed-token", "match-1234567890", "server-123456789", "player-123456789"), "query-bearing control-plane URL is rejected")
|
||||
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "token\nforged", "match-1234567890", "server-123456789", "player-123456789"), "header injection token is rejected")
|
||||
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "short", "server-123456789", "player-123456789"), "non-opaque match identity is rejected")
|
||||
assert_true(not boot.valid_connection_report_configuration("http://control-plane:8080", "signed-token", "match-1234567890", "server-123456789", "short"), "non-opaque player identity is rejected")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const ServerControlScript = preload("res://scripts/server_control.gd")
|
||||
|
||||
|
||||
func test_control_rejects_invalid_port_and_starts_loopback_listener() -> void:
|
||||
var control = ServerControlScript.new()
|
||||
assert_eq(control.start(0), ERR_INVALID_PARAMETER, "control rejects port zero")
|
||||
var port := 18000 + (Time.get_ticks_usec() % 1000)
|
||||
assert_eq(control.start(port, "drain-secret"), OK, "control starts on a valid loopback port")
|
||||
control.stop()
|
||||
control.queue_free()
|
||||
|
||||
|
||||
func test_process_ready_and_drain_state_are_monotonic() -> void:
|
||||
var control = ServerControlScript.new()
|
||||
assert_true(not control.is_draining(), "control starts non-draining")
|
||||
control.set_process_ready(true)
|
||||
assert_true(not control.is_draining(), "process readiness does not imply draining")
|
||||
control.stop()
|
||||
control.queue_free()
|
||||
@@ -0,0 +1,19 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
func test_allocated_initial_connect_policy_has_explicit_boundaries() -> void:
|
||||
var loop = preload("res://scripts/server_match_loop.gd")
|
||||
assert_eq(loop.allocated_initial_connect_action("ranked", 29999, 5, 6, true, true), loop.ALLOCATED_WAIT, "ranked waits before 30 seconds")
|
||||
assert_eq(loop.allocated_initial_connect_action("ranked", 30000, 5, 6, true, true), loop.ALLOCATED_CANCEL, "ranked cancels at 30 seconds")
|
||||
assert_eq(loop.allocated_initial_connect_action("casual", 59999, 2, 6, true, true), loop.ALLOCATED_WAIT, "casual waits before 60 seconds")
|
||||
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, true), loop.ALLOCATED_START_WITH_BOTS, "casual starts with bots when both teams are represented")
|
||||
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 2, 2, true, true), loop.ALLOCATED_START_WITH_BOTS, "complete relaxed casual roster starts with disclosed bots immediately")
|
||||
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 2, 2, true, false), loop.ALLOCATED_CANCEL, "malformed relaxed casual roster fails closed")
|
||||
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, false), loop.ALLOCATED_CANCEL, "casual cancels when one team is empty")
|
||||
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 6, 6, true, true), loop.ALLOCATED_READY, "complete roster is ready immediately")
|
||||
assert_eq(loop.allocated_initial_connect_action("ranked", 1000, 5, 5, true, true), loop.ALLOCATED_CANCEL, "ranked cannot shrink its expected roster")
|
||||
assert_eq(loop.allocated_initial_connect_action("other", 0, 1, 6, true, true), loop.ALLOCATED_CANCEL, "unknown allocated playlist fails closed")
|
||||
var instance = loop.new()
|
||||
instance.allocated_admission_armed = false
|
||||
instance.arm_allocated_admission()
|
||||
assert_true(instance.allocated_admission_armed, "durable readiness signal arms the local timeout")
|
||||
instance.free()
|
||||
@@ -0,0 +1,41 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const Client = preload("res://scripts/server_result_client.gd")
|
||||
|
||||
|
||||
func test_result_nonce_is_deterministic_and_score_bound() -> void:
|
||||
var first := Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED")
|
||||
assert_eq(first, Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED"), "retry keeps the exact nonce")
|
||||
assert_true(first != Client.result_nonce("match-123456789", "server-123456789", 2, 3, "CERTIFIED"), "a conflicting score cannot reuse the nonce")
|
||||
assert_true(first.length() >= 16, "nonce satisfies the control-plane minimum")
|
||||
|
||||
|
||||
func test_result_configuration_fails_closed() -> void:
|
||||
assert_true(Client.valid_configuration("https://control.invalid", "token", "match-123456789", "server-123456789"), "valid result reporter configuration is accepted")
|
||||
assert_true(not Client.valid_configuration("https://control.invalid?token=leak", "token", "match-123456789", "server-123456789"), "query-bearing endpoint is rejected")
|
||||
assert_true(not Client.valid_configuration("https://control@evil.invalid", "token", "match-123456789", "server-123456789"), "userinfo-bearing endpoint is rejected")
|
||||
assert_true(not Client.valid_configuration("https://control.invalid", "", "match-123456789", "server-123456789"), "empty bearer is rejected")
|
||||
|
||||
|
||||
func test_only_a_committed_result_acknowledgement_releases_the_match() -> void:
|
||||
assert_true(Client.response_is_accepted(202), "the endpoint's accepted response releases RESULTS")
|
||||
assert_true(not Client.response_is_accepted(200), "an unexpected generic success cannot lose the result")
|
||||
assert_true(not Client.response_is_accepted(422), "validation failure remains held for operator-visible retry")
|
||||
assert_true(not Client.response_is_accepted(503), "outage remains held for retry")
|
||||
|
||||
|
||||
func test_review_results_are_permitted_but_forged_states_are_not() -> void:
|
||||
var client := Client.new()
|
||||
assert_true(client.configure("https://control.invalid", "token", "match-123456789", "server-123456789"), "test client configures")
|
||||
# submit itself is asynchronous; the pure configuration boundary proves the
|
||||
# reporter can carry the REVIEW state selected by bounded overtime.
|
||||
assert_true(Client.result_nonce("match-123456789", "server-123456789", 1, 1, "REVIEW") != Client.result_nonce("match-123456789", "server-123456789", 1, 1, "CERTIFIED"), "integrity state binds the receipt identity")
|
||||
|
||||
|
||||
func test_match_net_forwards_review_integrity_to_the_reporter() -> void:
|
||||
var received: Array = []
|
||||
MatchNet.configure_result_submission(func(team_0: int, team_1: int, integrity: String): received.append_array([team_0, team_1, integrity]))
|
||||
assert_true(MatchNet.submit_authoritative_result({0: 1, 1: 1}, "REVIEW"), "configured reporter accepts the bounded-overtime outcome")
|
||||
assert_eq(received, [1, 1, "REVIEW"], "review state reaches the reporter and cannot become a rated result")
|
||||
MatchNet.configure_result_submission(Callable())
|
||||
assert_true(not MatchNet.submit_authoritative_result({0: 1, 1: 1}, "CERTIFIED"), "cleared reporter cannot silently claim result delivery")
|
||||
@@ -0,0 +1,29 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd")
|
||||
|
||||
|
||||
|
||||
|
||||
# Web-API ticket acquisition (task 7.6). Nothing in the project could obtain a
|
||||
# ticket before, so ControlPlaneClient.login_steam() had no production caller.
|
||||
# These run on stock Godot, which has no GodotSteam symbols, so they cover the
|
||||
# pure encoding and the unavailable path rather than a live Steam session.
|
||||
func test_web_api_ticket_is_unsupported_without_the_steam_runtime() -> void:
|
||||
if SteamBootstrap.is_runtime_available():
|
||||
return
|
||||
assert_true(not SteamBootstrap.supports_web_api_ticket(), "no ticket support without the custom build")
|
||||
assert_eq(SteamBootstrap.request_web_api_ticket(), 0, "requesting a ticket yields no handle")
|
||||
# Must not throw on stock Godot; cancelling a handle we never got is a no-op.
|
||||
SteamBootstrap.cancel_web_api_ticket(0)
|
||||
SteamBootstrap.cancel_web_api_ticket(17)
|
||||
|
||||
|
||||
func test_web_api_ticket_encoding_is_lowercase_hex() -> void:
|
||||
# The publisher Web API expects the raw ticket bytes hex encoded; the
|
||||
# backend rejects anything non-hex before it forwards a ticket to Valve.
|
||||
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray()), "", "an empty ticket encodes to nothing")
|
||||
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray([0x00, 0x0f, 0xa5, 0xff])), "000fa5ff", "bytes are zero-padded lowercase hex")
|
||||
var encoded := SteamBootstrap.encode_web_api_ticket(PackedByteArray([1, 2, 3, 4, 250]))
|
||||
assert_eq(encoded.length(), 10, "each byte becomes exactly two characters")
|
||||
assert_eq(encoded, encoded.to_lower(), "encoding is lowercase")
|
||||
@@ -0,0 +1,18 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const ShipAIControllerScript = preload("res://scripts/ship_ai_controller.gd")
|
||||
|
||||
func test_team_touch_credit_is_split_across_teammates() -> void:
|
||||
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.5, 2), 0.2, "half-weight touch is split between two teammates")
|
||||
|
||||
func test_team_touch_credit_never_exceeds_touch_payout() -> void:
|
||||
var credit := ShipAIControllerScript.team_touch_credit(0.8, 1.0, 1)
|
||||
assert_eq(credit, 0.8, "one teammate receives at most the touch payout")
|
||||
|
||||
func test_team_touch_credit_rejects_invalid_inputs() -> void:
|
||||
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.0, 2), 0.0, "zero weight is disabled")
|
||||
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 0.5, 0), 0.0, "no teammates receive no credit")
|
||||
assert_eq(ShipAIControllerScript.team_touch_credit(-1.0, 0.5, 2), 0.0, "negative payout cannot mint reward")
|
||||
|
||||
func test_team_touch_credit_clamps_weight() -> void:
|
||||
assert_eq(ShipAIControllerScript.team_touch_credit(0.8, 2.0, 2), 0.4, "weight above one is clamped")
|
||||
@@ -0,0 +1,21 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const TrainingModeScript = preload("res://scripts/training_mode.gd")
|
||||
|
||||
func test_wall_play_points_into_the_field() -> void:
|
||||
var velocity: Vector3 = TrainingModeScript.wall_state_velocity(false, 1.0, 8.0, 0.1, -0.2)
|
||||
assert_true(velocity.x < 0.0, "positive-side wall play travels inward")
|
||||
assert_almost_eq(velocity.length(), 8.0, 0.0001, "wall-play speed is preserved")
|
||||
|
||||
func test_rebound_points_into_the_selected_wall() -> void:
|
||||
var velocity: Vector3 = TrainingModeScript.wall_state_velocity(true, 1.0, 8.0, 0.1, -0.2)
|
||||
assert_true(velocity.x > 0.0, "positive-side rebound travels toward the wall")
|
||||
assert_almost_eq(velocity.length(), 8.0, 0.0001, "rebound speed is preserved")
|
||||
|
||||
func test_opposite_walls_mirror_the_normal_component() -> void:
|
||||
var positive: Vector3 = TrainingModeScript.wall_state_velocity(false, 1.0, 6.0, 0.0, 0.0)
|
||||
var negative: Vector3 = TrainingModeScript.wall_state_velocity(false, -1.0, 6.0, 0.0, 0.0)
|
||||
assert_eq(positive.x, -negative.x, "opposite wall starts mirror the x direction")
|
||||
|
||||
func test_negative_speed_fails_closed() -> void:
|
||||
assert_eq(TrainingModeScript.wall_state_velocity(false, 1.0, -1.0, 0.0, 0.0), Vector3.ZERO, "negative speed cannot create velocity")
|
||||
@@ -0,0 +1,137 @@
|
||||
extends Node
|
||||
|
||||
# Two-process real end-to-end proposal smoke test: extends
|
||||
# control_plane_smoke.gd's single-player login/queue/heartbeat/cancel
|
||||
# coverage to the matcher path -- two real Godot clients, two real queued
|
||||
# tickets, a real running server/cmd/matcher pairing them, both clients
|
||||
# observing the resulting proposal over the real WebSocket event stream and
|
||||
# accepting it for real. multiplayer-next.md 8.40 names this "a two-player
|
||||
# proposal round trip" as the next scoped extension to this harness.
|
||||
#
|
||||
# ControlPlaneClient is a singleton autoload, so one process can only ever be
|
||||
# one player -- this mirrors net_smoke.gd's own host/client two-process
|
||||
# pattern rather than trying to simulate two players in one process:
|
||||
#
|
||||
# godot --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
|
||||
# --control-plane-url=http://127.0.0.1:PORT --role=player-a
|
||||
# godot --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
|
||||
# --control-plane-url=http://127.0.0.1:PORT --role=player-b
|
||||
#
|
||||
# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1.
|
||||
|
||||
const TIMEOUT_SECONDS := 20.0
|
||||
|
||||
var _role := ""
|
||||
var _finished := false
|
||||
var _ticket_id := ""
|
||||
var _accept_sent := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var control_plane_url := ""
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--control-plane-url="):
|
||||
control_plane_url = arg.substr("--control-plane-url=".length())
|
||||
elif arg.begins_with("--role="):
|
||||
_role = arg.substr("--role=".length())
|
||||
if control_plane_url.is_empty() or (_role != "player-a" and _role != "player-b"):
|
||||
_finish(false, "missing --control-plane-url or --role=player-a|player-b")
|
||||
return
|
||||
if not ControlPlaneClient.configure(control_plane_url, "0:0"):
|
||||
_finish(false, "configure() rejected a valid-looking base URL")
|
||||
return
|
||||
|
||||
_ticket_id = "proposal-smoke-%s-%d" % [_role, Time.get_unix_time_from_system()]
|
||||
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
|
||||
ControlPlaneClient.request_failed.connect(_on_request_failed)
|
||||
ControlPlaneClient.state.changed.connect(_on_state_changed)
|
||||
|
||||
var web_api_ticket := "proposal-smoke-web-api-ticket-%s-%d" % [_role, Time.get_ticks_usec()]
|
||||
var err := ControlPlaneClient.login_steam(web_api_ticket)
|
||||
if err != OK:
|
||||
_finish(false, "login_steam() failed to start: %s" % error_string(err))
|
||||
return
|
||||
print("SMOKE[%s]: logging in against %s..." % [_role, control_plane_url])
|
||||
|
||||
var timer := Timer.new()
|
||||
timer.wait_time = TIMEOUT_SECONDS
|
||||
timer.one_shot = true
|
||||
timer.timeout.connect(func(): _finish(false, "timed out after %.1fs waiting for a proposal" % TIMEOUT_SECONDS))
|
||||
add_child(timer)
|
||||
timer.start()
|
||||
|
||||
|
||||
func _on_request_succeeded(operation: String, payload: Dictionary) -> void:
|
||||
if _finished:
|
||||
return
|
||||
if operation == "steam_session":
|
||||
print("SMOKE[%s]: logged in as %s, queueing for a 2-player casual match..." % [_role, ControlPlaneClient.player_id])
|
||||
var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1)
|
||||
if err != OK:
|
||||
_finish(false, "queue_create() failed to start: %s" % error_string(err))
|
||||
return
|
||||
if operation == "queue_create":
|
||||
print("SMOKE[%s]: ticket %s QUEUED, waiting for the matcher to propose a match..." % [_role, _ticket_id])
|
||||
return
|
||||
if operation.begins_with("proposal_"):
|
||||
if payload.get("state", "") != "ACCEPTED" and payload.get("state", "") != "OPEN":
|
||||
_finish(false, "unexpected proposal response after accept: %s" % payload)
|
||||
return
|
||||
if payload.get("state", "") == "ACCEPTED":
|
||||
_finish(true, "both players queued, the real matcher formed a proposal, and this client's accept was recorded")
|
||||
else:
|
||||
print("SMOKE[%s]: accepted; waiting for the other player..." % _role)
|
||||
var recovery_timer := get_tree().create_timer(1.0)
|
||||
recovery_timer.timeout.connect(_recover_after_accept)
|
||||
|
||||
|
||||
func _recover_after_accept() -> void:
|
||||
if _finished or ControlPlaneClient._operation != "" or ControlPlaneClient.state.proposal_id.is_empty():
|
||||
return
|
||||
var err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id)
|
||||
if err != OK and err != ERR_BUSY:
|
||||
_finish(false, "proposal recovery after accept failed to start: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_state_changed(snapshot: Dictionary) -> void:
|
||||
if _finished or _accept_sent or String(snapshot.get("proposal_state", "")) != "OPEN":
|
||||
return
|
||||
if ControlPlaneClient._operation != "":
|
||||
return # Already mid-request (e.g. the accept itself); avoid double-sending.
|
||||
print("SMOKE[%s]: proposal %s is OPEN at revision %d, accepting..." % [_role, ControlPlaneClient.state.proposal_id, ControlPlaneClient.state.proposal_revision])
|
||||
_accept_sent = true
|
||||
if _role == "player-b":
|
||||
var delay := get_tree().create_timer(0.5)
|
||||
delay.timeout.connect(_send_accept)
|
||||
return
|
||||
_send_accept()
|
||||
|
||||
|
||||
func _send_accept() -> void:
|
||||
if _finished:
|
||||
return
|
||||
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
|
||||
if err != OK and err != ERR_BUSY:
|
||||
_accept_sent = false
|
||||
_finish(false, "respond_to_proposal() failed to start: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_request_failed(operation: String, http_code: int, detail: String) -> void:
|
||||
if _finished:
|
||||
return
|
||||
if operation == "proposal_accept" and http_code == HTTPClient.RESPONSE_CONFLICT:
|
||||
print("SMOKE[%s]: concurrent accept conflicted; ControlPlaneClient is recovering the authoritative proposal..." % _role)
|
||||
return
|
||||
_finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail])
|
||||
|
||||
|
||||
func _finish(passed: bool, detail: String) -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finished = true
|
||||
if passed:
|
||||
print("SMOKE PASS: [%s] %s" % [_role, detail])
|
||||
get_tree().quit(0)
|
||||
else:
|
||||
print("SMOKE FAIL: [%s] %s" % [_role, detail])
|
||||
get_tree().quit(1)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/control_plane_proposal_smoke.gd" id="1_cpps"]
|
||||
|
||||
[node name="ControlPlaneProposalSmoke" type="Node"]
|
||||
script = ExtResource("1_cpps")
|
||||
@@ -0,0 +1,184 @@
|
||||
extends Node
|
||||
|
||||
# Real end-to-end smoke test for ControlPlaneClient against a REAL running
|
||||
# control-plane HTTP server backed by REAL PostgreSQL -- proving the actual
|
||||
# wire format (GDScript's HTTPRequest/JSON on one side, the real compiled Go
|
||||
# api.Service on the other) is compatible, not just that each side's own unit
|
||||
# tests pass in isolation. Every other ControlPlaneClient test in this repo
|
||||
# is either pure parsing/validation logic or drives the client against a
|
||||
# mock; nothing before this exercised a real network round trip end to end
|
||||
# (multiplayer-next.md 8.40's own evidence names this "live... verification"
|
||||
# as remaining).
|
||||
#
|
||||
# Run against scripts/verify_control_plane_integration.sh's server/cmd/testkit-api
|
||||
# instance -- see that script's own header for why a separate, clearly-marked
|
||||
# test-only binary exists rather than a flag on the real cmd/control-plane:
|
||||
#
|
||||
# godot --headless --path Game res://tests/control_plane_smoke.tscn -- \
|
||||
# --control-plane-url=http://127.0.0.1:PORT
|
||||
#
|
||||
# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1.
|
||||
|
||||
const TIMEOUT_SECONDS := 10.0
|
||||
|
||||
var _finished := false
|
||||
var _ticket_id := ""
|
||||
var _assignment_match_id := ""
|
||||
var _steam_ticket := ""
|
||||
var _ranked_profile_smoke := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var control_plane_url := ""
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--control-plane-url="):
|
||||
control_plane_url = arg.substr("--control-plane-url=".length())
|
||||
elif arg.begins_with("--assignment-match-id="):
|
||||
_assignment_match_id = arg.substr("--assignment-match-id=".length())
|
||||
elif arg.begins_with("--steam-ticket="):
|
||||
_steam_ticket = arg.substr("--steam-ticket=".length())
|
||||
elif arg == "--ranked-profile-smoke":
|
||||
_ranked_profile_smoke = true
|
||||
if control_plane_url.is_empty():
|
||||
_finish(false, "missing --control-plane-url")
|
||||
return
|
||||
# A syntactically valid but semantically meaningless placeholder token:
|
||||
# configure() validates format eagerly, but real auth doesn't exist until
|
||||
# login_steam()'s response overwrites it below. There is no other way to
|
||||
# set base_url alone.
|
||||
if not ControlPlaneClient.configure(control_plane_url, "0:0"):
|
||||
_finish(false, "configure() rejected a valid-looking base URL")
|
||||
return
|
||||
|
||||
_ticket_id = "smoke-ticket-%d" % Time.get_unix_time_from_system()
|
||||
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
|
||||
ControlPlaneClient.request_failed.connect(_on_request_failed)
|
||||
|
||||
var web_api_ticket := _steam_ticket if not _steam_ticket.is_empty() else "smoke-web-api-ticket-%d" % Time.get_ticks_usec()
|
||||
var err := ControlPlaneClient.login_steam(web_api_ticket)
|
||||
if err != OK:
|
||||
_finish(false, "login_steam() failed to start: %s" % error_string(err))
|
||||
return
|
||||
print("SMOKE: logging in against %s..." % control_plane_url)
|
||||
|
||||
var timer := Timer.new()
|
||||
timer.wait_time = TIMEOUT_SECONDS
|
||||
timer.one_shot = true
|
||||
timer.timeout.connect(func(): _finish(false, "timed out after %.1fs" % TIMEOUT_SECONDS))
|
||||
add_child(timer)
|
||||
timer.start()
|
||||
|
||||
|
||||
func _on_request_succeeded(operation: String, payload: Dictionary) -> void:
|
||||
if _finished:
|
||||
return
|
||||
match operation:
|
||||
"steam_session":
|
||||
if not _assignment_match_id.is_empty():
|
||||
print("SMOKE: logged in as %s, fetching player-scoped assignment..." % ControlPlaneClient.player_id)
|
||||
var err := ControlPlaneClient.fetch_assignment(_assignment_match_id)
|
||||
if err != OK:
|
||||
_finish(false, "fetch_assignment() failed to start: %s" % error_string(err))
|
||||
return
|
||||
if _ranked_profile_smoke:
|
||||
print("SMOKE: logged in as %s, fetching populated ranked profile..." % ControlPlaneClient.player_id)
|
||||
var ranked_err := ControlPlaneClient.fetch_ranked_profile()
|
||||
if ranked_err != OK:
|
||||
_finish(false, "fetch_ranked_profile() failed to start: %s" % error_string(ranked_err))
|
||||
return
|
||||
print("SMOKE: logged in as %s, fetching ranked profile (expect none yet)..." % ControlPlaneClient.player_id)
|
||||
var err := ControlPlaneClient.fetch_ranked_profile()
|
||||
if err != OK:
|
||||
_finish(false, "fetch_ranked_profile() failed to start: %s" % error_string(err))
|
||||
"assignment":
|
||||
if payload.get("match_id", "") != _assignment_match_id or payload.get("player_id", "") != ControlPlaneClient.player_id or not ControlPlaneClient.assignment.available:
|
||||
_finish(false, "unexpected assignment payload: %s" % payload)
|
||||
return
|
||||
_finish(true, "authenticated assignment fetch returned the player-scoped endpoint and join authorisation")
|
||||
"ranked_profile":
|
||||
if not _ranked_profile_smoke:
|
||||
_finish(false, "a brand-new testkit identity unexpectedly already has a ranked profile: %s" % payload)
|
||||
return
|
||||
if int(payload.get("rating", -1)) != 1600 or int(payload.get("ranked_games", -1)) != 12 or payload.get("provisional", true) != false or String(payload.get("tier", "")) != "GOLD":
|
||||
_finish(false, "unexpected populated ranked profile: %s" % payload)
|
||||
return
|
||||
_finish(true, "authenticated ranked profile returned the durable rating, games, tier, and provisional state")
|
||||
"queue_create":
|
||||
if payload.get("ticket_id", "") != _ticket_id or payload.get("state", "") != "QUEUED":
|
||||
_finish(false, "unexpected queue_create payload: %s" % payload)
|
||||
return
|
||||
# apply_ticket_update (called for every "queue_"-prefixed response,
|
||||
# including this one) can itself decide the ticket needs a resync
|
||||
# and fire off a recover_queue() call -- a real, existing part of
|
||||
# MatchmakingState's own state machine, not something this test
|
||||
# controls. Wait for ControlPlaneClient to go idle before sending
|
||||
# the next request rather than assuming queue_create was the only
|
||||
# thing in flight.
|
||||
print("SMOKE: ticket %s QUEUED at revision %d, heartbeating once idle..." % [_ticket_id, ControlPlaneClient.state.revision])
|
||||
_send_heartbeat_once_idle()
|
||||
"queue_recover":
|
||||
pass # Expected background resync; the idle-wait above handles it.
|
||||
"queue_heartbeat":
|
||||
if int(payload.get("revision", -1)) <= 0:
|
||||
_finish(false, "heartbeat did not advance the revision: %s" % payload)
|
||||
return
|
||||
print("SMOKE: heartbeat advanced to revision %d, cancelling..." % ControlPlaneClient.state.revision)
|
||||
_send_cancel_once_idle()
|
||||
"queue_cancel":
|
||||
if payload.get("state", "") != "CANCELLED":
|
||||
_finish(false, "unexpected queue_cancel payload: %s" % payload)
|
||||
return
|
||||
_finish(true, "login -> queue_create -> heartbeat -> cancel all round-tripped against a real server")
|
||||
|
||||
|
||||
func _send_heartbeat_once_idle() -> void:
|
||||
if not ControlPlaneClient._operation.is_empty():
|
||||
# call_deferred alone floods the message queue without ever letting a
|
||||
# real frame (and therefore the in-flight HTTP request) actually
|
||||
# process -- a real Timer yields to the engine between checks.
|
||||
var poll := get_tree().create_timer(0.05)
|
||||
poll.timeout.connect(_send_heartbeat_once_idle)
|
||||
return
|
||||
var err := ControlPlaneClient.heartbeat(_ticket_id, ControlPlaneClient.state.revision)
|
||||
if err != OK:
|
||||
_finish(false, "heartbeat() failed to start: %s" % error_string(err))
|
||||
|
||||
|
||||
func _send_cancel_once_idle() -> void:
|
||||
if not ControlPlaneClient._operation.is_empty():
|
||||
var poll := get_tree().create_timer(0.05)
|
||||
poll.timeout.connect(_send_cancel_once_idle)
|
||||
return
|
||||
var err := ControlPlaneClient.cancel_queue(_ticket_id, ControlPlaneClient.state.revision)
|
||||
if err != OK:
|
||||
_finish(false, "cancel_queue() failed to start: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_request_failed(operation: String, http_code: int, detail: String) -> void:
|
||||
if _finished:
|
||||
return
|
||||
if operation == "ranked_profile" and http_code == HTTPClient.RESPONSE_NOT_FOUND:
|
||||
# Expected: a brand-new testkit identity has no ratings row yet, and
|
||||
# the real store adapter reports that the same way the in-memory
|
||||
# fallback it replaced always did -- not found, not an error.
|
||||
print("SMOKE: ranked profile correctly reports not found, creating a queue ticket...")
|
||||
var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1)
|
||||
if err != OK:
|
||||
_finish(false, "queue_create() failed to start: %s" % error_string(err))
|
||||
return
|
||||
if operation == "assignment":
|
||||
_finish(false, "assignment fetch failed: http=%d detail=%s" % [http_code, detail])
|
||||
return
|
||||
_finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail])
|
||||
|
||||
|
||||
func _finish(passed: bool, detail: String) -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finished = true
|
||||
if passed:
|
||||
print("SMOKE PASS: %s" % detail)
|
||||
get_tree().quit(0)
|
||||
else:
|
||||
print("SMOKE FAIL: %s" % detail)
|
||||
get_tree().quit(1)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/control_plane_smoke.gd" id="1_cps"]
|
||||
|
||||
[node name="ControlPlaneSmoke" type="Node"]
|
||||
script = ExtResource("1_cps")
|
||||
@@ -8,7 +8,7 @@ extends Node
|
||||
# get_tree().current_scene, and calling it from a node that ISN'T an
|
||||
# ancestor-chain match for current_scene (as an earlier draft of this test
|
||||
# did, by add_child()-ing lobby.tscn under this driver) hung completely
|
||||
# on disconnect — see multiplayer-todo.md §9 gotcha 27.
|
||||
# on disconnect — see multiplayer-next.md §9 gotcha 27.
|
||||
#
|
||||
# The host role loading lobby.tscn is deliberate, not an oversight: a
|
||||
# *dedicated* server (server_boot.tscn) never loads it, but a self-hosting
|
||||
|
||||
@@ -35,7 +35,7 @@ func _ready() -> void:
|
||||
|
||||
func _run_host() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var host_btn: Button = menu.get_node("CenterContainer/VBoxContainer/HostButton")
|
||||
var host_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton")
|
||||
host_btn.emit_signal("pressed")
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
var scene := get_tree().current_scene
|
||||
@@ -58,7 +58,7 @@ func _run_join_ok() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press = %s" % str(overlay.visible))
|
||||
@@ -72,7 +72,7 @@ func _run_join_refused() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press (no server) = %s" % str(overlay.visible))
|
||||
@@ -92,7 +92,7 @@ func _run_join_cancel() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "10.255.255.1" # non-routable; connect attempt just hangs until timeout/cancel
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
@@ -97,7 +97,7 @@ func _on_player_joined(peer_id: int, player_name: String) -> void:
|
||||
_finish(true, "host saw player_joined (peer_id=%d, name=%s)" % [peer_id, player_name])
|
||||
|
||||
|
||||
# Adversarial-review regression (multiplayer-todo.md §9): MatchNet.roster
|
||||
# Adversarial-review regression (multiplayer-next.md §9): MatchNet.roster
|
||||
# used to have no path that cleared it when a HOST itself called
|
||||
# NetworkManager.shutdown() — only the client-side disconnect signal did.
|
||||
# Host -> client joins -> host leaves (shutdown) -> host again used to
|
||||
|
||||
@@ -1378,9 +1378,25 @@ func run_ci_client_check(run_seconds: float) -> void:
|
||||
var snapshot_count_ok: bool = snapshot_count[0] >= min_expected
|
||||
var net_stats: Dictionary = match_scene.get_net_debug_stats()
|
||||
var present_time := bool(match_scene.remote_visual_present_time_enabled)
|
||||
var remote_position_p95 := float(net_stats.get("remote_residual_position_p95", INF))
|
||||
var remote_rotation_p95 := float(net_stats.get("remote_residual_rotation_p95", INF))
|
||||
var remote_position_p99 := float(net_stats.get("remote_residual_position_p99", INF))
|
||||
var remote_rotation_p99 := float(net_stats.get("remote_residual_rotation_p99", INF))
|
||||
var remote_quality_ok := not present_time or (remote_position_p99 < 0.3 and remote_rotation_p99 < 5.0)
|
||||
# Two bars rather than one loose one. The tight bar moved to p95, which is
|
||||
# stable across runs; p99 over a few hundred samples is its worst handful,
|
||||
# so on a shared CI host it measures scheduling jitter as much as
|
||||
# interpolation. The p99 bar is the product's own tolerance: beyond
|
||||
# REMOTE_VISUAL_MAX_OFFSET the visual smoother stops absorbing a correction
|
||||
# in one step, so exceeding it is a real defect rather than a slow runner.
|
||||
#
|
||||
# The single p99 < 0.3 bar produced false failures: two clients in one run
|
||||
# reported 0.324 and 0.187 with everything else identical, and the same
|
||||
# commit passed and failed in the same minute.
|
||||
var remote_quality_ok := not present_time or (
|
||||
remote_position_p95 < 0.3 and remote_rotation_p95 < 5.0
|
||||
and remote_position_p99 < NetworkedMatch.REMOTE_VISUAL_MAX_OFFSET
|
||||
and remote_rotation_p99 < NetworkedMatch.REMOTE_VISUAL_MAX_ROTATION_DEGREES
|
||||
)
|
||||
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var score_path := "/tmp/cosmicclash_ci_score_%d.txt" % my_id
|
||||
@@ -1388,8 +1404,10 @@ func run_ci_client_check(run_seconds: float) -> void:
|
||||
f.store_string(JSON.stringify(match_scene.score))
|
||||
f.close()
|
||||
|
||||
print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s remote_present_time=%s residual_p99=%.3fm/%.3fdeg" % [
|
||||
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score), str(present_time), remote_position_p99, remote_rotation_p99,
|
||||
print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s remote_present_time=%s residual_p95=%.3fm/%.3fdeg residual_p99=%.3fm/%.3fdeg (p95 bar %.2fm/%.1fdeg, p99 bar %.2fm/%.1fdeg)" % [
|
||||
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score), str(present_time),
|
||||
remote_position_p95, remote_rotation_p95, remote_position_p99, remote_rotation_p99,
|
||||
0.3, 5.0, NetworkedMatch.REMOTE_VISUAL_MAX_OFFSET, NetworkedMatch.REMOTE_VISUAL_MAX_ROTATION_DEGREES,
|
||||
])
|
||||
var success: bool = slots_ok and snapshot_count_ok and remote_quality_ok
|
||||
print("SMOKE %s: CI client-bot run" % ("PASS" if success else "FAIL"))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
extends SceneTree
|
||||
|
||||
const ServerControlScript = preload("res://scripts/server_control.gd")
|
||||
const PORT := 18080
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
var control = ServerControlScript.new()
|
||||
root.add_child(control)
|
||||
if control.start(PORT, "drain-secret") != OK:
|
||||
printerr("server control failed to bind")
|
||||
quit(1)
|
||||
return
|
||||
control.set_process_ready(true)
|
||||
control.set_meta("admission_armed", false)
|
||||
control.initial_connect_ready.connect(func() -> void: control.set_meta("admission_armed", true))
|
||||
await process_frame
|
||||
var ready_response := await _request("GET", "/ready", [])
|
||||
if ready_response != 200:
|
||||
printerr("ready response was %d" % ready_response)
|
||||
quit(1)
|
||||
return
|
||||
var unauthorized := await _request("POST", "/drain", ["Authorization: Bearer wrong"])
|
||||
if unauthorized != 401:
|
||||
printerr("unauthorized drain response was %d" % unauthorized)
|
||||
quit(1)
|
||||
return
|
||||
var unauthorized_admission := await _request("POST", "/initial-connect-ready", ["Authorization: Bearer wrong"])
|
||||
if unauthorized_admission != 401 or bool(control.get_meta("admission_armed")):
|
||||
printerr("unauthorized initial-connect response/state was %d/%s" % [unauthorized_admission, control.get_meta("admission_armed")])
|
||||
quit(1)
|
||||
return
|
||||
var admitted := await _request("POST", "/initial-connect-ready", ["Authorization: Bearer drain-secret"])
|
||||
if admitted != 202 or not bool(control.get_meta("admission_armed")):
|
||||
printerr("authorized initial-connect response/state was %d/%s" % [admitted, control.get_meta("admission_armed")])
|
||||
quit(1)
|
||||
return
|
||||
var drained := await _request("POST", "/drain", ["Authorization: Bearer drain-secret"])
|
||||
if drained != 202 or not control.is_draining():
|
||||
printerr("authorized drain response/state was %d/%s" % [drained, control.is_draining()])
|
||||
quit(1)
|
||||
return
|
||||
var not_ready := await _request("GET", "/ready", [])
|
||||
if not_ready != 503:
|
||||
printerr("draining ready response was %d" % not_ready)
|
||||
quit(1)
|
||||
return
|
||||
control.stop()
|
||||
print("server control smoke passed")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _request(method: String, path: String, headers: PackedStringArray) -> int:
|
||||
var request := HTTPRequest.new()
|
||||
root.add_child(request)
|
||||
var http_method := HTTPClient.METHOD_GET if method == "GET" else HTTPClient.METHOD_POST
|
||||
var err := request.request("http://127.0.0.1:%d%s" % [PORT, path], headers, http_method)
|
||||
if err != OK:
|
||||
request.queue_free()
|
||||
return -1
|
||||
var result = await request.request_completed
|
||||
request.queue_free()
|
||||
return int(result[1])
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_resource type="Theme" load_steps=5 format=3]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_button_normal"]
|
||||
bg_color = Color(0.08, 0.12, 0.2, 0.94)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.18, 0.42, 0.68, 0.9)
|
||||
corner_radius_top_left = 6
|
||||
corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 9.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 9.0
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_button_hover"]
|
||||
bg_color = Color(0.12, 0.3, 0.48, 0.98)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3, 0.72, 1, 1)
|
||||
corner_radius_top_left = 6
|
||||
corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 9.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 9.0
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_line_edit"]
|
||||
bg_color = Color(0.035, 0.055, 0.1, 0.96)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.14, 0.3, 0.48, 1)
|
||||
corner_radius_top_left = 5
|
||||
corner_radius_top_right = 5
|
||||
corner_radius_bottom_right = 5
|
||||
corner_radius_bottom_left = 5
|
||||
content_margin_left = 10.0
|
||||
content_margin_top = 7.0
|
||||
content_margin_right = 10.0
|
||||
content_margin_bottom = 7.0
|
||||
|
||||
[resource]
|
||||
default_font_size = 16
|
||||
Button/colors/font_color = Color(0.86, 0.94, 1, 1)
|
||||
Button/colors/font_hover_color = Color(1, 1, 1, 1)
|
||||
Button/colors/font_pressed_color = Color(1, 1, 1, 1)
|
||||
Button/colors/font_disabled_color = Color(0.45, 0.52, 0.62, 1)
|
||||
Button/styles/normal = SubResource("StyleBox_button_normal")
|
||||
Button/styles/hover = SubResource("StyleBox_button_hover")
|
||||
Button/styles/pressed = SubResource("StyleBox_button_hover")
|
||||
Button/styles/focus = SubResource("StyleBox_button_hover")
|
||||
LineEdit/colors/font_color = Color(0.9, 0.96, 1, 1)
|
||||
LineEdit/colors/caret_color = Color(0.3, 0.72, 1, 1)
|
||||
LineEdit/styles/normal = SubResource("StyleBox_line_edit")
|
||||
OptionButton/colors/font_color = Color(0.86, 0.94, 1, 1)
|
||||
Label/colors/font_color = Color(0.82, 0.9, 0.98, 1)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
extends Node
|
||||
|
||||
# One-off GPU frame-time profiling harness for task 0.15b's real-hardware
|
||||
# follow-up (multiplayer-todo.md §5.5.1) — the automated Mac passes gave
|
||||
# follow-up (MULTIPLAYER_SPEC.md §5.5.1) — the automated Mac passes gave
|
||||
# inconsistent, sometimes implausible numbers (stale-process contention,
|
||||
# and Apple Silicon's tile-based GPU architecture is a poor stand-in for the
|
||||
# target reference hardware). Run this directly on a machine with a real
|
||||
@@ -47,7 +47,7 @@ func _ready() -> void:
|
||||
|
||||
var match_scene := load("res://scenes/match.tscn") as PackedScene
|
||||
_match = match_scene.instantiate()
|
||||
# 3v3 = 6 ships, matching the scenario multiplayer-todo.md §5.5 measures.
|
||||
# 3v3 = 6 ships, matching the scenario MULTIPLAYER_SPEC.md §5.5 measures.
|
||||
_match.team_size = 3
|
||||
# Direct-scene-run fallback path (see match_mode.gd:_make_opponent_controller)
|
||||
# — gives every AI ship a real trained policy so thruster VFX/movement
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user