Match relay in the save/login service: WebSocket rooms, seats, ready, 100 ms turn confirmation and checksum relay
This commit is contained in:
1 parent
42034b062b
commit
848ab0f677
6 files changed
+956
-6
No files matched your search
+12
@@ -19,6 +19,18 @@ server {
|
|||||||
}
|
}
|
||||||
# savegame service (server/). Resolved at request time, so the game still starts without it;
|
# savegame service (server/). Resolved at request time, so the game still starts without it;
|
||||||
# the game then falls back to savegame files.
|
# the game then falls back to savegame files.
|
||||||
|
# match relay WebSocket (server/src/netplay.rs)
|
||||||
|
location /api/netplay/ws {
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $saves http://saves:8080;
|
||||||
|
proxy_pass $saves;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
location /api/ {
|
location /api/ {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $saves http://saves:8080;
|
set $saves http://saves:8080;
|
||||||
|
|||||||
Generated
+201
-4
@@ -27,6 +27,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -45,8 +46,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -72,6 +75,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64ct"
|
name = "base64ct"
|
||||||
version = "1.8.3"
|
version = "1.8.3"
|
||||||
@@ -127,6 +136,12 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -144,7 +159,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"getrandom",
|
"futures-util",
|
||||||
|
"getrandom 0.2.17",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -165,6 +181,23 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -178,6 +211,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -204,6 +239,18 @@ dependencies = [
|
|||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -343,7 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -359,6 +406,15 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -377,12 +433,47 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -416,7 +507,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -455,6 +546,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -483,6 +585,17 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.9"
|
version = "3.0.9"
|
||||||
@@ -500,12 +613,33 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.54.1"
|
version = "1.54.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -522,7 +656,19 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -573,6 +719,22 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand",
|
||||||
|
"sha1",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -597,6 +759,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.4+wasi-0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -612,6 +783,32 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
+2
-1
@@ -6,12 +6,13 @@ license = "MIT"
|
|||||||
description = "Savegame storage service for DungeonCraft"
|
description = "Savegame storage service for DungeonCraft"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "sync", "time"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
|
futures-util = "0.3.34"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
mod netplay;
|
||||||
|
use netplay::Netplay;
|
||||||
|
|
||||||
const MAX_SAVE: usize = 1024 * 1024;
|
const MAX_SAVE: usize = 1024 * 1024;
|
||||||
const MAX_TOTAL: u64 = 500 * 1024 * 1024;
|
const MAX_TOTAL: u64 = 500 * 1024 * 1024;
|
||||||
const WRITES_PER_MINUTE: usize = 30;
|
const WRITES_PER_MINUTE: usize = 30;
|
||||||
@@ -59,6 +62,7 @@ struct AppState {
|
|||||||
sessions: Arc<Mutex<HashMap<String, String>>>, // token -> user name
|
sessions: Arc<Mutex<HashMap<String, String>>>, // token -> user name
|
||||||
writes: Arc<Mutex<Vec<u64>>>,
|
writes: Arc<Mutex<Vec<u64>>>,
|
||||||
logins: Arc<Mutex<Vec<u64>>>,
|
logins: Arc<Mutex<Vec<u64>>>,
|
||||||
|
netplay: Arc<Mutex<Netplay>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
@@ -193,6 +197,13 @@ impl AppState {
|
|||||||
self.store_sessions().await;
|
self.store_sessions().await;
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the user behind a WebSocket query token (login is optional in a match lobby)
|
||||||
|
pub async fn user_by_token(&self, token: &str) -> Option<String> {
|
||||||
|
let token = token.trim();
|
||||||
|
let name = self.sessions.lock().await.get(token).cloned()?;
|
||||||
|
self.users.lock().await.iter().find(|u| u.name == name).map(|u| u.name.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn total_size(dir: &PathBuf) -> u64 {
|
async fn total_size(dir: &PathBuf) -> u64 {
|
||||||
@@ -506,6 +517,7 @@ async fn main() {
|
|||||||
sessions: Arc::new(Mutex::new(sessions)),
|
sessions: Arc::new(Mutex::new(sessions)),
|
||||||
writes: Arc::new(Mutex::new(Vec::new())),
|
writes: Arc::new(Mutex::new(Vec::new())),
|
||||||
logins: Arc::new(Mutex::new(Vec::new())),
|
logins: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
netplay: Arc::new(Mutex::new(Netplay::new())),
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/health", get(health))
|
.route("/api/health", get(health))
|
||||||
@@ -516,6 +528,7 @@ async fn main() {
|
|||||||
.route("/api/password", post(change_password))
|
.route("/api/password", post(change_password))
|
||||||
.route("/api/users", get(list_users).post(create_user))
|
.route("/api/users", get(list_users).post(create_user))
|
||||||
.route("/api/users/{name}", delete(delete_user))
|
.route("/api/users/{name}", delete(delete_user))
|
||||||
|
.route("/api/netplay/ws", get(netplay::netplay_ws))
|
||||||
.route("/api/saves", get(list).post(create))
|
.route("/api/saves", get(list).post(create))
|
||||||
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
|
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
|
||||||
.layer(DefaultBodyLimit::max(MAX_SAVE + 1024))
|
.layer(DefaultBodyLimit::max(MAX_SAVE + 1024))
|
||||||
|
|||||||
@@ -0,0 +1,727 @@
|
|||||||
|
// DungeonCraft service, match relay over WebSocket.
|
||||||
|
//
|
||||||
|
// The relay never simulates the dungeon. It keeps rooms, seats and a 100 ms turn clock: clients send
|
||||||
|
// their commands, the relay confirms a turn with the commands of all sides, and every client runs
|
||||||
|
// exactly five simulation steps. Checksums are relayed, not checked.
|
||||||
|
//
|
||||||
|
// Lobby messages (JSON, one object per WebSocket frame):
|
||||||
|
// {"t":"rooms"} -> list of open matches
|
||||||
|
// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?}
|
||||||
|
// {"t":"join","code":"ABC123"}
|
||||||
|
// {"t":"take_side","side":2}
|
||||||
|
// {"t":"ready","ready":true}
|
||||||
|
// {"t":"start"} -> side 0 starts the match
|
||||||
|
// {"t":"cmds","cmds":[Command]} -> commands of the sender's side
|
||||||
|
// {"t":"sum","turn":1,"sum":1234} -> checksum for a peer
|
||||||
|
// {"t":"chat","text":"hello"}
|
||||||
|
// {"t":"resume"} -> side 0 resumes after a side went missing
|
||||||
|
// {"t":"abort"} -> side 0 closes the match
|
||||||
|
// {"t":"leave"}
|
||||||
|
//
|
||||||
|
// Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
Query, State,
|
||||||
|
},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use getrandom::getrandom;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
|
||||||
|
const TURN_MS: u64 = 100;
|
||||||
|
const CHAT_MAX: usize = 80;
|
||||||
|
const MAX_PENDING: usize = 200;
|
||||||
|
const MAX_ROOMS: usize = 64;
|
||||||
|
const MASTER_SIDES: [u8; 4] = [0, 2, 3, 4];
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Netplay {
|
||||||
|
rooms: HashMap<String, Room>,
|
||||||
|
clients: HashMap<u64, Client>,
|
||||||
|
next_conn: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Client {
|
||||||
|
name: String,
|
||||||
|
#[allow(dead_code)] // kept for the lobby: the name came from a logged-in user, not from the query
|
||||||
|
user: Option<String>,
|
||||||
|
room: Option<String>,
|
||||||
|
tx: mpsc::UnboundedSender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Room {
|
||||||
|
code: String,
|
||||||
|
sides: Vec<u8>,
|
||||||
|
seats: HashMap<u8, Seat>,
|
||||||
|
pending: HashMap<u8, Vec<Value>>,
|
||||||
|
turn: u32,
|
||||||
|
started: bool,
|
||||||
|
paused: bool,
|
||||||
|
missing: bool,
|
||||||
|
seed: Option<u32>,
|
||||||
|
map_w: usize,
|
||||||
|
map_h: usize,
|
||||||
|
difficulty: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Seat {
|
||||||
|
conn: Option<u64>,
|
||||||
|
name: String,
|
||||||
|
ready: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct WsQuery {
|
||||||
|
name: Option<String>,
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn room_code() -> String {
|
||||||
|
let mut b = [0u8; 6];
|
||||||
|
getrandom(&mut b).ok();
|
||||||
|
let letters: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
b.iter().map(|x| letters[(*x as usize) % letters.len()] as char).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_seed() -> u32 {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
getrandom(&mut b).ok();
|
||||||
|
u32::from_le_bytes(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_name(n: Option<String>) -> String {
|
||||||
|
let s: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(24).collect();
|
||||||
|
if s.trim().is_empty() {
|
||||||
|
"Guest".into()
|
||||||
|
} else {
|
||||||
|
s.trim().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sides_of(v: Option<&Value>) -> Vec<u8> {
|
||||||
|
let mut out: Vec<u8> = match v {
|
||||||
|
Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).filter(|s| MASTER_SIDES.contains(s)).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
out.sort_by_key(|s| MASTER_SIDES.iter().position(|m| m == s).unwrap_or(9));
|
||||||
|
out.dedup();
|
||||||
|
if !out.contains(&0) {
|
||||||
|
out.insert(0, 0);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query<WsQuery>, State(st): State<AppState>) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, st, q))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(socket: WebSocket, st: AppState, q: WsQuery) {
|
||||||
|
let user = match q.token.as_deref() {
|
||||||
|
Some(t) => st.user_by_token(t).await,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
let (net_tx, mut net_rx) = mpsc::unbounded_channel::<String>();
|
||||||
|
let conn = st.netplay.lock().await.connect(net_tx, q.name, user);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = net_rx.recv().await {
|
||||||
|
if ws_tx.send(Message::Text(msg.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
while let Some(msg) = ws_rx.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(Message::Text(text)) => {
|
||||||
|
if let Ok(v) = serde_json::from_str::<Value>(&text) {
|
||||||
|
let new_rooms = st.netplay.lock().await.handle(conn, v);
|
||||||
|
for code in new_rooms {
|
||||||
|
spawn_turn_loop(st.clone(), code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Close(_)) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.netplay.lock().await.disconnect(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_turn_loop(st: AppState, code: String) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_millis(TURN_MS)).await;
|
||||||
|
let mut net = st.netplay.lock().await;
|
||||||
|
if !net.rooms.contains_key(&code) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
net.tick(&code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Netplay {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect(&mut self, tx: mpsc::UnboundedSender<String>, name: Option<String>, user: Option<String>) -> u64 {
|
||||||
|
let id = self.next_conn.wrapping_add(1);
|
||||||
|
self.next_conn = id;
|
||||||
|
let name = user.clone().unwrap_or_else(|| clean_name(name));
|
||||||
|
self.clients.insert(id, Client { name, user, room: None, tx });
|
||||||
|
self.send(id, json!({"t":"hello","name":self.clients[&id].name}));
|
||||||
|
self.rooms_msg(id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disconnect(&mut self, conn: u64) {
|
||||||
|
if let Some(c) = self.clients.remove(&conn) {
|
||||||
|
if let Some(code) = c.room {
|
||||||
|
self.leave_room(conn, &code, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns newly created room codes whose turn loop the service has to start
|
||||||
|
pub fn handle(&mut self, conn: u64, msg: Value) -> Vec<String> {
|
||||||
|
let t = msg.get("t").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
match t.as_str() {
|
||||||
|
"rooms" => {
|
||||||
|
self.rooms_msg(conn);
|
||||||
|
}
|
||||||
|
"create" => {
|
||||||
|
if let Some(c) = self.create(conn, &msg) {
|
||||||
|
return vec![c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"join" => self.join(conn, &msg),
|
||||||
|
"take_side" => self.take_side(conn, &msg),
|
||||||
|
"ready" => self.ready(conn, &msg),
|
||||||
|
"start" => self.start(conn),
|
||||||
|
"cmds" => self.cmds(conn, &msg),
|
||||||
|
"sum" => self.sum(conn, &msg),
|
||||||
|
"chat" => self.chat(conn, &msg),
|
||||||
|
"resume" => self.resume(conn),
|
||||||
|
"abort" => self.abort(conn),
|
||||||
|
"leave" => {
|
||||||
|
let code = self.clients.get(&conn).and_then(|c| c.room.clone());
|
||||||
|
if let Some(code) = code {
|
||||||
|
self.leave_room(conn, &code, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => self.error(conn, "unknown message"),
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick(&mut self, code: &str) {
|
||||||
|
let action = match self.rooms.get_mut(code) {
|
||||||
|
Some(r) => {
|
||||||
|
if r.started && !r.paused {
|
||||||
|
let mut cmds = Vec::new();
|
||||||
|
for side in r.sides.clone() {
|
||||||
|
if let Some(v) = r.pending.remove(&side) {
|
||||||
|
cmds.extend(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.turn += 1;
|
||||||
|
Some((r.turn, cmds))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
if let Some((turn, cmds)) = action {
|
||||||
|
self.broadcast_room(code, json!({"t":"turn","turn":turn,"cmds":cmds}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&mut self, conn: u64, msg: &Value) -> Option<String> {
|
||||||
|
if self.rooms.len() >= MAX_ROOMS {
|
||||||
|
self.error(conn, "too many matches open");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let code = loop {
|
||||||
|
let c = room_code();
|
||||||
|
if !self.rooms.contains_key(&c) {
|
||||||
|
break c;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let sides = sides_of(msg.get("sides"));
|
||||||
|
let map_w = msg.get("map_w").and_then(|v| v.as_u64()).unwrap_or(96).clamp(72, 256) as usize;
|
||||||
|
let map_h = msg.get("map_h").and_then(|v| v.as_u64()).unwrap_or(128).clamp(72, 256) as usize;
|
||||||
|
let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8;
|
||||||
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
||||||
|
let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false })).collect::<HashMap<_, _>>();
|
||||||
|
let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty };
|
||||||
|
self.rooms.insert(code.clone(), room);
|
||||||
|
if let Some(c) = self.clients.get_mut(&conn) {
|
||||||
|
c.room = Some(code.clone());
|
||||||
|
}
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
if let Some(s) = r.seats.get_mut(&0) {
|
||||||
|
s.conn = Some(conn);
|
||||||
|
s.name = name;
|
||||||
|
s.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.rooms_msg(conn);
|
||||||
|
self.room_msg(conn, &code);
|
||||||
|
Some(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let code = msg.get("code").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||||||
|
if code.len() != 6 {
|
||||||
|
self.error(conn, "match code needs 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.rooms.contains_key(&code) {
|
||||||
|
self.error(conn, "no such match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.rooms[&code].started {
|
||||||
|
self.error(conn, "cannot join a match in progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let side = match self.rooms[&code].sides.iter().copied().find(|s| self.rooms[&code].seats.get(s).map(|seat| seat.conn.is_none()).unwrap_or(false)) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
self.error(conn, "match is full");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
||||||
|
if let Some(c) = self.clients.get_mut(&conn) {
|
||||||
|
c.room = Some(code.clone());
|
||||||
|
}
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
if let Some(s) = r.seats.get_mut(&side) {
|
||||||
|
s.conn = Some(conn);
|
||||||
|
s.name = name;
|
||||||
|
s.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.rooms_msg(conn);
|
||||||
|
self.room_msg_all(&code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_side(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let side = match msg.get("side").and_then(|v| v.as_u64()) {
|
||||||
|
Some(n) => n as u8,
|
||||||
|
None => return self.error(conn, "need a side"),
|
||||||
|
};
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
if !MASTER_SIDES.contains(&side) {
|
||||||
|
return self.error(conn, "unknown side");
|
||||||
|
}
|
||||||
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
||||||
|
let mut started = false;
|
||||||
|
let mut taken = false;
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
started = r.started;
|
||||||
|
if !started && r.sides.contains(&side) && r.seats.get(&side).map(|s| s.conn.is_none()).unwrap_or(false) {
|
||||||
|
for s in r.seats.values_mut() {
|
||||||
|
if s.conn == Some(conn) {
|
||||||
|
s.conn = None;
|
||||||
|
s.name.clear();
|
||||||
|
s.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(t) = r.seats.get_mut(&side) {
|
||||||
|
t.conn = Some(conn);
|
||||||
|
t.name = name;
|
||||||
|
t.ready = false;
|
||||||
|
taken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
self.room_msg_all(&code);
|
||||||
|
} else if started {
|
||||||
|
self.error(conn, "match already started");
|
||||||
|
} else {
|
||||||
|
self.error(conn, "side not free");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ready(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let ready = msg.get("ready").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
let mut ok = false;
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
for s in r.seats.values_mut() {
|
||||||
|
if s.conn == Some(conn) {
|
||||||
|
s.ready = ready;
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
self.room_msg_all(&code);
|
||||||
|
} else {
|
||||||
|
self.error(conn, "no seat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self, conn: u64) {
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
if r.started {
|
||||||
|
return self.error(conn, "match already started");
|
||||||
|
}
|
||||||
|
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
||||||
|
return self.error(conn, "only the red master can start");
|
||||||
|
}
|
||||||
|
for s in r.seats.values() {
|
||||||
|
if s.conn.is_some() && !s.ready {
|
||||||
|
return self.error(conn, "not everyone is ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.started = true;
|
||||||
|
r.paused = false;
|
||||||
|
r.missing = false;
|
||||||
|
r.seed = Some(random_seed());
|
||||||
|
r.turn = 0;
|
||||||
|
}
|
||||||
|
let msg = match self.rooms.get(&code) {
|
||||||
|
Some(r) => json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides}),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
self.room_msg(conn, &code);
|
||||||
|
self.broadcast_room(&code, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmds(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let side = match self.side_of(&code, conn) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let cmds = match msg.get("cmds").and_then(|v| v.as_array()) {
|
||||||
|
Some(a) => a.clone(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
if cmds.iter().any(|c| c.get("side").and_then(|v| v.as_u64()) != Some(side as u64)) {
|
||||||
|
return self.error(conn, "bad command side");
|
||||||
|
}
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
if !r.started || r.paused {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let list = r.pending.entry(side).or_default();
|
||||||
|
for c in cmds {
|
||||||
|
if list.len() < MAX_PENDING {
|
||||||
|
list.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sum(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let from = self.side_of(&code, conn);
|
||||||
|
let turn = msg.get("turn").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||||
|
let sum = match msg.get("sum").and_then(|v| v.as_u64()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
self.broadcast_room_except(&code, conn, json!({"t":"sum","from":from,"turn":turn,"sum":sum}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let text: String = msg.get("text").and_then(|v| v.as_str()).unwrap_or("").chars().filter(|c| !c.is_control()).take(CHAT_MAX).collect();
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
||||||
|
self.broadcast_room(&code, json!({"t":"chat","name":name,"text":text}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resume(&mut self, conn: u64) {
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
if !r.started {
|
||||||
|
return self.error(conn, "match not started");
|
||||||
|
}
|
||||||
|
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
||||||
|
return self.error(conn, "only the red master can resume");
|
||||||
|
}
|
||||||
|
r.paused = false;
|
||||||
|
r.missing = false;
|
||||||
|
}
|
||||||
|
self.room_msg_all(&code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abort(&mut self, conn: u64) {
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
let side = self.side_of(&code, conn);
|
||||||
|
if side != Some(0) {
|
||||||
|
return self.error(conn, "only the red master can abort");
|
||||||
|
}
|
||||||
|
self.broadcast_room(&code, json!({"t":"closed","why":"aborted"}));
|
||||||
|
if let Some(c) = self.clients.get_mut(&conn) {
|
||||||
|
c.room = None;
|
||||||
|
}
|
||||||
|
self.rooms.remove(&code);
|
||||||
|
self.rooms_msg(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leave_room(&mut self, conn: u64, code: &str, disconnect: bool) {
|
||||||
|
let (remove_room, messages) = match self.rooms.get_mut(code) {
|
||||||
|
Some(r) => {
|
||||||
|
let side0_left = r.seats.iter().any(|(&side, s)| s.conn == Some(conn) && side == 0);
|
||||||
|
for s in r.seats.values_mut() {
|
||||||
|
if s.conn == Some(conn) {
|
||||||
|
s.conn = None;
|
||||||
|
s.name.clear();
|
||||||
|
s.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.started {
|
||||||
|
if side0_left {
|
||||||
|
(true, vec![json!({"t":"closed","why":"host left"})])
|
||||||
|
} else {
|
||||||
|
r.paused = true;
|
||||||
|
r.missing = true;
|
||||||
|
(false, vec![json!({"t":"room","why":"missing","code":code,"turn":r.turn,"started":r.started,"paused":r.paused,"missing":r.missing})])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let empty = r.seats.values().all(|s| s.conn.is_none());
|
||||||
|
if empty {
|
||||||
|
(true, Vec::new())
|
||||||
|
} else if disconnect {
|
||||||
|
(false, vec![json!({"t":"room","why":"left","code":code,"turn":r.turn,"started":r.started,"paused":r.paused,"missing":r.missing})])
|
||||||
|
} else {
|
||||||
|
(false, Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (false, Vec::new()),
|
||||||
|
};
|
||||||
|
for msg in messages {
|
||||||
|
self.broadcast_room(code, msg);
|
||||||
|
}
|
||||||
|
if remove_room {
|
||||||
|
if let Some(c) = self.clients.get_mut(&conn) {
|
||||||
|
c.room = None;
|
||||||
|
}
|
||||||
|
self.rooms.remove(code);
|
||||||
|
self.rooms_msg(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn side_of(&self, code: &str, conn: u64) -> Option<u8> {
|
||||||
|
let r = self.rooms.get(code)?;
|
||||||
|
r.seats.iter().find(|(_, s)| s.conn == Some(conn)).map(|(side, _)| *side)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error(&mut self, conn: u64, why: &str) {
|
||||||
|
self.send(conn, json!({"t":"error","why":why}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&mut self, conn: u64, v: Value) {
|
||||||
|
if let Some(c) = self.clients.get_mut(&conn) {
|
||||||
|
let _ = c.tx.send(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast_room(&mut self, code: &str, v: Value) {
|
||||||
|
let targets: Vec<u64> = self.rooms.get(code).map(|r| r.seats.values().filter_map(|s| s.conn).collect()).unwrap_or_default();
|
||||||
|
for conn in targets {
|
||||||
|
self.send(conn, v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast_room_except(&mut self, code: &str, except: u64, v: Value) {
|
||||||
|
let targets: Vec<u64> = self.rooms.get(code).map(|r| r.seats.values().filter_map(|s| s.conn).filter(|c| *c != except).collect()).unwrap_or_default();
|
||||||
|
for conn in targets {
|
||||||
|
self.send(conn, v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn room_msg(&mut self, conn: u64, code: &str) {
|
||||||
|
if let Some(v) = self.room_json(code) {
|
||||||
|
self.send(conn, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn room_msg_all(&mut self, code: &str) {
|
||||||
|
if let Some(v) = self.room_json(code) {
|
||||||
|
self.broadcast_room(code, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn room_json(&self, code: &str) -> Option<Value> {
|
||||||
|
let r = self.rooms.get(code)?;
|
||||||
|
let seats: Vec<Value> = r
|
||||||
|
.sides
|
||||||
|
.iter()
|
||||||
|
.filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some()})))
|
||||||
|
.collect();
|
||||||
|
Some(json!({
|
||||||
|
"t":"room",
|
||||||
|
"code":r.code,
|
||||||
|
"sides":r.sides,
|
||||||
|
"seats":seats,
|
||||||
|
"turn":r.turn,
|
||||||
|
"started":r.started,
|
||||||
|
"paused":r.paused,
|
||||||
|
"missing":r.missing,
|
||||||
|
"map_w":r.map_w,
|
||||||
|
"map_h":r.map_h,
|
||||||
|
"difficulty":r.difficulty,
|
||||||
|
"seed":r.seed
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rooms_msg(&mut self, conn: u64) {
|
||||||
|
let rooms: Vec<Value> = self
|
||||||
|
.rooms
|
||||||
|
.values()
|
||||||
|
.map(|r| {
|
||||||
|
let players = r.seats.values().filter(|s| s.conn.is_some()).count();
|
||||||
|
json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.send(conn, json!({"t":"rooms","rooms":rooms}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::sync::mpsc::UnboundedReceiver;
|
||||||
|
|
||||||
|
fn pop(rx: &mut UnboundedReceiver<String>) -> Value {
|
||||||
|
let s = rx.try_recv().expect("no message");
|
||||||
|
serde_json::from_str(&s).expect("bad json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn until(rx: &mut UnboundedReceiver<String>, kind: &str) -> Value {
|
||||||
|
loop {
|
||||||
|
let v = pop(rx);
|
||||||
|
if v["t"] == kind {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_relay_confirms_one_turn_after_the_next() {
|
||||||
|
let mut net = Netplay::new();
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let red = net.connect(tx, Some("red".into()), None);
|
||||||
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
||||||
|
assert_eq!(rooms.len(), 1);
|
||||||
|
let code = rooms[0].clone();
|
||||||
|
|
||||||
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||||
|
let green = net.connect(tx2, Some("green".into()), None);
|
||||||
|
net.handle(green, json!({"t":"join","code":&code}));
|
||||||
|
net.handle(red, json!({"t":"ready"}));
|
||||||
|
net.handle(green, json!({"t":"ready"}));
|
||||||
|
net.handle(red, json!({"t":"start"}));
|
||||||
|
|
||||||
|
let start_red = until(&mut rx, "start");
|
||||||
|
let start_green = until(&mut rx2, "start");
|
||||||
|
assert_eq!(start_red["seed"], start_green["seed"]);
|
||||||
|
assert_eq!(start_red["sides"], json!([0, 2]));
|
||||||
|
|
||||||
|
let cmd = json!({"side":0,"seq":0,"act":{"Dig":{"x0":5,"y0":5,"x1":6,"y1":6}}});
|
||||||
|
net.handle(red, json!({"t":"cmds","cmds":[cmd.clone()]}));
|
||||||
|
net.tick(&code);
|
||||||
|
|
||||||
|
let turn_red = until(&mut rx, "turn");
|
||||||
|
let turn_green = until(&mut rx2, "turn");
|
||||||
|
assert_eq!(turn_red["turn"].as_u64(), Some(1));
|
||||||
|
assert_eq!(turn_red["cmds"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(turn_green["cmds"], turn_red["cmds"]);
|
||||||
|
|
||||||
|
net.handle(red, json!({"t":"sum","turn":1,"sum":12345678901234567890u64}));
|
||||||
|
let sum = until(&mut rx2, "sum");
|
||||||
|
assert_eq!(sum["sum"].as_u64(), Some(12345678901234567890u64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_host_cannot_start_a_match() {
|
||||||
|
let mut net = Netplay::new();
|
||||||
|
let (tx, _rx) = mpsc::unbounded_channel();
|
||||||
|
let red = net.connect(tx, Some("red".into()), None);
|
||||||
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
||||||
|
let code = rooms[0].clone();
|
||||||
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||||
|
let green = net.connect(tx2, Some("green".into()), None);
|
||||||
|
net.handle(green, json!({"t":"join","code":&code}));
|
||||||
|
net.handle(green, json!({"t":"ready"}));
|
||||||
|
net.handle(green, json!({"t":"start"}));
|
||||||
|
let err = until(&mut rx2, "error");
|
||||||
|
assert_eq!(err["why"].as_str(), Some("only the red master can start"));
|
||||||
|
assert!(!net.rooms[&code].started);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_started_match_pauses_when_a_side_goes_missing() {
|
||||||
|
let mut net = Netplay::new();
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let red = net.connect(tx, Some("red".into()), None);
|
||||||
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
||||||
|
let code = rooms[0].clone();
|
||||||
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||||
|
let green = net.connect(tx2, Some("green".into()), None);
|
||||||
|
net.handle(green, json!({"t":"join","code":&code}));
|
||||||
|
net.handle(red, json!({"t":"ready"}));
|
||||||
|
net.handle(green, json!({"t":"ready"}));
|
||||||
|
net.handle(red, json!({"t":"start"}));
|
||||||
|
until(&mut rx, "start");
|
||||||
|
until(&mut rx2, "start");
|
||||||
|
|
||||||
|
net.disconnect(green);
|
||||||
|
let missing = until(&mut rx, "room");
|
||||||
|
assert_eq!(missing["missing"].as_bool(), Some(true));
|
||||||
|
net.handle(red, json!({"t":"resume"}));
|
||||||
|
net.tick(&code);
|
||||||
|
let turn = until(&mut rx, "turn");
|
||||||
|
assert_eq!(turn["turn"].as_u64(), Some(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user