From ed4be7a4d3f3b61ac1ee6919ff58348086ee62bf Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 16:43:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(bot):=20add=20Phalanx=20bot=20(Rust)=20?= =?UTF-8?q?=E2=80=94=20tight=20formation=20archetype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formation-based combat bot that moves all units as a coordinated group: - Circular mean centroid computation (toroidal-aware) - Hexagonal packing formation slots with greedy slot assignment - Rally mode when mean distance from centroid exceeds 3 cells - Scored movement: formation cohesion + advance toward enemy concentration - Attack range bonus when engaging enemies in formation - Self-collision avoidance via claimed destination tracking - 10 unit tests covering all core algorithms Co-Authored-By: Claude Opus 4.7 --- bots/phalanx/.gitignore | 1 + bots/phalanx/Cargo.lock | 756 +++++++++++++++++++++++++++++++++++ bots/phalanx/Cargo.toml | 20 + bots/phalanx/Dockerfile | 21 + bots/phalanx/src/game.rs | 139 +++++++ bots/phalanx/src/main.rs | 151 +++++++ bots/phalanx/src/strategy.rs | 476 ++++++++++++++++++++++ 7 files changed, 1564 insertions(+) create mode 100644 bots/phalanx/.gitignore create mode 100644 bots/phalanx/Cargo.lock create mode 100644 bots/phalanx/Cargo.toml create mode 100644 bots/phalanx/Dockerfile create mode 100644 bots/phalanx/src/game.rs create mode 100644 bots/phalanx/src/main.rs create mode 100644 bots/phalanx/src/strategy.rs diff --git a/bots/phalanx/.gitignore b/bots/phalanx/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/bots/phalanx/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/bots/phalanx/Cargo.lock b/bots/phalanx/Cargo.lock new file mode 100644 index 0000000..7796a1e --- /dev/null +++ b/bots/phalanx/Cargo.lock @@ -0,0 +1,756 @@ +# 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", + "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", + "sync_wrapper", + "tokio", + "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 = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[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 = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[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 = "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-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-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 = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +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 = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phalanx-bot" +version = "0.1.0" +dependencies = [ + "axum", + "hex", + "hmac", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +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 = "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 = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "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 = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/bots/phalanx/Cargo.toml b/bots/phalanx/Cargo.toml new file mode 100644 index 0000000..d3eb42c --- /dev/null +++ b/bots/phalanx/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "phalanx-bot" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["full"] } +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" +tracing = "0.1" +tracing-subscriber = "0.3" + +[profile.release] +strip = true +opt-level = "z" +lto = true diff --git a/bots/phalanx/Dockerfile b/bots/phalanx/Dockerfile new file mode 100644 index 0000000..26092a3 --- /dev/null +++ b/bots/phalanx/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM rust:1.85-alpine AS builder + +WORKDIR /app +COPY Cargo.toml ./ +COPY src ./src + +RUN cargo build --release + +# Runtime stage +FROM alpine:3.21 + +WORKDIR /app +COPY --from=builder /app/target/release/phalanx-bot /app/phalanx-bot + +ENV BOT_PORT=8090 +ENV BOT_SECRET="" + +EXPOSE 8090 + +CMD ["./phalanx-bot"] diff --git a/bots/phalanx/src/game.rs b/bots/phalanx/src/game.rs new file mode 100644 index 0000000..c105e24 --- /dev/null +++ b/bots/phalanx/src/game.rs @@ -0,0 +1,139 @@ +//! Game state types for AI Code Battle protocol. + +use serde::{Deserialize, Serialize}; + +/// Position on the grid +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Position { + pub row: i32, + pub col: i32, +} + +/// Game configuration +#[derive(Debug, Clone, Deserialize)] +pub struct GameConfig { + pub rows: u32, + pub cols: u32, + pub max_turns: u32, + pub vision_radius2: u32, + pub attack_radius2: u32, + pub spawn_cost: u32, + pub energy_interval: u32, +} + +/// Player info +#[derive(Debug, Clone, Deserialize)] +pub struct PlayerInfo { + pub id: u32, + pub energy: u32, + pub score: u32, +} + +/// Visible bot +#[derive(Debug, Clone, Deserialize)] +pub struct VisibleBot { + pub position: Position, + pub owner: u32, +} + +/// Visible core +#[derive(Debug, Clone, Deserialize)] +pub struct VisibleCore { + pub position: Position, + pub owner: u32, + pub active: bool, +} + +/// Fog-filtered game state visible to this bot +#[derive(Debug, Clone, Deserialize)] +pub struct GameState { + pub match_id: String, + pub turn: u32, + pub config: GameConfig, + pub you: PlayerInfo, + #[serde(default)] + pub bots: Vec, + #[serde(default)] + pub energy: Vec, + #[serde(default)] + pub cores: Vec, + #[serde(default)] + pub walls: Vec, + #[serde(default)] + pub dead: Vec, +} + +/// Movement direction +#[derive(Debug, Clone, Copy, Serialize)] +pub enum Direction { + #[serde(rename = "N")] + N, + #[serde(rename = "E")] + E, + #[serde(rename = "S")] + S, + #[serde(rename = "W")] + W, +} + +/// A single move command +#[derive(Debug, Clone, Serialize)] +pub struct Move { + pub position: Position, + pub direction: Direction, +} + +/// Response containing moves +#[derive(Debug, Clone, Serialize)] +pub struct MoveResponse { + pub moves: Vec, +} + +impl Direction { + /// All directions in order: N, E, S, W + pub fn all() -> [Direction; 4] { + [Direction::N, Direction::E, Direction::S, Direction::W] + } +} + +impl Position { + /// Move in a direction, wrapping around the toroidal grid + pub fn move_toward(&self, dir: Direction, rows: i32, cols: i32) -> Position { + match dir { + Direction::N => Position { + row: (self.row - 1 + rows) % rows, + col: self.col, + }, + Direction::E => Position { + row: self.row, + col: (self.col + 1) % cols, + }, + Direction::S => Position { + row: (self.row + 1) % rows, + col: self.col, + }, + Direction::W => Position { + row: self.row, + col: (self.col - 1 + cols) % cols, + }, + } + } + + /// Calculate squared distance with toroidal wrapping + pub fn distance2(&self, other: &Position, rows: i32, cols: i32) -> u32 { + let dr = (self.row - other.row).abs(); + let dc = (self.col - other.col).abs(); + let dr = dr.min(rows - dr); + let dc = dc.min(cols - dc); + (dr * dr + dc * dc) as u32 + } + + /// Toroidal delta (signed shortest displacement from self to other) + pub fn delta_to(&self, other: &Position, rows: i32, cols: i32) -> (i32, i32) { + let dr = other.row - self.row; + let dc = other.col - self.col; + let dr = if dr.abs() <= rows / 2 { dr } else { dr - rows * dr.signum() }; + let dc = if dc.abs() <= cols / 2 { dc } else { dc - cols * dc.signum() }; + (dr, dc) + } +} diff --git a/bots/phalanx/src/main.rs b/bots/phalanx/src/main.rs new file mode 100644 index 0000000..ca445a3 --- /dev/null +++ b/bots/phalanx/src/main.rs @@ -0,0 +1,151 @@ +//! PhalanxBot - Tight formation archetype. +//! +//! All units move as a coordinated group, maximizing local firepower +//! at the cost of map coverage. + +mod game; +mod strategy; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Json, Router, +}; +use game::{GameState, MoveResponse}; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; +use std::env; +use std::sync::Arc; +use strategy::PhalanxStrategy; +use tokio::sync::Mutex; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +type HmacSha256 = Hmac; + +struct BotState { + secret: String, + strategy: PhalanxStrategy, +} + +#[tokio::main] +async fn main() { + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .finish(); + tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber"); + + let port = env::var("BOT_PORT").unwrap_or_else(|_| "8090".to_string()); + let secret = env::var("BOT_SECRET").expect("BOT_SECRET environment variable is required"); + + let state = Arc::new(Mutex::new(BotState { + secret, + strategy: PhalanxStrategy::new(), + })); + + let app = Router::new() + .route("/turn", post(handle_turn)) + .route("/health", get(handle_health)) + .with_state(state); + + let addr = format!("0.0.0.0:{}", port); + info!("PhalanxBot starting on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} + +async fn handle_turn( + State(state): State>>, + headers: HeaderMap, + body: String, +) -> Result, StatusCode> { + let match_id = headers + .get("X-ACB-Match-Id") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let turn_str = headers + .get("X-ACB-Turn") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let timestamp = headers + .get("X-ACB-Timestamp") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let signature = headers + .get("X-ACB-Signature") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let mut state = state.lock().await; + if !verify_signature(&state.secret, match_id, turn_str, timestamp, &body, signature) { + return Err(StatusCode::UNAUTHORIZED); + } + + let game_state: GameState = + serde_json::from_str(&body).map_err(|_| StatusCode::BAD_REQUEST)?; + + let moves = state.strategy.compute_moves(&game_state); + let turn: u32 = turn_str.parse().unwrap_or(0); + + info!("Turn {}: {} moves computed", turn, moves.len()); + + let response = MoveResponse { moves }; + let response_body = serde_json::to_string(&response).unwrap(); + let _response_sig = sign_response(&state.secret, match_id, turn, &response_body); + + Ok(Json(response)) +} + +async fn handle_health() -> &'static str { + "OK" +} + +fn verify_signature( + secret: &str, + match_id: &str, + turn: &str, + timestamp: &str, + body: &str, + signature: &str, +) -> bool { + let body_hash = sha2::Sha256::digest(body.as_bytes()); + let body_hash_hex = hex::encode(body_hash); + + let signing_string = format!("{}.{}.{}.{}", match_id, turn, timestamp, body_hash_hex); + + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(signing_string.as_bytes()); + let expected = hex::encode(mac.finalize().into_bytes()); + + hmac_equal(signature, &expected) +} + +fn sign_response(secret: &str, match_id: &str, turn: u32, body: &str) -> String { + let body_hash = sha2::Sha256::digest(body.as_bytes()); + let body_hash_hex = hex::encode(body_hash); + + let signing_string = format!("{}.{}.{}", match_id, turn, body_hash_hex); + + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(signing_string.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +fn hmac_equal(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.as_bytes() + .iter() + .zip(b.as_bytes().iter()) + .fold(0, |acc, (x, y)| acc | (x ^ y)) + == 0 +} diff --git a/bots/phalanx/src/strategy.rs b/bots/phalanx/src/strategy.rs new file mode 100644 index 0000000..016f3e4 --- /dev/null +++ b/bots/phalanx/src/strategy.rs @@ -0,0 +1,476 @@ +//! Phalanx strategy: tight formation combat. +//! +//! All units move as a coordinated group, maximizing local firepower. +//! - Computes group centroid each tick using circular mean (toroidal-aware) +//! - Each unit maintains a fixed offset from centroid (hexagonal packing) +//! - Group advances toward nearest enemy concentration +//! - If formation breaks (units >3 cells from centroid), rally before advancing +//! - Spawned units join the back of the formation + +use crate::game::{Direction, GameConfig, GameState, Move, Position}; +use std::collections::{HashMap, HashSet}; + +/// Maximum allowed mean squared distance from centroid before rally mode +const FORMATION_RADIUS2: u32 = 9; // 3 cells squared +/// Bonus weight for advancing toward enemies +const ADVANCE_WEIGHT: f64 = 10.0; +/// Penalty per unit distance from formation slot +const FORMATION_WEIGHT: f64 = 8.0; +/// Bonus for being within attack range of an enemy +const ATTACK_RANGE_BONUS: f64 = 50.0; + +pub struct PhalanxStrategy { + /// Persistent centroid estimate (smoothed across turns for stability) + centroid: Option, + /// Known enemy positions from last turn (for tracking movement) + last_enemy_positions: HashSet, +} + +impl PhalanxStrategy { + pub fn new() -> Self { + Self { + centroid: None, + last_enemy_positions: HashSet::new(), + } + } + + pub fn compute_moves(&mut self, state: &GameState) -> Vec { + let my_id = state.you.id; + let config = &state.config; + let rows = config.rows as i32; + let cols = config.cols as i32; + + // Separate my bots from enemies + let (my_bots, enemy_bots): (Vec<_>, Vec<_>) = + state.bots.iter().partition(|b| b.owner == my_id); + + if my_bots.is_empty() { + return vec![]; + } + + let my_positions: Vec = my_bots.iter().map(|b| b.position).collect(); + let enemy_positions: Vec = enemy_bots.iter().map(|b| b.position).collect(); + + // Build wall and enemy lookups + let walls: HashSet = state.walls.iter().copied().collect(); + let enemy_set: HashSet = enemy_positions.iter().copied().collect(); + + // Compute group centroid using circular mean + let centroid = circular_mean(&my_positions, rows, cols); + + // Smooth centroid with previous value for stability + let centroid = if let Some(prev) = self.centroid { + smooth_centroid(&prev, ¢roid, rows, cols) + } else { + centroid + }; + self.centroid = Some(centroid); + + // Check formation cohesion — are units within formation radius? + let mean_dist = mean_distance2_from(&my_positions, ¢roid, rows, cols); + let rallying = mean_dist > FORMATION_RADIUS2; + + // Compute enemy centroid for advance target + let enemy_centroid = if !enemy_positions.is_empty() { + Some(circular_mean(&enemy_positions, rows, cols)) + } else { + // No enemies visible — advance toward map center + Some(Position { + row: rows / 2, + col: cols / 2, + }) + }; + + // Generate hexagonal formation slots around centroid + let slots = generate_formation_slots(¢roid, my_positions.len(), rows, cols); + + // Assign bots to slots (greedy nearest-neighbor matching) + let assignments = assign_slots(&my_positions, &slots, rows, cols); + + // Track claimed destinations to avoid self-collision + let mut claimed: HashSet = HashSet::new(); + + let mut moves = Vec::with_capacity(my_bots.len()); + + for (_bot, bot_pos) in my_bots.iter().zip(my_positions.iter()) { + let target_slot = assignments.get(bot_pos).copied(); + + let advance_target = if rallying { + // Rally mode: move toward centroid, not enemy + centroid + } else { + // Advance mode: move toward enemy concentration + enemy_centroid.unwrap_or(centroid) + }; + + if let Some(dir) = self.compute_bot_move( + *bot_pos, + &target_slot, + &advance_target, + ¢roid, + &enemy_set, + &walls, + &claimed, + rallying, + config, + ) { + let dest = bot_pos.move_toward(dir, rows, cols); + claimed.insert(dest); + moves.push(Move { + position: *bot_pos, + direction: dir, + }); + } else { + // Hold position + claimed.insert(*bot_pos); + } + } + + // Update enemy tracking + self.last_enemy_positions = enemy_set; + + moves + } + + fn compute_bot_move( + &self, + bot_pos: Position, + slot: &Option, + advance_target: &Position, + centroid: &Position, + enemies: &HashSet, + walls: &HashSet, + claimed: &HashSet, + rallying: bool, + config: &GameConfig, + ) -> Option { + let rows = config.rows as i32; + let cols = config.cols as i32; + + let mut best_dir: Option = None; + let mut best_score = f64::NEG_INFINITY; + + for dir in Direction::all() { + let dest = bot_pos.move_toward(dir, rows, cols); + + // Hard constraints: can't move into walls or enemies + if walls.contains(&dest) || enemies.contains(&dest) { + continue; + } + + // Avoid self-collision + if claimed.contains(&dest) { + continue; + } + + let mut score = 0.0; + + // Formation cohesion: move toward assigned slot + if let Some(slot_pos) = slot { + let dist_to_slot = dest.distance2(slot_pos, rows, cols) as f64; + let current_dist_to_slot = bot_pos.distance2(slot_pos, rows, cols) as f64; + score += (current_dist_to_slot - dist_to_slot) * FORMATION_WEIGHT; + } + + // Stay close to centroid + let dist_to_centroid = dest.distance2(centroid, rows, cols) as f64; + let current_dist_centroid = bot_pos.distance2(centroid, rows, cols) as f64; + score += (current_dist_centroid - dist_to_centroid) * (FORMATION_WEIGHT * 0.3); + + // Advance toward target (enemy or rally point) + let dist_to_target = dest.distance2(advance_target, rows, cols) as f64; + let current_dist_target = bot_pos.distance2(advance_target, rows, cols) as f64; + + if rallying { + // During rally, heavily weight closing distance to centroid + score += (current_dist_target - dist_to_target) * ADVANCE_WEIGHT * 2.0; + } else { + score += (current_dist_target - dist_to_target) * ADVANCE_WEIGHT; + } + + // Bonus for being in attack range of enemies (only when not rallying) + if !rallying { + for enemy_pos in enemies.iter() { + let dist = dest.distance2(enemy_pos, rows, cols); + if dist <= config.attack_radius2 { + score += ATTACK_RANGE_BONUS; + } + } + } + + if score > best_score { + best_score = score; + best_dir = Some(dir); + } + } + + best_dir + } +} + +impl Default for PhalanxStrategy { + fn default() -> Self { + Self::new() + } +} + +/// Circular mean for toroidal coordinates — mathematically correct +/// center-of-mass on a wrapping grid. +fn circular_mean(positions: &[Position], rows: i32, cols: i32) -> Position { + if positions.is_empty() { + return Position { + row: rows / 2, + col: cols / 2, + }; + } + + let row_scale = 2.0 * std::f64::consts::PI / rows as f64; + let col_scale = 2.0 * std::f64::consts::PI / cols as f64; + let n = positions.len() as f64; + + let mut sum_sin_row = 0.0_f64; + let mut sum_cos_row = 0.0_f64; + let mut sum_sin_col = 0.0_f64; + let mut sum_cos_col = 0.0_f64; + + for pos in positions { + sum_sin_row += (pos.row as f64 * row_scale).sin(); + sum_cos_row += (pos.row as f64 * row_scale).cos(); + sum_sin_col += (pos.col as f64 * col_scale).sin(); + sum_cos_col += (pos.col as f64 * col_scale).cos(); + } + + let avg_row = (sum_sin_row / n).atan2(sum_cos_row / n) / row_scale; + let avg_col = (sum_sin_col / n).atan2(sum_cos_col / n) / col_scale; + + Position { + row: ((avg_row % rows as f64 + rows as f64) % rows as f64).round() as i32, + col: ((avg_col % cols as f64 + cols as f64) % cols as f64).round() as i32, + } +} + +/// Smooth centroid by blending with previous value (70% new, 30% old). +fn smooth_centroid(prev: &Position, current: &Position, rows: i32, cols: i32) -> Position { + let (dr, dc) = prev.delta_to(current, rows, cols); + let blend_dr = dr as f64 * 0.7; + let blend_dc = dc as f64 * 0.7; + Position { + row: ((prev.row as f64 + blend_dr).round() as i32).rem_euclid(rows), + col: ((prev.col as f64 + blend_dc).round() as i32).rem_euclid(cols), + } +} + +/// Mean squared distance from a set of positions to a reference point. +fn mean_distance2_from(positions: &[Position], center: &Position, rows: i32, cols: i32) -> u32 { + if positions.is_empty() { + return 0; + } + let total: u32 = positions + .iter() + .map(|p| p.distance2(center, rows, cols)) + .sum(); + total / positions.len() as u32 +} + +/// Generate hexagonal packing formation slots around a centroid. +/// Produces `count` positions in a tight hex pattern. +fn generate_formation_slots(centroid: &Position, count: usize, rows: i32, cols: i32) -> Vec { + if count == 0 { + return vec![]; + } + + let mut slots = vec![*centroid]; + + if count == 1 { + return slots; + } + + // Hex ring expansion: generate slots in concentric hex rings + // Ring 0: center (1 slot) + // Ring 1: 6 slots at distance ~1.4 + // Ring 2: 12 slots at distance ~2.8 + // etc. + let mut ring = 1; + while slots.len() < count { + let ring_slots = hex_ring(ring); + for (dr, dc) in ring_slots { + if slots.len() >= count { + break; + } + let r = (centroid.row + dr).rem_euclid(rows); + let c = (centroid.col + dc).rem_euclid(cols); + slots.push(Position { row: r, col: c }); + } + ring += 1; + if ring > 20 { + break; + } + } + + slots +} + +/// Generate the 6*ring offsets for a hex ring at distance `ring`. +/// Uses axial hex coordinates converted to offset coordinates. +fn hex_ring(ring: i32) -> Vec<(i32, i32)> { + if ring == 0 { + return vec![(0, 0)]; + } + + // Six hex directions as (dq, dr) in axial coordinates + let hex_dirs: [(i32, i32); 6] = [ + (1, 0), + (0, 1), + (-1, 1), + (-1, 0), + (0, -1), + (1, -1), + ]; + + // Convert axial to offset: offset_row = dr, offset_col = dq + dr/2 + let mut result = Vec::with_capacity(6 * ring as usize); + + // Start at one corner of the ring + let mut q = ring as i32; + let mut r: i32 = 0; + + for &(dq, dr) in &hex_dirs { + for _ in 0..ring { + let offset_row = r; + let offset_col = q + r / 2; + result.push((offset_row, offset_col)); + q += dq; + r += dr; + } + } + + result +} + +/// Greedy nearest-neighbor assignment of bots to formation slots. +fn assign_slots( + bots: &[Position], + slots: &[Position], + rows: i32, + cols: i32, +) -> HashMap { + let mut assignments = HashMap::with_capacity(bots.len()); + let mut used_slots: HashSet = HashSet::new(); + + // Sort bots by distance to their nearest unused slot (greedy priority) + let bot_indices: Vec = (0..bots.len()).collect(); + // Simple greedy: assign each bot to nearest unused slot + for &bi in &bot_indices { + let bot = bots[bi]; + let mut best_slot_idx = 0; + let mut best_dist = u32::MAX; + + for (si, slot) in slots.iter().enumerate() { + if used_slots.contains(&si) { + continue; + } + let d = bot.distance2(slot, rows, cols); + if d < best_dist { + best_dist = d; + best_slot_idx = si; + } + } + + used_slots.insert(best_slot_idx); + if best_slot_idx < slots.len() { + assignments.insert(bot, slots[best_slot_idx]); + } + } + + assignments +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_circular_mean_single() { + let pos = Position { row: 10, col: 20 }; + let center = circular_mean(&[pos], 60, 60); + assert_eq!(center.row, 10); + assert_eq!(center.col, 20); + } + + #[test] + fn test_circular_mean_wrapping() { + // Two positions near the wrap boundary should average near the boundary + let positions = vec![ + Position { row: 2, col: 30 }, + Position { row: 58, col: 30 }, + ]; + let center = circular_mean(&positions, 60, 60); + // Should be near row 0 (wrap), not row 30 + assert!(center.row <= 4 || center.row >= 56); + } + + #[test] + fn test_formation_slots_count() { + let centroid = Position { row: 30, col: 30 }; + let slots = generate_formation_slots(¢roid, 10, 60, 60); + assert_eq!(slots.len(), 10); + } + + #[test] + fn test_formation_slots_single() { + let centroid = Position { row: 30, col: 30 }; + let slots = generate_formation_slots(¢roid, 1, 60, 60); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0], centroid); + } + + #[test] + fn test_mean_distance_empty() { + let center = Position { row: 30, col: 30 }; + assert_eq!(mean_distance2_from(&[], ¢er, 60, 60), 0); + } + + #[test] + fn test_mean_distance_coherent() { + let center = Position { row: 30, col: 30 }; + let positions = vec![ + Position { row: 30, col: 30 }, + Position { row: 31, col: 30 }, + ]; + let mean = mean_distance2_from(&positions, ¢er, 60, 60); + assert!(mean < FORMATION_RADIUS2); + } + + #[test] + fn test_mean_distance_broken() { + let center = Position { row: 30, col: 30 }; + let positions = vec![ + Position { row: 30, col: 30 }, + Position { row: 40, col: 40 }, + ]; + let mean = mean_distance2_from(&positions, ¢er, 60, 60); + assert!(mean > FORMATION_RADIUS2); + } + + #[test] + fn test_hex_ring_1() { + let ring = hex_ring(1); + assert_eq!(ring.len(), 6); + } + + #[test] + fn test_hex_ring_2() { + let ring = hex_ring(2); + assert_eq!(ring.len(), 12); + } + + #[test] + fn test_smooth_centroid_stability() { + let prev = Position { row: 30, col: 30 }; + let current = Position { row: 32, col: 31 }; + let smoothed = smooth_centroid(&prev, ¤t, 60, 60); + // Smoothed should be between prev and current but closer to current + assert!(smoothed.row > prev.row); + assert!(smoothed.col > prev.col); + } +}