mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
Compare commits
558 Commits
4f13b4eca9
...
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 | |||
| 8a55c33666 | |||
| 6a9b269798 | |||
| badd0b1b47 | |||
| f7ab77dec5 | |||
| ad6f9cc148 | |||
| 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 | |||
| 3aa0f5b9c2 | |||
| 964094f65a | |||
| c51d5ee369 | |||
| 6d837b2bf3 |
@@ -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).
|
||||
@@ -2,14 +2,30 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Important rule: never create co-authored commits. Never mention Claude in commits.
|
||||
Important rule: never create co-authored commits. Never mention Claude in commits.
|
||||
|
||||
## 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. The project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.
|
||||
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.
|
||||
|
||||
### Where the documentation lives
|
||||
|
||||
The prose docs carry far more design rationale than the code comments, and several are load-bearing:
|
||||
|
||||
- `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, 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
|
||||
|
||||
This repo vendors [godot-mcp](https://github.com/tugcantopaloglu/godot-mcp) as a git submodule at `mcp/godot-mcp` and registers it in `.mcp.json`. **Prefer the godot-mcp tools over manual file edits or shell commands** when the task involves inspecting or modifying the Godot project — reading/editing scenes, nodes, scripts, running the project, or interacting with a live Godot editor/runtime instance. It understands Godot's scene tree and `.tscn`/`.gd` structures directly, which is more reliable than hand-parsing them.
|
||||
@@ -55,35 +71,232 @@ Upstream ships telemetry, and there are **two independent switches** — turning
|
||||
|
||||
There is no build step or linter for the GDScript project itself — Godot projects run directly from source.
|
||||
|
||||
### Running the game
|
||||
|
||||
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
|
||||
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
|
||||
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
|
||||
- **Unit tests** (pure-function assertions, see `multiplayer-todo.md` task 1.0): `godot --headless --path Game res://tests/test_runner.tscn`. Exits 0/1. Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed.
|
||||
- **Networking smoke tests** (real two-process ENet connect/disconnect, see `multiplayer-todo.md` §7 Phase 1 tasks): each starts a host then a client, each in its own `godot --headless` process, printing `SMOKE PASS/FAIL: ...` and exiting 0/1. Not part of `test_runner.tscn` — a live ENet handshake needs two real processes. `res://tests/net_smoke.tscn` (task 1.2 — `--role=host|client`, now also confirms the host observes `client_disconnected`, not just that each side exits cleanly on its own), `res://tests/match_net_smoke.tscn` (task 1.4 — `--role=host|client|client-badversion|client-longname|host_recycle`; `client-longname` sends an oversized player name and expects rejection, `host_recycle` hosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plain `client` role against it), `res://tests/clock_smoke.tscn` (task 1.8 — `--role=host|client`, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency), `res://tests/lobby_smoke.tscn` (task 1.5 — `--role=host|client`; **both** roles load `lobby.tscn` for real via `change_scene_to_file` now, exercising the server's read-only view as well as the client's interactive one). 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).
|
||||
- **`main_menu.tscn`'s Host/Join flow** (task 1.7) 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.
|
||||
- **Import resources first** on a fresh checkout or in a container: `godot --headless --path Game --import`. Do **not** add `--quit` — it ends the editor after one iteration and can leave `.godot/imported` half-generated (see the Dockerfile comment).
|
||||
|
||||
### Unit tests
|
||||
|
||||
`godot --headless --path Game res://tests/test_runner.tscn` — pure-function assertions, exits 0/1.
|
||||
|
||||
Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed. The same path-vs-`class_name` caveat is why `net_codec.gd`, `sim_constants.gd` etc. are reached by `preload()` elsewhere in the codebase.
|
||||
|
||||
The runner has **no filter flag** — it always runs everything (it's fast). To isolate one case, temporarily move the others out of `tests/cases/`.
|
||||
|
||||
Two runner behaviours exist because of past silent-pass bugs, and new tests must respect them: a case file that fails to parse is a failure (`can_instantiate()` is the guard — `load()` does *not* return null on a broken script), and **a test that completes having made zero assertions is itself a failure**, because GDScript has no exceptions and a crash before the first `assert_*` would otherwise read as a pass.
|
||||
|
||||
### Networking smoke tests (multi-process)
|
||||
|
||||
These need two or three real `godot --headless` processes for a live ENet handshake, so they are **not** part of `test_runner.tscn`. Each prints `SMOKE PASS/FAIL: ...` and exits 0/1. Run them all through the wrapper:
|
||||
|
||||
```bash
|
||||
make verify-enet-integration # host+client pairs for every case, then the 3-process match
|
||||
VERIFY_ENET_CASES=net,clock make verify-enet-integration # subset, for local debugging
|
||||
GODOT_BIN=/path/to/godot make verify-enet-integration # non-default Godot binary
|
||||
```
|
||||
|
||||
`scripts/verify_enet_integration.sh` starts each role, waits, and fails on any `SCRIPT ERROR`/`ERROR:`/`SMOKE FAIL` in the logs — a clean exit code alone is not the bar. It prints its temp log directory and dumps the logs on failure. The individual scenes, if you need to drive one by hand with `-- --role=<role>`:
|
||||
|
||||
| Scene | Roles | What it proves |
|
||||
| --- | --- | --- |
|
||||
| `tests/net_smoke.tscn` | `host`, `client` | Raw connect/disconnect; the host observes `client_disconnected`, not just clean self-exit. |
|
||||
| `tests/match_net_smoke.tscn` | `host`, `client`, `client-badversion`, `client-longname`, `host_recycle` | Handshake gating (protocol version, oversized name rejection) and that a host→leave→re-host cycle actually empties the roster (run a plain `client` against `host_recycle`). |
|
||||
| `tests/clock_smoke.tscn` | `host`, `client` | Clock convergence, cross-checked against independent OS-wall-clock ground truth rather than self-consistency. |
|
||||
| `tests/lobby_smoke.tscn` | `host`, `client` | **Both** roles load `lobby.tscn` for real via `change_scene_to_file`, exercising the server's read-only view as well as the client's interactive one. |
|
||||
| `tests/networked_match_ci.tscn` | `host`, `client-bot --test-bot` (×2) | A full server + two AI-driven clients playing a real match: snapshot throughput and cross-peer score agreement, via a deterministic server-forced goal. |
|
||||
| `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-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.
|
||||
|
||||
`tests/server_physics_parity.gd` is a standalone `SceneTree` script (no `.tscn`, no wired runner) that traces the real Ship scene through fixed inputs and dumps every physics step's pose/velocity plus the observation vector. It exists to be diffed against the same file run from a `git archive HEAD` tree, proving a client-side change didn't perturb shared physics.
|
||||
|
||||
### Dedicated server (Docker)
|
||||
|
||||
```bash
|
||||
make verify-phase6 # the whole exported-server gate; also the entire Phase 6 CI workflow
|
||||
```
|
||||
|
||||
This builds the stripped `Linux Dedicated Server` export, runs it in one container, joins two independent headless clients from two others, forces a server-owned goal in each of two matches, and asserts both clients saw both scores **and that the arena actually rotated between matches**. It needs several GB of free Docker space (the pinned `barichello/godot-ci:4.7.1` image is ~2.4 GB) and always tears down its Compose containers, printing its log directory either way.
|
||||
|
||||
The Dockerfile's targets are worth knowing: `project-imported` (base, resources imported) → `enet-test` (source client, used by the ENet CI workflow) → `exporter` (rewrites `run/main_scene` and exports the server) → `server` (slim Ubuntu runtime) and `smoke-client` (test-only harness). **Godot dedicated exports refuse command-line scene overrides**, which is why the server scene is baked in by `sed` at export time rather than passed as an argument.
|
||||
|
||||
To run one by hand, and for every config flag, see `SERVER.md`. `--smoke-force-goal-after=<seconds>` is a verification-only switch and must never be used for a real match.
|
||||
|
||||
### Steam builds (optional)
|
||||
|
||||
`make verify-steam-templates` requires custom GodotSteam executables pinned in `steam-dependencies.lock.json` and pointed at by `COSMIC_CLASH_STEAM_CLIENT_GODOT` / `COSMIC_CLASH_STEAM_SERVER_GODOT`. It deliberately refuses a stock Godot binary. Nothing else in the repo needs Steam — the default build and every Docker check are ENet-only. See `STEAM.md`.
|
||||
|
||||
### CI
|
||||
|
||||
`.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
|
||||
|
||||
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
|
||||
- `Game/tools/` holds editor-run utilities (`bake_arena_boundary.gd`, `gpu_profile_harness.tscn`, `replay_dump.gd` for reading server replay logs); `tools/blender/` and `tools/textures/` hold the Python generators for the original ship/ball/nebula assets.
|
||||
|
||||
## Architecture
|
||||
|
||||
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework (see `TODO.md` for the deferred work). The three load-bearing seams are the controller abstraction, the arena/game-mode split, and code-driven spawning.
|
||||
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework. The load-bearing seams are the controller abstraction, the arena/game-mode split, code-driven spawning, and (for networking) the pure-function codec/state modules that can be unit-tested without a live connection.
|
||||
|
||||
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
|
||||
- **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; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
|
||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `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. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
||||
### Core game
|
||||
|
||||
- **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. **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 formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends 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. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
|
||||
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
|
||||
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig` (mirroring the camera rig's `target`, not group lookup — the `"ship"` group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
|
||||
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks.
|
||||
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
|
||||
- **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.
|
||||
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless runs work.
|
||||
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd`) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig`, connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments, not hardcoded `get_node` paths or per-frame polling. **`HUDController` duck-types on optional signals** (`timer_updated`, `match_ended`, `kickoff_countdown`, …) and hides the corresponding widget when a mode lacks one — so declaring a signal you never emit is worse than not declaring it (it shows a permanently frozen timer instead of hiding it).
|
||||
- **`SimConstants.TICK_HZ`** is the single source of truth for the 60 Hz tick — every derived timing constant reads it rather than restating `60`. It is *not* wired to `project.godot`'s `physics/common/physics_ticks_per_second` (an engine setting), so those must be kept in sync by hand.
|
||||
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turbo`, `reset_ball`, `toggle_perf_overlay` F3, `toggle_net_overlay` F4, …) and read only by `PlayerShipController` plus mode-level `_unhandled_input` — add new controls there rather than hardcoding key checks.
|
||||
- Physics engine is Jolt (`[physics] 3d/physics_engine="Jolt Physics"`).
|
||||
|
||||
### `project.godot` hazard
|
||||
|
||||
Godot's `ConfigFile` writer does not round-trip comments, and a `#` block directly above a setting can be spliced into that setting's own line on rewrite, silently commenting it out. **Do not add comments to `Game/project.godot`.** This matters most for the feature overrides `run/main_scene.training` and `run/main_scene.dedicated_server`, which are how the training and server exports reach the right scene without a CLI flag; `tests/cases/test_project_settings.gd` fails loudly if they ever break.
|
||||
|
||||
### Networking (server-authoritative, with client prediction)
|
||||
|
||||
All hot-path RPCs live on **autoloads**, never scene nodes, so RPC NodePaths never depend on which scene is loaded. The autoload chain, in `project.godot` order:
|
||||
|
||||
- **`NetSim`** (`net_sim.gd`) — debug-only seeded latency/jitter/loss/duplicate decorator around outgoing dispatch. A pure passthrough unless `--net-sim-latency=` / `--net-sim-jitter=` / `--net-sim-loss=` / `--net-sim-dup=` / `--net-sim-seed=` are passed, so its mere existence changes nothing. Each process reads only its own flags, which is what makes asymmetric (e.g. lossy-upload-only) testing free.
|
||||
- **`NetworkManager`** (`network_manager.gd`) — transport-neutral host/join/shutdown and connection signals. Sets `server_relay = false` the moment a peer exists (the default `true` would let any client RPC any other client through the server). Runs **manual multiplayer polling** (`set_multiplayer_poll_enabled(false)`), because SceneTree's automatic poll runs on the idle frame and would cost a frame in each direction for RPCs issued from `_physics_process` — anything driving a connection must call `NetworkManager.poll()` itself or nothing is ever sent or received.
|
||||
- **`MatchNet`** (`match_net.gd`) — hello/welcome handshake, strict `protocol_version` and tick-rate gating, and the roster (name, team, ready) that survives the lobby→match transition. Slot assignment is *not* stored here; it's derived at spawn time.
|
||||
- **`MatchSim`** (`match_sim.gd`) — the Phase 2+ simulation RPCs: `match_config`, `input` (client→server), `snapshot` (server→client), score/state/kickoff/goal/clock messages. Also owns protocol-level input validation (leaky-bucket packet and byte rate limits), because framing abuse is independent of any particular match's state. Emits `input_rejected` with the verbatim bytes so the replay log can explain "my input did nothing".
|
||||
- **`NetDebugOverlay`** (F4) and **`PerfOverlay`** (F3) — headless-guarded read-only overlays.
|
||||
|
||||
Supporting modules, deliberately standalone (`RefCounted`/`class_name`, no scene or RPC dependency) so they unit-test head-on:
|
||||
|
||||
- `net_codec.gd` — **the wire format**. `PROTOCOL_VERSION`, channel intents, quantisers, pack/unpack for the input and snapshot packets. Any change here is a protocol change.
|
||||
- `match_state.gd` — the match lifecycle enum and its legal-transition table. **The integer values are the wire format** (`match_state` is a u8 in the snapshot header): never renumber an existing state, only append.
|
||||
- `net_body_state.gd`, `net_interpolator.gd`, `net_ship_predictor.gd`, `local_prediction_history.gd`, `local_input_timeline.gd`, `input_jitter_buffer.gd`, `input_lead_controller.gd`, `adaptive_input_depth_controller.gd`, `ship_action_codec.gd`, `replay_log.gd`, `server_config.gd`, `server_log.gd` — each has a header comment explaining its role and the task it came from.
|
||||
|
||||
`networked_match.gd` (`NetworkedMatch extends GameMode`, ~2.4k lines) is where it all meets: the server simulates every slot and broadcasts 60 Hz snapshots; a client simulates **only its own unfrozen slot** with one real controller while every remote slot and the ball stay frozen and interpolated. Its scene has no Arena or HUD child — both are built in code once the arena is actually known (the server picks it, the client learns it from `match_config`), which is why it overrides `_ready()` entirely rather than using `GameMode`'s arena-required-synchronously flow.
|
||||
|
||||
Server process: `scenes/server_boot.tscn` (`server_boot.gd`) is the shell — strict CLI parsing via `ServerConfig` (unknown flag or bad value refuses to start), structured logging via `ServerLog`, physics-overrun watchdog. `ServerMatchLoop` (`server_match_loop.gd`) is the actual match loop: wait for `--min-players` **by roster, not raw peers**, count down, load the next arena from the rotation, run the match, return to the lobby, repeat or drain at `--max-matches`. It parents itself to the scene-tree **root**, never `current_scene`, because `change_scene_to_file` frees the live scene and an orchestrator freed by its own transition can't orchestrate the next one.
|
||||
|
||||
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.
|
||||
|
||||
## Reinforcement learning / AI bots
|
||||
|
||||
See `TRAINING.md` for the full workflow (training, exporting, evaluating, difficulty tiers). Architecture summary:
|
||||
See `TRAINING.md` for the full workflow. Architecture summary:
|
||||
|
||||
- `scenes/training.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
|
||||
- `scripts/ship_observations.gd` is the shared observation builder used by both training and in-game inference — never fork or diverge these two paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
|
||||
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/`; Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent.
|
||||
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`).
|
||||
- `scenes/training.tscn` / `training_elevated.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
|
||||
- `scripts/ship_observations.gd` is the shared observation builder used by training, in-game inference, **and** the server — never fork or diverge these paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
|
||||
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/` (promoted tiers in `Game/bots/promoted/`); Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent, with the main menu's `GameSettings` autoload selections overriding them.
|
||||
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`), plus the curriculum drivers (`curriculum.py`, `generation5.py`) and their JSON state files.
|
||||
- The flattened action space is Box(7) in gymnasium's **sorted-key order**: rotation xyz, thrust xyz, turbo (>0 = on). The fields are `ShipAction`'s, but gymnasium alphabetizes Dict spaces, so the flat order is NOT ShipAction's thrust-first declaration order — `AIShipController._decide` consumes exported policies in sorted order; change the action space only deliberately and everywhere together.
|
||||
|
||||
+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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+44
-25
@@ -25,24 +25,10 @@ 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"
|
||||
@@ -50,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]
|
||||
|
||||
@@ -69,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={
|
||||
@@ -122,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={
|
||||
@@ -143,7 +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)
|
||||
@@ -164,15 +183,15 @@ toggle_net_overlay={
|
||||
|
||||
[physics]
|
||||
|
||||
common/physics_jitter_fix=0.0
|
||||
3d/physics_engine="Jolt Physics"
|
||||
common/physics_interpolation=true
|
||||
common/physics_jitter_fix=0.0
|
||||
|
||||
[rendering]
|
||||
|
||||
anti_aliasing/quality/msaa_3d=2
|
||||
anti_aliasing/quality/screen_space_aa=1
|
||||
anti_aliasing/quality/use_debanding=true
|
||||
lights_and_shadows/positional_shadow/atlas_size=2048
|
||||
lights_and_shadows/directional_shadow/size=2048
|
||||
anti_aliasing/quality/msaa_3d=2
|
||||
anti_aliasing/quality/use_debanding=true
|
||||
anti_aliasing/quality/screen_space_aa=1
|
||||
lights_and_shadows/positional_shadow/atlas_size=2048
|
||||
lights_and_shadows/soft_shadow_filter_quality=2
|
||||
|
||||
+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
|
||||
@@ -0,0 +1 @@
|
||||
uid://505pjuvqynm0
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjij4dxir0qxd
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://itgcxadtf1wd
|
||||
@@ -0,0 +1 @@
|
||||
uid://c117m546w6u30
|
||||
@@ -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 @@
|
||||
uid://cklv8t2htg2gf
|
||||
@@ -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 @@
|
||||
uid://dtqbio4ob00hq
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvwivtlgx2f34
|
||||
@@ -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")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user