first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-12 21:55:03 +07:00
commit 835475ac86
40 changed files with 6167 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
+805
View File
@@ -0,0 +1,805 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[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]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"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.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[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",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "showdown-backend"
version = "0.1.0"
dependencies = [
"axum",
"serde",
"serde_json",
"tokio",
"tower-http",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[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]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"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]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"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.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "showdown-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["fs"] }
[profile.release]
opt-level = "z"
lto = true
strip = true
+46
View File
@@ -0,0 +1,46 @@
# Showdown backend
Rust (axum + tokio). Implements `../PROTOCOL.md`: WebSocket rooms that live
entirely in memory and a static file server for the built frontend — the whole
app is one binary plus a `dist/` folder.
## Run it
```sh
# build the frontend first so there is something to serve
cd ../frontend && npm run build && cd ../backend
cargo run # http://localhost:8080
```
- `PORT` — listen port (default `8080`)
- `STATIC_DIR` — built frontend location (default `../frontend/dist`)
During frontend development you can instead run `npm run dev` with
`VITE_WS=1` and Vite proxies `/ws` here.
## How it stays small
- **No database, by design.** A `Room` is a deck, a `revealed` flag, a round
counter and a `Vec` of players — a few hundred bytes. All rooms sit in one
`Mutex<HashMap>`. When the last socket of a room closes, the room is
removed; restart the server and everything is gone, which is the point.
- **One task per socket, nothing else.** Each connection is a single
`tokio::select!` loop (snapshots out, messages in, a 30s keepalive ping).
No per-room goroutine-style actors, no background jobs, no timers per room.
- **Snapshots are serialized once per change** (borrowed data, no clones) and
fanned out through a per-room `broadcast` channel with a small buffer.
## Files
```
src/main.rs routes + static serving with SPA fallback (~40 lines)
src/room.rs room state and the game rules (vote/reveal/reset/deck)
src/ws.rs socket lifecycle: hello → select loop → disconnect cleanup
```
Rules enforced here (mirroring PROTOCOL.md): votes are hidden until reveal
and rejected after it; reveal needs at least one vote; reset/deck-change
start a fresh round; a refresh re-claims the same seat without losing the
vote; watchers (`watch` handshake — the TV view) receive everything and may
send nothing.
+39
View File
@@ -0,0 +1,39 @@
//! Showdown backend: WebSocket rooms held entirely in memory, plus static
//! file serving for the built frontend. No database — a room lives exactly
//! as long as someone has it open. See ../PROTOCOL.md for the wire format.
mod room;
mod ws;
use std::env;
use std::path::PathBuf;
use axum::routing::get;
use axum::Router;
use tower_http::services::{ServeDir, ServeFile};
use crate::room::Rooms;
#[tokio::main]
async fn main() {
let rooms = Rooms::default();
let static_dir =
PathBuf::from(env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/dist".into()));
// Unknown paths fall back to index.html so deep links like /room/abc work.
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(static_dir.join("index.html")));
let app = Router::new()
.route("/ws/room/{room_id}", get(ws::ws_handler))
.fallback_service(spa)
.with_state(rooms);
let port: u16 = env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8080);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.expect("failed to bind port");
println!("🤠 Showdown saloon open at http://localhost:{port}");
println!(" serving frontend from {}", static_dir.display());
axum::serve(listener, app).await.expect("server error");
}
+214
View File
@@ -0,0 +1,214 @@
//! Room state and rules. Everything lives in one `Mutex<HashMap>` — a room
//! is a few hundred bytes and is dropped the moment its last socket closes.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
pub type Rooms = Arc<Mutex<HashMap<String, Room>>>;
pub const DEFAULT_DECK: &[&str] = &["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", ""];
const MAX_DECK_CARDS: usize = 15;
const MAX_CARD_CHARS: usize = 4;
/// Everything a client may send. The first frame must be `join` or `watch`;
/// the rest only make sense from a seated player.
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ClientMessage {
Join {
#[serde(rename = "playerId")]
player_id: String,
name: String,
emoji: String,
},
Watch,
Vote { value: Option<String> },
Reveal,
Reset,
Deck { cards: Vec<String> },
}
pub struct Player {
pub id: String,
pub name: String,
pub emoji: String,
pub vote: Option<String>,
/// Newest socket claiming this seat. A refresh re-joins with the same
/// player id and takes over the seat (keeping the vote); the old socket's
/// disconnect then sees a newer `conn` and leaves the seat alone.
pub conn: u64,
}
pub struct Room {
pub deck: Vec<String>,
pub revealed: bool,
pub round: u64,
/// Join order is seat order, and scrum teams are small — a Vec beats a map.
pub players: Vec<Player>,
/// Open sockets, players and watchers alike. Zero means delete the room.
pub conns: usize,
/// Every state change is serialized once and fanned out through here.
pub tx: broadcast::Sender<String>,
}
impl Room {
pub fn new() -> Self {
Self {
deck: DEFAULT_DECK.iter().map(|c| c.to_string()).collect(),
revealed: false,
round: 1,
players: Vec::new(),
conns: 0,
tx: broadcast::channel(32).0,
}
}
pub fn join(&mut self, id: &str, name: &str, emoji: &str, conn: u64) {
match self.players.iter_mut().find(|p| p.id == id) {
Some(p) => {
p.name = name.to_string();
p.emoji = emoji.to_string();
p.conn = conn;
}
None => self.players.push(Player {
id: id.to_string(),
name: name.to_string(),
emoji: emoji.to_string(),
vote: None,
conn,
}),
}
}
/// Free the seat unless a newer socket (a refresh) already re-claimed it.
/// Returns whether the player list actually changed.
pub fn leave(&mut self, id: &str, conn: u64) -> bool {
match self.players.iter().position(|p| p.id == id && p.conn == conn) {
Some(pos) => {
self.players.remove(pos);
true
}
None => false,
}
}
pub fn apply(&mut self, player_id: &str, msg: ClientMessage) {
match msg {
ClientMessage::Vote { value } => {
if self.revealed {
return; // no changing your story after the showdown
}
if let Some(v) = &value {
if !self.deck.contains(v) {
return;
}
}
if let Some(p) = self.players.iter_mut().find(|p| p.id == player_id) {
p.vote = value;
}
}
ClientMessage::Reveal => {
if self.players.iter().any(|p| p.vote.is_some()) {
self.revealed = true;
}
}
ClientMessage::Reset => self.new_round(),
ClientMessage::Deck { cards } => {
if let Some(deck) = clean_deck(cards) {
self.deck = deck;
self.new_round();
}
}
// join/watch are handshake messages, meaningless mid-session.
ClientMessage::Join { .. } | ClientMessage::Watch => {}
}
}
fn new_round(&mut self) {
self.revealed = false;
self.round += 1;
for p in &mut self.players {
p.vote = None;
}
}
/// One JSON snapshot of the room. Votes stay hidden until the reveal —
/// only `voted` leaks beforehand. Serializes from borrows; no clones.
pub fn snapshot_json(&self, room_id: &str) -> String {
#[derive(Serialize)]
struct PlayerOut<'a> {
id: &'a str,
name: &'a str,
emoji: &'a str,
voted: bool,
vote: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Snapshot<'a> {
room_id: &'a str,
deck: &'a [String],
revealed: bool,
round: u64,
players: Vec<PlayerOut<'a>>,
}
#[derive(Serialize)]
struct StateMsg<'a> {
r#type: &'static str,
state: Snapshot<'a>,
}
let players = self
.players
.iter()
.map(|p| PlayerOut {
id: &p.id,
name: &p.name,
emoji: &p.emoji,
voted: p.vote.is_some(),
vote: if self.revealed { p.vote.as_deref() } else { None },
})
.collect();
serde_json::to_string(&StateMsg {
r#type: "state",
state: Snapshot {
room_id,
deck: &self.deck,
revealed: self.revealed,
round: self.round,
players,
},
})
.expect("room state always serializes")
}
pub fn broadcast(&self, room_id: &str) {
// Send fails only when nobody is listening, which is fine.
let _ = self.tx.send(self.snapshot_json(room_id));
}
}
/// Trim, dedupe and cap a proposed deck. None if fewer than two cards survive.
fn clean_deck(cards: Vec<String>) -> Option<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for raw in cards {
let card = raw.trim();
if card.is_empty() || card.chars().count() > MAX_CARD_CHARS {
continue;
}
if out.iter().any(|c| c == card) {
continue;
}
out.push(card.to_string());
if out.len() == MAX_DECK_CARDS {
break;
}
}
(out.len() >= 2).then_some(out)
}
+196
View File
@@ -0,0 +1,196 @@
//! One async task per socket. The task introduces itself (join/watch), then
//! sits in a single select loop: room snapshots out, client messages in.
//! Disconnect cleanup is what makes presence work — closing the tab is
//! leaving the room.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::room::{ClientMessage, Room, Rooms};
/// Distinguishes the sockets of a player who refreshed; see `Player::conn`.
static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
const HELLO_TIMEOUT: Duration = Duration::from_secs(10);
const PING_INTERVAL: Duration = Duration::from_secs(30);
enum Seat {
Player { id: String },
Watcher,
}
pub async fn ws_handler(
Path(room_id): Path<String>,
State(rooms): State<Rooms>,
ws: WebSocketUpgrade,
) -> Response {
if !valid_room_id(&room_id) {
return (StatusCode::BAD_REQUEST, "bad room id").into_response();
}
ws.on_upgrade(move |socket| handle_socket(socket, room_id, rooms))
}
fn valid_room_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
async fn handle_socket(mut socket: WebSocket, room_id: String, rooms: Rooms) {
// 1. The first frame must introduce the socket: join (player) or watch (TV).
let hello = match tokio::time::timeout(HELLO_TIMEOUT, socket.recv()).await {
Ok(Some(Ok(Message::Text(text)))) => text,
_ => return,
};
let seat = match serde_json::from_str::<ClientMessage>(&hello) {
Ok(ClientMessage::Join { player_id, name, emoji }) => {
if player_id.is_empty() || player_id.len() > 64 {
return;
}
let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
register_player(
&rooms,
&room_id,
&player_id,
&clean_text(&name, 16, "Outlaw"),
&clean_text(&emoji, 8, "🤠"),
conn_id,
);
(Seat::Player { id: player_id }, conn_id)
}
Ok(ClientMessage::Watch) => {
let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
register_watcher(&rooms, &room_id, conn_id);
(Seat::Watcher, conn_id)
}
_ => return,
};
let (seat, conn_id) = seat;
// 2. Subscribe to snapshots and send this socket the current one.
let (mut rx, snapshot) = {
let mut map = rooms.lock().unwrap();
let room = map.get_mut(&room_id).expect("registered above");
(room.tx.subscribe(), room.snapshot_json(&room_id))
};
// 3. The whole session is one loop; whenever it ends, fall through to cleanup.
serve(&mut socket, &room_id, &rooms, &seat, &mut rx, snapshot).await;
// 4. Disconnect: free the seat, tell the others, drop the room if empty.
cleanup(&rooms, &room_id, &seat, conn_id);
}
fn register_player(rooms: &Rooms, room_id: &str, id: &str, name: &str, emoji: &str, conn: u64) {
let mut map = rooms.lock().unwrap();
let room = map.entry(room_id.to_string()).or_insert_with(Room::new);
room.conns += 1;
room.join(id, name, emoji, conn);
room.broadcast(room_id);
}
fn register_watcher(rooms: &Rooms, room_id: &str, _conn: u64) {
let mut map = rooms.lock().unwrap();
let room = map.entry(room_id.to_string()).or_insert_with(Room::new);
room.conns += 1;
}
async fn serve(
socket: &mut WebSocket,
room_id: &str,
rooms: &Rooms,
seat: &Seat,
rx: &mut broadcast::Receiver<String>,
first_snapshot: String,
) {
if socket.send(Message::Text(first_snapshot.into())).await.is_err() {
return;
}
let mut ping = tokio::time::interval(PING_INTERVAL);
ping.tick().await; // the first tick is immediate; skip it
loop {
tokio::select! {
// A state change somewhere in the room — relay the snapshot.
update = rx.recv() => {
let json = match update {
Ok(json) => json,
// This socket fell behind; skip straight to the freshest state.
Err(RecvError::Lagged(_)) => match current_snapshot(rooms, room_id) {
Some(json) => json,
None => return,
},
Err(RecvError::Closed) => return,
};
if socket.send(Message::Text(json.into())).await.is_err() {
return;
}
}
// A frame from this client.
frame = socket.recv() => {
let text = match frame {
Some(Ok(Message::Text(text))) => text,
Some(Ok(_)) => continue, // pongs etc. — ignore
_ => return, // closed or errored
};
let Seat::Player { id } = seat else {
continue; // watchers are read-only
};
let Ok(msg) = serde_json::from_str::<ClientMessage>(&text) else {
continue; // malformed frame — ignore
};
let mut map = rooms.lock().unwrap();
if let Some(room) = map.get_mut(room_id) {
room.apply(id, msg);
room.broadcast(room_id);
}
}
// Keepalive so idle rooms survive proxies and NAT timeouts.
_ = ping.tick() => {
if socket.send(Message::Ping(Default::default())).await.is_err() {
return;
}
}
}
}
}
fn current_snapshot(rooms: &Rooms, room_id: &str) -> Option<String> {
rooms.lock().unwrap().get(room_id).map(|r| r.snapshot_json(room_id))
}
fn cleanup(rooms: &Rooms, room_id: &str, seat: &Seat, conn_id: u64) {
let mut map = rooms.lock().unwrap();
let Some(room) = map.get_mut(room_id) else {
return;
};
room.conns -= 1;
if room.conns == 0 {
// Last one out: the whole room vanishes. No database, no leftovers.
map.remove(room_id);
return;
}
if let Seat::Player { id } = seat {
if room.leave(id, conn_id) {
room.broadcast(room_id);
}
}
}
fn clean_text(raw: &str, max_chars: usize, fallback: &str) -> String {
let trimmed: String = raw.trim().chars().take(max_chars).collect();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed
}
}