From 5e0c188d9be40e3cccd182db0ee768310d2f1c5f Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 08:18:07 -0700 Subject: [PATCH] feat: add Rust Axum backend with WebSocket game server Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/.gitignore | 1 + backend/Cargo.lock | 1452 +++++++++++++++++++++++++++++++++++++++ backend/Cargo.toml | 20 + backend/src/game.rs | 354 ++++++++++ backend/src/main.rs | 217 ++++++ backend/src/physics.rs | 380 ++++++++++ backend/src/protocol.rs | 126 ++++ 7 files changed, 2550 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/Cargo.lock create mode 100644 backend/Cargo.toml create mode 100644 backend/src/game.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/physics.rs create mode 100644 backend/src/protocol.rs diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +/target diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..7ca357a --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,1452 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[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 = "curltastic-backend" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "rand 0.8.6", + "rapier2d", + "serde", + "serde_json", + "tokio", + "tokio-test", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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 = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-macro", + "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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "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 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + +[[package]] +name = "glam" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898f5a568a84989b6c0f8caa50a93074b97dbdc58fc6d9543157bb4562758933" +dependencies = [ + "approx", + "libm", +] + +[[package]] +name = "glamx" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9847f980afb0628849bcd6a406cdffd29eb9d53fca83e1646005901a9acd4c5c" +dependencies = [ + "approx", + "glam 0.33.1", + "nalgebra", + "num-traits", + "simba", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[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 = "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 = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[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 = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nalgebra" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" +dependencies = [ + "approx", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "glam 0.33.1", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parry2d" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442878be87d7db217355c73d925df664b0f8e46faf115c58a52139a3e40c8cd" +dependencies = [ + "approx", + "arrayvec", + "bitflags", + "downcast-rs", + "either", + "ena", + "foldhash", + "glamx", + "hashbrown 0.17.1", + "log", + "num-derive", + "num-traits", + "ordered-float", + "simba", + "slab", + "smallvec", + "spade", + "thiserror", +] + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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]] +name = "rapier2d" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6350781ded7f80bc5c76ef9c45c57813c34c2b3df43769677114633a6b36059e" +dependencies = [ + "approx", + "bitflags", + "glamx", + "log", + "nalgebra", + "num-traits", + "parry2d", + "profiling", + "serde", + "simba", + "static_assertions", + "thiserror", + "wide", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simba" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f45c644a9f3a386f9288625d9f0c1e999e1acf07a37df35d0516c7f199d9cb2" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "wide", +] + +[[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 = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +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 = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "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-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[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-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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[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 0.9.4", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wide" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[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" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..938c166 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "curltastic-backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rapier2d = "0.33" +uuid = { version = "1", features = ["v4"] } +futures-util = "0.3" +tokio-util = "0.7" +tracing = "0.1" +tracing-subscriber = "0.3" +rand = "0.8" + +[dev-dependencies] +tokio-test = "0.4" diff --git a/backend/src/game.rs b/backend/src/game.rs new file mode 100644 index 0000000..a9ea466 --- /dev/null +++ b/backend/src/game.rs @@ -0,0 +1,354 @@ +use crate::protocol::*; +use crate::physics::PhysicsWorld; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GamePhase { + Waiting, + Playing, + Simulating, + Scoring, + EndComplete, + GameComplete, +} + +pub struct Game { + red: Option, + yellow: Option, + phase: GamePhase, + end: u8, + scores: [i32; 2], + hammer: Team, + turn_team: Team, + physics: PhysicsWorld, + active_stones: Vec, + stones_red: u8, + stones_yellow: u8, + last_end_scored: Option<(u8, i32, Option)>, + pub room_tx: Option>, +} + +impl Game { + pub fn new() -> Self { + Self { + red: None, + yellow: None, + phase: GamePhase::Waiting, + end: 1, + scores: [0, 0], + hammer: Team::Red, + turn_team: Team::Red, + physics: PhysicsWorld::new(), + active_stones: Vec::new(), + stones_red: STONES_PER_TEAM, + stones_yellow: STONES_PER_TEAM, + last_end_scored: None, + room_tx: None, + } + } + + pub fn add_player(&mut self, id: String, preferred: Option) -> Option { + let team = match preferred { + Some(Team::Red) if self.red.is_none() => Some(Team::Red), + Some(Team::Yellow) if self.yellow.is_none() => Some(Team::Yellow), + Some(_) => { + // Preferred is taken; fall back to the other free team. + if self.red.is_none() { + Some(Team::Red) + } else if self.yellow.is_none() { + Some(Team::Yellow) + } else { + None + } + } + None => { + // Legacy first-free logic. + if self.red.is_none() { + Some(Team::Red) + } else if self.yellow.is_none() { + Some(Team::Yellow) + } else { + None + } + } + }; + + if let Some(t) = team { + let player = Player { id, team: t, connected: true }; + match t { + Team::Red => self.red = Some(player), + Team::Yellow => self.yellow = Some(player), + } + Some(t) + } else { + None + } + } + + pub fn remove_player(&mut self, id: &str) -> Option { + if let Some(ref p) = self.red { + if p.id == id { + self.red = None; + return Some(Team::Red); + } + } + if let Some(ref p) = self.yellow { + if p.id == id { + self.yellow = None; + return Some(Team::Yellow); + } + } + None + } + + pub fn set_player_connected(&mut self, id: &str, connected: bool) { + if let Some(p) = self.red.as_mut().filter(|p| p.id == id) { + p.connected = connected; + } + if let Some(p) = self.yellow.as_mut().filter(|p| p.id == id) { + p.connected = connected; + } + } + + pub fn can_start(&self) -> bool { + self.red.is_some() && self.yellow.is_some() + } + + pub fn start(&mut self) { + if !self.can_start() { + return; + } + self.hammer = if rand::random() { Team::Red } else { Team::Yellow }; + self.turn_team = self.hammer.other(); + self.phase = GamePhase::Playing; + self.end = 1; + self.stones_red = STONES_PER_TEAM; + self.stones_yellow = STONES_PER_TEAM; + self.scores = [0, 0]; + self.physics.reset(); + self.physics.reset_stone_ids(); + self.active_stones.clear(); + } + + pub fn current_player_id(&self) -> Option<&str> { + let p = match self.turn_team { + Team::Red => self.red.as_ref()?, + Team::Yellow => self.yellow.as_ref()?, + }; + Some(&p.id) + } + + pub fn current_team_for_player(&self, player_id: &str) -> Option { + if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } } + if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } } + None + } + + pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result, String> { + let current_id = self.current_player_id().ok_or("No current player")?; + if current_id != player_id { + return Err("Not your turn".to_string()); + } + if self.phase != GamePhase::Playing { + return Err("Cannot throw now".to_string()); + } + let dx = broom_x - HOUSE_CENTER.0; + let dy = broom_y - HOUSE_CENTER.1; + let dist = (dx * dx + dy * dy).sqrt(); + if dist > HOUSE_RADIUS { + return Err("Broom must be in the house".to_string()); + } + + self.active_stones.clear(); + let path = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?; + self.active_stones = self.physics.current_stones(); + self.phase = GamePhase::Simulating; + + match self.turn_team { + Team::Red => self.stones_red = self.stones_red.saturating_sub(1), + Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1), + } + + Ok(path) + } + + pub fn finish_simulation(&mut self) { + if self.phase != GamePhase::Simulating { + return; + } + self.active_stones = self.physics.current_stones(); + self.score_end_internal(false); + } + + pub fn score_end(&mut self) { + self.score_end_internal(true); + } + + fn score_end_internal(&mut self, force: bool) { + let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0); + if !end_done { + self.phase = GamePhase::Playing; + self.turn_team = self.turn_team.other(); + return; + } + + self.phase = GamePhase::Scoring; + let states = self.physics.stone_states_for_scoring(); + let mut by_distance: Vec<_> = states.iter() + .map(|(id, team, x, y)| { + let dx = x - HOUSE_CENTER.0; + let dy = y - HOUSE_CENTER.1; + let dist = (dx * dx + dy * dy).sqrt(); + (dist, *id, *team, *x, *y) + }) + .filter(|(dist, _, _, _, _)| *dist <= HOUSE_RADIUS) + .collect(); + by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + + let scoring_team: Option = by_distance.first().map(|(_, _, team, _, _)| *team); + let mut points = 0; + if let Some(team) = scoring_team { + for (_, _, t, _, _) in &by_distance { + if *t == team { + points += 1; + } else { + break; + } + } + + if points > 0 { + let team_idx = match team { + Team::Red => 0, + Team::Yellow => 1, + }; + self.scores[team_idx] += points as i32; + self.hammer = team.other(); + } else { + points = 0; + } + } + + self.last_end_scored = Some((self.end, points, scoring_team)); + self.phase = GamePhase::EndComplete; + self.end_ends_or_continue(points, scoring_team); + } + + fn end_ends_or_continue(&mut self, _points: i32, _scoring_team: Option) { + let tied = self.scores[0] == self.scores[1]; + let after_regulation = self.end >= ENDS; + + if after_regulation && !tied { + self.phase = GamePhase::GameComplete; + return; + } + + if after_regulation && tied { + // Extra end + } + + self.end += 1; + self.stones_red = STONES_PER_TEAM; + self.stones_yellow = STONES_PER_TEAM; + self.active_stones.clear(); + self.physics.reset(); + self.physics.reset_stone_ids(); + self.turn_team = self.hammer.other(); + self.phase = GamePhase::Playing; + } + + pub fn phase(&self) -> GamePhase { + self.phase + } + + pub fn take_last_end_scored(&mut self) -> Option { + let msg = self.last_end_scored.map(|(end, points, scoring_team)| { + ServerMessage::EndScored { end, points, scoring_team } + }); + self.last_end_scored = None; + msg + } + + pub fn game_state_message(&self) -> ServerMessage { + ServerMessage::GameState { + end: self.end, + scores: self.scores, + hammer: self.hammer, + turn_team: self.turn_team, + stones: self.active_stones.clone(), + phase: match self.phase { + GamePhase::Waiting => Phase::Waiting, + GamePhase::Playing => Phase::Playing, + GamePhase::Simulating => Phase::Simulating, + GamePhase::Scoring => Phase::Scoring, + GamePhase::EndComplete => Phase::EndComplete, + GamePhase::GameComplete => Phase::GameComplete, + }, + } + } + + pub fn game_over_message(&self) -> ServerMessage { + let winner = if self.scores[0] > self.scores[1] { + Some(Team::Red) + } else if self.scores[1] > self.scores[0] { + Some(Team::Yellow) + } else { + None + }; + ServerMessage::GameOver { + scores: self.scores, + winner, + } + } +} + +pub type RoomId = String; + +pub struct Room { + pub id: RoomId, + pub game: Game, + pub tx: tokio::sync::broadcast::Sender, +} + +impl Room { + pub fn new(id: &str) -> Self { + let (tx, _) = tokio::sync::broadcast::channel(256); + let mut room = Self { + id: id.to_string(), + game: Game::new(), + tx, + }; + room.game.room_tx = Some(room.tx.clone()); + room + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_red_when_requested_and_free() { + let mut game = Game::new(); + assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red)); + } + + #[test] + fn prefers_yellow_when_requested_and_free() { + let mut game = Game::new(); + assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow)); + } + + #[test] + fn falls_back_when_preferred_taken() { + let mut game = Game::new(); + assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red)); + assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow)); + } + + #[test] + fn legacy_order_without_preference() { + let mut game = Game::new(); + assert_eq!(game.add_player("p1".into(), None), Some(Team::Red)); + assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow)); + assert_eq!(game.add_player("p3".into(), None), None); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..a7f42fa --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,217 @@ +mod protocol; +mod physics; +mod game; + +use axum::{ + extract::{Query, State, WebSocketUpgrade}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use axum::extract::ws::{Message, Utf8Bytes, WebSocket}; +use futures_util::{sink::SinkExt, stream::StreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::sync::Mutex as TokioMutex; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::game::{GamePhase, Room}; +use crate::protocol::*; + +#[derive(Clone)] +struct AppState { + rooms: Arc>>>>, +} + +impl AppState { + fn new() -> Self { + Self { rooms: Arc::new(Mutex::new(HashMap::new())) } + } + + fn get_or_create_room(&self, room_id: &str) -> Arc> { + let mut rooms = self.rooms.lock().unwrap(); + if let Some(room) = rooms.get(room_id).cloned() { + return room; + } + let room = Arc::new(TokioMutex::new(Room::new(room_id))); + rooms.insert(room_id.to_string(), room.clone()); + room + } +} + +#[derive(Deserialize)] +struct RoomQuery { + room: String, + team: Option, +} + +#[derive(Serialize)] +struct NewRoomResponse { + room: String, +} + +fn generate_room_code() -> String { + let s = Uuid::new_v4().to_string().replace("-", ""); + s.chars().take(6).collect::().to_uppercase() +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let state = Arc::new(AppState::new()); + + let app = Router::new() + .route("/", get(health)) + .route("/room", post(new_room)) + .route("/ws", get(ws_handler)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + info!("Server listening on 0.0.0.0:3000"); + axum::serve(listener, app).await.unwrap(); +} + +async fn health() -> impl IntoResponse { + "curltastic ok" +} + +async fn new_room(State(state): State>) -> impl IntoResponse { + let room_id = generate_room_code(); + state.get_or_create_room(&room_id); + (StatusCode::OK, axum::Json(NewRoomResponse { room: room_id })) +} + +async fn ws_handler( + ws: WebSocketUpgrade, + Query(query): Query, + State(state): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team)) +} + +async fn handle_socket(socket: WebSocket, state: Arc, room_id: String, preferred_team: Option) { + let room_arc = state.get_or_create_room(&room_id); + let player_id = Uuid::new_v4().to_string(); + let player_id_for_recv = player_id.clone(); + + let (mut sender, mut receiver) = socket.split(); + + // Reject room-full before subscribing so the error goes only to the joining socket. + { + let room = room_arc.lock().await; + if room.game.can_start() { + let err = serde_json::to_string(&ServerMessage::Error { + message: "Room is full".to_string(), + }).unwrap(); + let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await; + return; + } + } + + // Add the player to the room and tell only this socket its assigned team. + let team = { + let mut room = room_arc.lock().await; + let team = room.game.add_player(player_id.clone(), preferred_team) + .unwrap_or(Team::Red); + if room.game.can_start() { + room.game.start(); + } + team + }; + + let joined_msg = serde_json::to_string(&ServerMessage::Joined { + room: room_id.clone(), + team, + }).unwrap(); + let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await; + + // Subscribe to broadcast and spawn the forwarding task. + let tx = { + let room = room_arc.lock().await; + room.tx.clone() + }; + let mut rx = tx.subscribe(); + + let send_task = tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(msg) => { + let text = match serde_json::to_string(&msg) { + Ok(t) => t, + Err(_) => continue, + }; + if sender.send(Message::Text(Utf8Bytes::from(text))).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + // Broadcast waiting/game state to everyone in the room. + { + let room = room_arc.lock().await; + if room.game.can_start() { + let state_msg = room.game.game_state_message(); + let _ = room.tx.send(state_msg); + } else { + let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() }); + } + } + + let recv_room = room_arc.clone(); + let recv_id = player_id_for_recv; + let recv_task = tokio::spawn(async move { + while let Some(Ok(msg)) = receiver.next().await { + let Message::Text(text) = msg else { continue; }; + let text_ref = text.as_str(); + let parsed: Result = serde_json::from_str(text_ref); + match parsed { + Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => { + let room = recv_room.clone(); + let mut room = room.lock().await; + if room.game.current_team_for_player(&recv_id) != Some(team) { + let _ = room.tx.send(ServerMessage::Error { message: "Not your turn".to_string() }); + continue; + } + match room.game.handle_throw(&recv_id, broom_x, broom_y, weight, curl, friction) { + Ok(path) => { + room.tx.send(ServerMessage::Trajectory { path }).ok(); + room.game.finish_simulation(); + if let Some(scored) = room.game.take_last_end_scored() { + room.tx.send(scored).ok(); + } + let after = room.game.game_state_message(); + room.tx.send(after).ok(); + if room.game.phase() == GamePhase::GameComplete { + room.tx.send(room.game.game_over_message()).ok(); + } + } + Err(e) => { + room.tx.send(ServerMessage::Error { message: e }).ok(); + } + } + } + Err(e) => { + warn!("Invalid message: {}", e); + } + } + } + }); + + tokio::select! { + _ = send_task => {} + _ = recv_task => {} + } + + // On disconnect, free the team slot so refreshes and new tabs can rejoin. + { + let mut room = room_arc.lock().await; + let _ = room.game.remove_player(&player_id); + } +} diff --git a/backend/src/physics.rs b/backend/src/physics.rs new file mode 100644 index 0000000..4142952 --- /dev/null +++ b/backend/src/physics.rs @@ -0,0 +1,380 @@ +use rapier2d::prelude::*; + +use crate::protocol::*; + +const LINEAR_DAMPING: f32 = 0.142; +const ANGULAR_DAMPING: f32 = 0.18; +const MAX_SIM_TIME: f32 = 30.0; +const REST_SPEED: f32 = 0.04; +const REST_ANGULAR_SPEED: f32 = 0.05; +// Rotation rate of the velocity vector, in rad/s. +// Positive curl_sign = right curl -> curves toward +x when moving up-sheet. +const CURL_RATE: f32 = 0.010; + +pub struct PhysicsWorld { + gravity: Vector, + integration_parameters: IntegrationParameters, + pipeline: PhysicsPipeline, + islands: IslandManager, + broad_phase: DefaultBroadPhase, + narrow_phase: NarrowPhase, + bodies: RigidBodySet, + colliders: ColliderSet, + impulse_joints: ImpulseJointSet, + multibody_joints: MultibodyJointSet, + ccd_solver: CCDSolver, + next_stone_id: u32, + stone_handles: Vec<(u32, RigidBodyHandle, Team, i8)>, +} + +impl Default for PhysicsWorld { + fn default() -> Self { + Self::new() + } +} + +impl PhysicsWorld { + pub fn new() -> Self { + let mut integration_parameters = IntegrationParameters::default(); + integration_parameters.dt = PHYSICS_DT; + integration_parameters.num_solver_iterations = 8; + + let mut world = Self { + gravity: Vector::new(0.0, 0.0), + integration_parameters, + pipeline: PhysicsPipeline::new(), + islands: IslandManager::new(), + broad_phase: DefaultBroadPhase::new(), + narrow_phase: NarrowPhase::new(), + bodies: RigidBodySet::new(), + colliders: ColliderSet::new(), + impulse_joints: ImpulseJointSet::new(), + multibody_joints: MultibodyJointSet::new(), + ccd_solver: CCDSolver::new(), + next_stone_id: 1, + stone_handles: Vec::new(), + }; + world.build_sheet(); + world + } + + pub fn reset(&mut self) { + self.bodies = RigidBodySet::new(); + self.colliders = ColliderSet::new(); + self.islands = IslandManager::new(); + self.broad_phase = DefaultBroadPhase::new(); + self.narrow_phase = NarrowPhase::new(); + self.impulse_joints = ImpulseJointSet::new(); + self.multibody_joints = MultibodyJointSet::new(); + self.ccd_solver = CCDSolver::new(); + self.stone_handles.clear(); + self.build_sheet(); + } + + pub fn reset_stone_ids(&mut self) { + self.next_stone_id = 1; + } + + fn build_sheet(&mut self) { + let half = SHEET_WIDTH / 2.0 + 0.1; + let left = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0) + .translation(Vector::new(-half, SHEET_LENGTH / 2.0)) + .friction(0.0) + .restitution(0.0) + .build(); + self.colliders.insert(left); + + let right = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0) + .translation(Vector::new(half, SHEET_LENGTH / 2.0)) + .friction(0.0) + .restitution(0.0) + .build(); + self.colliders.insert(right); + + let back = ColliderBuilder::cuboid(SHEET_WIDTH / 2.0 + 1.0, 0.1) + .translation(Vector::new(0.0, SHEET_LENGTH + 0.1)) + .friction(0.0) + .restitution(0.1) + .build(); + self.colliders.insert(back); + } + + pub fn throw( + &mut self, + team: Team, + broom_x: f32, + broom_y: f32, + weight: u8, + curl: i8, + friction: f32, + ) -> Result, String> { + let weight = weight.clamp(1, 10) as f32; + let t = (weight - 1.0) / 9.0; + let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED); + let dx = broom_x; + let dy = broom_y - HACK_Y; + let len = (dx * dx + dy * dy).sqrt().max(0.01); + let vx = dx / len * speed; + let vy = dy / len * speed; + + let curl_sign = if curl < 0 { -1 } else { 1 }; + let damping_mult = friction.clamp(0.5, 2.0); + + self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, damping_mult) + } + + fn spawn_stone( + &mut self, + team: Team, + x: f32, + y: f32, + vx: f32, + vy: f32, + curl_sign: i8, + damping_mult: f32, + ) -> Result, String> { + let id = self.next_stone_id; + self.next_stone_id += 1; + + let body = RigidBodyBuilder::dynamic() + .translation(Vector::new(x, y)) + .linvel(Vector::new(vx, vy)) + .angvel(0.0) + .linear_damping(LINEAR_DAMPING * damping_mult) + .angular_damping(ANGULAR_DAMPING) + .can_sleep(false) + .build(); + + let handle = self.bodies.insert(body); + + let collider = ColliderBuilder::ball(STONE_RADIUS) + .friction(STONE_FRICTION) + .friction_combine_rule(CoefficientCombineRule::Average) + .restitution(STONE_RESTITUTION) + .restitution_combine_rule(CoefficientCombineRule::Average) + .density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS)) + .build(); + + self.colliders.insert_with_parent(collider, handle, &mut self.bodies); + self.stone_handles.push((id, handle, team, curl_sign)); + + self.simulate_until_rest(id) + } + + fn simulate_until_rest(&mut self, thrown_id: u32) -> Result, String> { + let mut path: Vec<(f32, f32, f32)> = Vec::new(); + let sample_step = 1.0 / SAMPLE_RATE_HZ as f32; + let mut sample_accum: f32 = 0.0; + let mut time: f32 = 0.0; + + if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) { + let body = &self.bodies[*h]; + path.push((body.translation().x, body.translation().y, time)); + } + + loop { + self.step(); + self.apply_curl(); + time += PHYSICS_DT; + sample_accum += PHYSICS_DT; + + if sample_accum >= sample_step { + sample_accum -= sample_step; + if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) { + let body = &self.bodies[*h]; + path.push((body.translation().x, body.translation().y, time)); + } + } + + if self.all_stones_at_rest() || time > MAX_SIM_TIME { + break; + } + } + + self.prune_out_of_play(); + + Ok(path) + } + + // Rotate each stone's velocity slightly based on its selected curl direction. + // Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y). + fn apply_curl(&mut self) { + for (_, handle, _, curl_sign) in &self.stone_handles { + let body = match self.bodies.get_mut(*handle) { + Some(b) => b, + None => continue, + }; + + let v = body.linvel(); + let speed_sq = v.x * v.x + v.y * v.y; + let speed = speed_sq.sqrt(); + if speed < 1e-4 { + continue; + } + + let angle = -(*curl_sign as f32) * CURL_RATE * PHYSICS_DT; + let cos = angle.cos(); + let sin = angle.sin(); + let new_v = Vector::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos); + body.set_linvel(new_v, true); + } + } + + fn prune_out_of_play(&mut self) { + let mut keep = Vec::new(); + for (id, handle, team, curl) in self.stone_handles.drain(..) { + if let Some(body) = self.bodies.get(handle) { + let pos = body.translation(); + let beyond_back = pos.y > BACK_LINE_Y; + let short_of_hog = pos.y < HOG_LINE_Y; + let outside = pos.x.abs() > SHEET_WIDTH / 2.0; + if beyond_back || short_of_hog || outside { + self.bodies.remove(handle, &mut self.islands, &mut self.colliders, &mut self.impulse_joints, &mut self.multibody_joints, true); + } else { + keep.push((id, handle, team, curl)); + } + } + } + self.stone_handles = keep; + } + + fn step(&mut self) { + self.pipeline.step( + self.gravity, + &self.integration_parameters, + &mut self.islands, + &mut self.broad_phase, + &mut self.narrow_phase, + &mut self.bodies, + &mut self.colliders, + &mut self.impulse_joints, + &mut self.multibody_joints, + &mut self.ccd_solver, + &(), + &(), + ); + } + + fn all_stones_at_rest(&self) -> bool { + for (_, handle, _, _) in &self.stone_handles { + if let Some(body) = self.bodies.get(*handle) { + let v = body.linvel(); + let speed = (v.x * v.x + v.y * v.y).sqrt(); + if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED { + return false; + } + } + } + true + } + + pub fn current_stones(&self) -> Vec { + let mut states = Vec::new(); + for (id, handle, team, _) in &self.stone_handles { + if let Some(body) = self.bodies.get(*handle) { + let pos = body.translation(); + states.push(StoneState { + id: *id, + team: *team, + x: pos.x, + y: pos.y, + rotation: body.rotation().angle(), + active: false, + }); + } + } + states + } + + pub fn stone_states_for_scoring(&self) -> Vec<(u32, Team, f32, f32)> { + let mut out = Vec::new(); + for (id, handle, team, _) in &self.stone_handles { + if let Some(body) = self.bodies.get(*handle) { + let pos = body.translation(); + out.push((*id, *team, pos.x, pos.y)); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn final_y(world: &PhysicsWorld, id: u32) -> f32 { + world.stone_handles.iter() + .find(|(sid, _, _, _)| *sid == id) + .map(|(_, h, _, _)| { + let b = &world.bodies[*h]; + b.translation().y + }) + .unwrap_or(f32::NAN) + } + + fn final_x(world: &PhysicsWorld, id: u32) -> f32 { + world.stone_handles.iter() + .find(|(sid, _, _, _)| *sid == id) + .map(|(_, h, _, _)| { + let b = &world.bodies[*h]; + b.translation().x + }) + .unwrap_or(f32::NAN) + } + + #[test] + fn weight_7_lands_on_tee_line() { + let mut world = PhysicsWorld::new(); + world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); + let id = world.next_stone_id - 1; + let y = final_y(&world, id); + println!("weight 7 final y={}", y); + assert!( + (y - HOUSE_CENTER.1).abs() <= 0.5, + "weight-7 draw shot should finish on the tee line, got y={}", + y + ); + } + + #[test] + fn curl_direction_mirrors_x_offset() { + let mut right = PhysicsWorld::new(); + right.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); + let right_id = right.next_stone_id - 1; + let right_x = final_x(&right, right_id); + + let mut left = PhysicsWorld::new(); + left.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap(); + let left_id = left.next_stone_id - 1; + let left_x = final_x(&left, left_id); + + println!("right curl final x={} left curl final x={}", right_x, left_x); + assert!( + right_x > left_x + 0.05, + "right curl should finish to the right of left curl: right={} left={}", + right_x, + left_x + ); + } + + #[test] + fn stones_persist_after_multiple_throws() { + let mut world = PhysicsWorld::new(); + world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); + world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap(); + + let stones = world.current_stones(); + assert_eq!(stones.len(), 2, "both stones should remain in the physics world"); + assert_eq!(stones[0].id, 1); + assert_eq!(stones[1].id, 2); + } + + #[test] + fn out_of_play_stone_is_pruned() { + // A very light, high-friction throw should stop short of the hog line and be removed. + let mut world = PhysicsWorld::new(); + world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 1, 0, 2.0).unwrap(); + let stones = world.current_stones(); + assert!(stones.is_empty(), "stones short of the hog line should be pruned"); + } +} diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs new file mode 100644 index 0000000..4589e91 --- /dev/null +++ b/backend/src/protocol.rs @@ -0,0 +1,126 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +pub const TICK_RATE_HZ: u16 = 120; +pub const SAMPLE_RATE_HZ: u16 = 40; +pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32; + +pub const STONES_PER_TEAM: u8 = 8; +pub const ENDS: u8 = 10; + +// World coordinates in meters, y along sheet toward house. +pub const SHEET_WIDTH: f32 = 5.0; +pub const SHEET_LENGTH: f32 = 45.0; +pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5); +pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft +pub const BUTTON_RADIUS: f32 = 0.1524; // 0.5 ft +pub const FOUR_FT_RADIUS: f32 = 0.6096; +pub const EIGHT_FT_RADIUS: f32 = 1.2192; +pub const TWELVE_FT_RADIUS: f32 = 1.8288; +pub const HOG_LINE_Y: f32 = 21.0; +pub const BACK_LINE_Y: f32 = 42.0; +pub const HACK_Y: f32 = 2.0; + +// Stone physical properties +pub const STONE_RADIUS: f32 = 0.15; +pub const STONE_MASS: f32 = 20.0; +pub const STONE_FRICTION: f32 = 0.015; +pub const STONE_RESTITUTION: f32 = 0.05; +pub const MIN_SPEED: f32 = 3.0; +pub const MAX_SPEED: f32 = 6.45; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)] +#[serde(rename_all = "snake_case")] +pub enum Team { + #[default] + Red, + Yellow, +} + +impl Team { + pub fn other(self) -> Self { + match self { + Team::Red => Team::Yellow, + Team::Yellow => Team::Red, + } + } +} + +impl fmt::Display for Team { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Team::Red => write!(f, "red"), + Team::Yellow => write!(f, "yellow"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMessage { + Throw { + broom_x: f32, + broom_y: f32, + weight: u8, + #[serde(default = "default_curl")] + curl: i8, + #[serde(default = "default_friction")] + friction: f32, + }, +} + +fn default_curl() -> i8 { 1 } +fn default_friction() -> f32 { 1.0 } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + Joined { room: String, team: Team }, + Waiting { message: String }, + GameState { + end: u8, + scores: [i32; 2], // red, yellow + hammer: Team, + turn_team: Team, + stones: Vec, + phase: Phase, + }, + Trajectory { + path: Vec<(f32, f32, f32)>, + }, + EndScored { end: u8, points: i32, scoring_team: Option }, + GameOver { + scores: [i32; 2], + winner: Option, + }, + Error { message: String }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + #[default] + Waiting, + Playing, + Simulating, + Scoring, + EndComplete, + GameComplete, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoneState { + pub id: u32, + pub team: Team, + pub x: f32, + pub y: f32, + pub rotation: f32, + pub active: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Player { + pub id: String, + pub team: Team, + pub connected: bool, +}