From dae34cce41107c3eebcf906fee10d222d11e8fbc Mon Sep 17 00:00:00 2001 From: markmental Date: Tue, 10 Feb 2026 16:44:07 -0500 Subject: [PATCH] Initial Rust CLI/TUI Rebuild (Seeded Sim + CSV Export) --- .gitignore | 1 + Cargo.lock | 709 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 14 + DEVLOG.md | 39 +++ src/app.rs | 251 ++++++++++++++++ src/data.rs | 255 ++++++++++++++++ src/export.rs | 153 ++++++++++ src/instance.rs | 160 ++++++++++ src/main.rs | 135 +++++++++ src/sim.rs | 688 ++++++++++++++++++++++++++++++++++++++++++ src/ui/dashboard.rs | 83 ++++++ src/ui/detail.rs | 109 +++++++ src/ui/mod.rs | 41 +++ src/ui/modal.rs | 2 + src/ui/widgets.rs | 9 + src/utils.rs | 90 ++++++ 16 files changed, 2739 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DEVLOG.md create mode 100644 src/app.rs create mode 100644 src/data.rs create mode 100644 src/export.rs create mode 100644 src/instance.rs create mode 100644 src/main.rs create mode 100644 src/sim.rs create mode 100644 src/ui/dashboard.rs create mode 100644 src/ui/detail.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/modal.rs create mode 100644 src/ui/widgets.rs create mode 100644 src/utils.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cd2cdd6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,709 @@ +# 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 = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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 = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "soccercloud" +version = "0.1.0" +dependencies = [ + "clap", + "crossterm", + "ratatui", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0b14ec8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "soccercloud" +version = "0.1.0" +edition = "2021" + +[dependencies] +ratatui = "0.29" +crossterm = "0.28" +clap = { version = "4.5", features = ["derive"] } + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..155f2ed --- /dev/null +++ b/DEVLOG.md @@ -0,0 +1,39 @@ +# DEVLOG + +## 2026-02-10 - Rust CLI/TUI rebuild (strict dependencies) + +### Scope completed +- Initialized a Rust project with strict dependency policy: `ratatui`, `crossterm`, `clap` only. +- Added global `--seed` support for reproducible runs across TUI and headless commands. +- Ported team/tactic/profile data from JavaScript into `src/data.rs`. +- Implemented a std-only deterministic RNG in `src/utils.rs`. +- Ported core simulation logic to Rust in `src/sim.rs`: + - Single match minute loop with xG, tactics, fouls/cards, corners, offsides, saves. + - 4-team league mode with fixtures and standings tie-breakers. + - 4-team knockout mode with penalties and edge-case loop guard. +- Implemented lifecycle state machine and bounded logs in `src/instance.rs`. +- Built a ratatui dashboard/detail UI with keyboard controls in `src/app.rs` + `src/ui/*`. +- Implemented CSV export in `src/export.rs` with manual escaping and CSV injection sanitization. +- Added CLI commands: + - `quick` (single match) + - `list` (team listing) + - `export` (CSV generation) + +### Key controls in TUI +- `n`: create Single Match instance +- `l`: create 4-Team League instance +- `o`: create 4-Team Knockout instance +- `s`: start selected instance +- `c`: clone selected instance +- `d`: delete selected instance +- `e`: export selected instance to CSV +- `v` or `Enter`: toggle dashboard/detail +- `1`, `2`, `4`, `0`: speed control (1x/2x/4x/instant) +- `j`/`k` or arrow keys: select instance +- `q`: quit + +### Notes +- No async runtime is used. +- No external data files are required at runtime. +- Team data is fully embedded in the binary. +- CSV export format is mode-specific and generated with std I/O. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..6d5e64e --- /dev/null +++ b/src/app.rs @@ -0,0 +1,251 @@ +use std::fs::File; +use std::io::{self, Write}; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use crate::data::TEAMS; +use crate::instance::{SimStatus, SimulationInstance}; +use crate::sim::SimulationType; +use crate::ui; +use crate::utils::{derive_seed, Rng}; + +const MAX_INSTANCES: usize = 100; + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum Speed { + X1, + X2, + X4, + Instant, +} + +impl Speed { + pub fn frames_per_tick(self) -> usize { + match self { + Speed::X1 => 1, + Speed::X2 => 2, + Speed::X4 => 4, + Speed::Instant => 200, + } + } + + pub fn label(self) -> &'static str { + match self { + Speed::X1 => "1x", + Speed::X2 => "2x", + Speed::X4 => "4x", + Speed::Instant => "Instant", + } + } +} + +pub struct App { + pub base_seed: u64, + pub speed: Speed, + pub instances: Vec, + pub selected: usize, + pub show_detail: bool, + pub status_line: String, + next_id: usize, +} + +impl App { + pub fn new(base_seed: u64, speed: Speed) -> Self { + Self { + base_seed, + speed, + instances: Vec::with_capacity(MAX_INSTANCES), + selected: 0, + show_detail: false, + status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()), + next_id: 0, + } + } + + pub fn create_instance(&mut self, sim_type: SimulationType) { + if self.instances.len() >= MAX_INSTANCES { + self.status_line = format!("Instance limit reached ({MAX_INSTANCES})"); + return; + } + let id = self.next_id; + let seed = derive_seed(self.base_seed, id as u64 + 1); + let teams = self.random_teams_for_mode(sim_type, seed); + let instance = SimulationInstance::new(id, sim_type, teams, seed); + self.instances.push(instance); + self.selected = self.instances.len().saturating_sub(1); + self.next_id += 1; + self.status_line = format!("Created sim-{id}"); + } + + pub fn start_selected(&mut self) { + if let Some(inst) = self.instances.get_mut(self.selected) { + inst.start(); + self.status_line = format!("Started sim-{}", inst.id); + } + } + + pub fn clone_selected(&mut self) { + if self.instances.len() >= MAX_INSTANCES { + self.status_line = format!("Instance limit reached ({MAX_INSTANCES})"); + return; + } + let Some(existing) = self.instances.get(self.selected).cloned() else { + return; + }; + + let new_id = self.next_id; + let new_seed = derive_seed(self.base_seed, new_id as u64 + 1); + let cloned = existing.clone_as(new_id, new_seed); + self.instances.push(cloned); + self.selected = self.instances.len().saturating_sub(1); + self.next_id += 1; + self.status_line = format!("Cloned sim-{} -> sim-{}", existing.id, new_id); + } + + pub fn delete_selected(&mut self) { + if self.instances.is_empty() { + return; + } + let removed = self.instances.remove(self.selected); + if self.selected >= self.instances.len() { + self.selected = self.instances.len().saturating_sub(1); + } + self.status_line = format!("Deleted sim-{}", removed.id); + } + + pub fn export_selected(&mut self) { + let Some(inst) = self.instances.get(self.selected) else { + return; + }; + + match inst.export_csv() { + Ok(bytes) => { + let file_name = format!("sim-{}-{}.csv", inst.id, inst.sim_type.as_str()); + match File::create(&file_name).and_then(|mut f| f.write_all(&bytes)) { + Ok(_) => { + self.status_line = format!("Exported {}", file_name); + } + Err(e) => { + self.status_line = format!("Export failed: {e}"); + } + } + } + Err(e) => self.status_line = e, + } + } + + pub fn cycle_speed(&mut self, speed: Speed) { + self.speed = speed; + self.status_line = format!("Speed set to {}", self.speed.label()); + } + + fn random_teams_for_mode(&self, sim_type: SimulationType, seed: u64) -> Vec { + let count = match sim_type { + SimulationType::Single => 2, + SimulationType::League4 | SimulationType::Knockout4 => 4, + }; + unique_random_teams(count, seed) + } + + pub fn tick(&mut self) { + let frames = self.speed.frames_per_tick(); + for inst in &mut self.instances { + if matches!(inst.status, SimStatus::Running { .. }) { + inst.tick(frames); + } + } + } + + pub fn selected_instance(&self) -> Option<&SimulationInstance> { + self.instances.get(self.selected) + } + + pub fn select_prev(&mut self) { + if self.instances.is_empty() { + return; + } + if self.selected == 0 { + self.selected = self.instances.len() - 1; + } else { + self.selected -= 1; + } + } + + pub fn select_next(&mut self) { + if self.instances.is_empty() { + return; + } + self.selected = (self.selected + 1) % self.instances.len(); + } +} + +pub fn run_tui(mut app: App) -> io::Result<()> { + let mut stdout = io::stdout(); + enable_raw_mode()?; + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let mut running = true; + let tick_rate = Duration::from_millis(16); + let mut last_tick = Instant::now(); + + while running { + terminal.draw(|f| ui::draw(f, &app))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') => running = false, + KeyCode::Up | KeyCode::Char('k') => app.select_prev(), + KeyCode::Down | KeyCode::Char('j') => app.select_next(), + KeyCode::Char('n') => app.create_instance(SimulationType::Single), + KeyCode::Char('l') => app.create_instance(SimulationType::League4), + KeyCode::Char('o') => app.create_instance(SimulationType::Knockout4), + KeyCode::Char('s') => app.start_selected(), + KeyCode::Char('c') => app.clone_selected(), + KeyCode::Char('d') => app.delete_selected(), + KeyCode::Enter | KeyCode::Char('v') => app.show_detail = !app.show_detail, + KeyCode::Char('e') => app.export_selected(), + KeyCode::Char('1') => app.cycle_speed(Speed::X1), + KeyCode::Char('2') => app.cycle_speed(Speed::X2), + KeyCode::Char('4') => app.cycle_speed(Speed::X4), + KeyCode::Char('0') => app.cycle_speed(Speed::Instant), + _ => {} + } + } + } + } + + if last_tick.elapsed() >= tick_rate { + app.tick(); + last_tick = Instant::now(); + } + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +fn unique_random_teams(count: usize, seed: u64) -> Vec { + let mut rng = Rng::new(seed); + let mut pool: Vec<&str> = TEAMS.to_vec(); + let mut picked = Vec::with_capacity(count); + while picked.len() < count && !pool.is_empty() { + let i = rng.range_usize(pool.len()); + picked.push(pool.remove(i).to_string()); + } + picked +} diff --git a/src/data.rs b/src/data.rs new file mode 100644 index 0000000..1dcd7b8 --- /dev/null +++ b/src/data.rs @@ -0,0 +1,255 @@ +#[derive(Debug, Clone, Copy)] +pub struct Tactic { + pub key: &'static str, + pub label: &'static str, + pub attack_bias: f64, + pub goal_mult: f64, + pub fast_break: f64, + pub foul_mult: f64, + pub block_mult: f64, + pub press_mult: f64, +} + +#[derive(Debug, Clone, Copy)] +pub struct TeamProfile { + pub formation: &'static str, + pub tactic: &'static str, +} + +pub const TACTICS: [Tactic; 4] = [ + Tactic { + key: "counter", + label: "Counter", + attack_bias: 1.10, + goal_mult: 1.08, + fast_break: 0.25, + foul_mult: 1.00, + block_mult: 1.00, + press_mult: 0.95, + }, + Tactic { + key: "possession", + label: "Possession", + attack_bias: 1.00, + goal_mult: 0.95, + fast_break: 0.10, + foul_mult: 0.90, + block_mult: 1.00, + press_mult: 0.90, + }, + Tactic { + key: "high_press", + label: "High Press", + attack_bias: 1.15, + goal_mult: 1.00, + fast_break: 0.20, + foul_mult: 1.20, + block_mult: 0.95, + press_mult: 1.20, + }, + Tactic { + key: "low_block", + label: "Low Block", + attack_bias: 0.92, + goal_mult: 0.92, + fast_break: 0.12, + foul_mult: 0.95, + block_mult: 1.15, + press_mult: 0.85, + }, +]; + +pub const TEAMS: [&str; 29] = [ + "Kashima Antlers", + "Urawa Red Diamonds", + "Gamba Osaka", + "Cerezo Osaka", + "Kawasaki Frontale", + "Yokohama F. Marinos", + "Nagoya Grampus", + "Shimizu S-Pulse", + "Sanfrecce Hiroshima", + "Consadole Sapporo", + "Ventforet Kofu", + "Tokyo Verdy", + "JEF United Chiba", + "Arsenal", + "FC Barcelona", + "Real Madrid", + "Manchester City", + "Manchester United", + "Liverpool", + "Bayern Munich", + "Borussia Dortmund", + "Paris Saint-Germain", + "Juventus", + "Inter", + "AC Milan", + "Ajax", + "Benfica", + "Porto", + "Celtic", +]; + +pub fn team_flag(team: &str) -> &'static str { + match team { + "Kashima Antlers" + | "Urawa Red Diamonds" + | "Gamba Osaka" + | "Cerezo Osaka" + | "Kawasaki Frontale" + | "Yokohama F. Marinos" + | "Nagoya Grampus" + | "Shimizu S-Pulse" + | "Sanfrecce Hiroshima" + | "Consadole Sapporo" + | "Ventforet Kofu" + | "Tokyo Verdy" + | "JEF United Chiba" => "🇯🇵", + "Arsenal" | "Manchester City" | "Manchester United" | "Liverpool" | "Celtic" => "🇬🇧", + "FC Barcelona" | "Real Madrid" => "🇪🇸", + "Bayern Munich" | "Borussia Dortmund" => "🇩🇪", + "Paris Saint-Germain" => "🇫🇷", + "Juventus" | "Inter" | "AC Milan" => "🇮🇹", + "Ajax" => "🇳🇱", + "Benfica" | "Porto" => "🇵🇹", + _ => "🏳️", + } +} + +pub fn display_name(team: &str) -> String { + format!("{} {}", team_flag(team), team) +} + +pub fn tactic_by_key(key: &str) -> Tactic { + TACTICS + .iter() + .copied() + .find(|t| t.key == key) + .unwrap_or(TACTICS[0]) +} + +pub fn profile_for(team: &str) -> TeamProfile { + match team { + "Arsenal" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "FC Barcelona" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "Real Madrid" => TeamProfile { + formation: "4-3-3", + tactic: "counter", + }, + "Manchester City" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "Manchester United" => TeamProfile { + formation: "4-2-3-1", + tactic: "high_press", + }, + "Liverpool" => TeamProfile { + formation: "4-3-3", + tactic: "high_press", + }, + "Bayern Munich" => TeamProfile { + formation: "4-2-3-1", + tactic: "high_press", + }, + "Borussia Dortmund" => TeamProfile { + formation: "4-2-3-1", + tactic: "high_press", + }, + "Paris Saint-Germain" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "Juventus" => TeamProfile { + formation: "3-5-2", + tactic: "low_block", + }, + "Inter" => TeamProfile { + formation: "3-5-2", + tactic: "low_block", + }, + "AC Milan" => TeamProfile { + formation: "4-2-3-1", + tactic: "possession", + }, + "Ajax" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "Benfica" => TeamProfile { + formation: "4-2-3-1", + tactic: "possession", + }, + "Porto" => TeamProfile { + formation: "4-4-2", + tactic: "counter", + }, + "Celtic" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "Kawasaki Frontale" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "Yokohama F. Marinos" => TeamProfile { + formation: "4-3-3", + tactic: "high_press", + }, + "Kashima Antlers" => TeamProfile { + formation: "4-4-2", + tactic: "counter", + }, + "Urawa Red Diamonds" => TeamProfile { + formation: "4-2-3-1", + tactic: "possession", + }, + "Gamba Osaka" => TeamProfile { + formation: "4-4-2", + tactic: "counter", + }, + "Cerezo Osaka" => TeamProfile { + formation: "4-4-2", + tactic: "counter", + }, + "Nagoya Grampus" => TeamProfile { + formation: "4-2-3-1", + tactic: "low_block", + }, + "Sanfrecce Hiroshima" => TeamProfile { + formation: "3-5-2", + tactic: "possession", + }, + "Consadole Sapporo" => TeamProfile { + formation: "3-5-2", + tactic: "high_press", + }, + "Shimizu S-Pulse" => TeamProfile { + formation: "4-4-2", + tactic: "counter", + }, + "Ventforet Kofu" => TeamProfile { + formation: "4-4-2", + tactic: "counter", + }, + "Tokyo Verdy" => TeamProfile { + formation: "4-3-3", + tactic: "possession", + }, + "JEF United Chiba" => TeamProfile { + formation: "4-3-3", + tactic: "counter", + }, + _ => TeamProfile { + formation: "4-4-2", + tactic: "counter", + }, + } +} diff --git a/src/export.rs b/src/export.rs new file mode 100644 index 0000000..d8716c0 --- /dev/null +++ b/src/export.rs @@ -0,0 +1,153 @@ +use std::io::{self, Write}; + +use crate::sim::{PreparedSimulation, SimOutcome}; +use crate::utils::csv_escape; + +fn write_row(mut w: W, cols: &[String]) -> io::Result<()> { + let mut first = true; + for col in cols { + if !first { + w.write_all(b",")?; + } + first = false; + w.write_all(csv_escape(col).as_bytes())?; + } + w.write_all(b"\n") +} + +pub fn simulation_to_csv_bytes(sim: &PreparedSimulation) -> io::Result> { + let mut out: Vec = Vec::new(); + + match &sim.outcome { + SimOutcome::Single(m) => { + write_row( + &mut out, + &[ + "Category".to_string(), + "Home Team".to_string(), + "Away Team".to_string(), + ], + )?; + write_row( + &mut out, + &["Team".to_string(), m.home.clone(), m.away.clone()], + )?; + write_row( + &mut out, + &[ + "Goals".to_string(), + m.home_goals.to_string(), + m.away_goals.to_string(), + ], + )?; + write_row( + &mut out, + &[ + "Shots".to_string(), + m.stats.home.shots.to_string(), + m.stats.away.shots.to_string(), + ], + )?; + write_row( + &mut out, + &[ + "Shots on Target".to_string(), + m.stats.home.sot.to_string(), + m.stats.away.sot.to_string(), + ], + )?; + write_row( + &mut out, + &[ + "xG".to_string(), + format!("{:.2}", m.stats.home.xg), + format!("{:.2}", m.stats.away.xg), + ], + )?; + write_row( + &mut out, + &[ + "Corners".to_string(), + m.stats.home.corners.to_string(), + m.stats.away.corners.to_string(), + ], + )?; + write_row( + &mut out, + &[ + "Fouls".to_string(), + m.stats.home.fouls.to_string(), + m.stats.away.fouls.to_string(), + ], + )?; + write_row( + &mut out, + &[ + "Yellow Cards".to_string(), + m.stats.home.yellows.to_string(), + m.stats.away.yellows.to_string(), + ], + )?; + write_row( + &mut out, + &[ + "Saves".to_string(), + m.stats.home.saves.to_string(), + m.stats.away.saves.to_string(), + ], + )?; + } + SimOutcome::League { final_table, .. } => { + write_row( + &mut out, + &[ + "Team".to_string(), + "P".to_string(), + "W".to_string(), + "D".to_string(), + "L".to_string(), + "GF".to_string(), + "GA".to_string(), + "GD".to_string(), + "Pts".to_string(), + ], + )?; + + for row in final_table { + write_row( + &mut out, + &[ + row.team.clone(), + row.p.to_string(), + row.w.to_string(), + row.d.to_string(), + row.l.to_string(), + row.gf.to_string(), + row.ga.to_string(), + row.gd.to_string(), + row.pts.to_string(), + ], + )?; + } + } + SimOutcome::Knockout { .. } => { + write_row(&mut out, &["Stage".to_string(), "Match Result".to_string()])?; + for line in &sim.history_lines { + let stage = if line.starts_with("Semi 1") { + "Semi 1" + } else if line.starts_with("Semi 2") { + "Semi 2" + } else if line.starts_with("Final") { + "Final" + } else if line.starts_with("Champion") { + "Champion" + } else { + "Info" + }; + write_row(&mut out, &[stage.to_string(), line.clone()])?; + } + } + } + + Ok(out) +} diff --git a/src/instance.rs b/src/instance.rs new file mode 100644 index 0000000..ed7a37e --- /dev/null +++ b/src/instance.rs @@ -0,0 +1,160 @@ +use std::collections::VecDeque; + +use crate::export::simulation_to_csv_bytes; +use crate::sim::{run_simulation, PreparedSimulation, SimOutcome, SimulationType}; +use crate::utils::Rng; + +pub const MAX_LOG_LINES: usize = 1000; + +#[derive(Debug, Clone)] +pub enum SimStatus { + Pending, + Running { + frame_index: usize, + total_frames: usize, + }, + Completed, +} + +#[derive(Debug, Clone)] +pub struct SimulationInstance { + pub id: usize, + pub sim_type: SimulationType, + pub teams: Vec, + pub seed: u64, + pub status: SimStatus, + pub scoreboard: String, + pub logs: VecDeque, + pub stats_lines: Vec, + pub competition_lines: Vec, + pub history_lines: Vec, + prepared: Option, +} + +impl SimulationInstance { + pub fn new(id: usize, sim_type: SimulationType, teams: Vec, seed: u64) -> Self { + Self { + id, + sim_type, + teams, + seed, + status: SimStatus::Pending, + scoreboard: "Waiting for kickoff...".to_string(), + logs: VecDeque::with_capacity(MAX_LOG_LINES), + stats_lines: Vec::new(), + competition_lines: Vec::new(), + history_lines: Vec::new(), + prepared: None, + } + } + + pub fn start(&mut self) { + if !matches!(self.status, SimStatus::Pending) { + return; + } + + let mut rng = Rng::new(self.seed); + let prepared = run_simulation(self.sim_type, &self.teams, &mut rng); + let total_frames = prepared.frames.len(); + self.stats_lines = prepared.stats_lines.clone(); + self.competition_lines = prepared.competition_lines.clone(); + self.history_lines = prepared.history_lines.clone(); + self.status = SimStatus::Running { + frame_index: 0, + total_frames, + }; + self.prepared = Some(prepared); + self.push_log(format!( + "Instance sim-{} started (seed={})", + self.id, self.seed + )); + } + + pub fn tick(&mut self, frames_to_advance: usize) { + if self.prepared.is_none() { + return; + } + + let total_available = self.prepared.as_ref().map(|p| p.frames.len()).unwrap_or(0); + + let SimStatus::Running { + mut frame_index, + total_frames, + } = self.status.clone() + else { + return; + }; + + for _ in 0..frames_to_advance { + if frame_index >= total_available { + self.status = SimStatus::Completed; + self.push_log("Simulation completed.".to_string()); + return; + } + + let frame = self + .prepared + .as_ref() + .and_then(|p| p.frames.get(frame_index).cloned()) + .expect("frame exists while running"); + self.scoreboard = frame.scoreboard; + for line in frame.logs { + self.push_log(line); + } + frame_index += 1; + } + + if frame_index >= total_frames { + self.status = SimStatus::Completed; + self.push_log("Simulation completed.".to_string()); + } else { + self.status = SimStatus::Running { + frame_index, + total_frames, + }; + } + } + + pub fn clone_as(&self, new_id: usize, new_seed: u64) -> Self { + Self::new(new_id, self.sim_type, self.teams.clone(), new_seed) + } + + pub fn progress_text(&self) -> String { + match &self.status { + SimStatus::Pending => "Ready to start".to_string(), + SimStatus::Running { + frame_index, + total_frames, + } => format!("Running {}/{}", frame_index, total_frames), + SimStatus::Completed => "Completed".to_string(), + } + } + + pub fn export_csv(&self) -> Result, String> { + let Some(prepared) = &self.prepared else { + return Err("Simulation has not run yet".to_string()); + }; + simulation_to_csv_bytes(prepared).map_err(|e| format!("CSV export failed: {e}")) + } + + pub fn outcome_summary(&self) -> String { + let Some(prepared) = &self.prepared else { + return "No result yet".to_string(); + }; + + match &prepared.outcome { + SimOutcome::Single(m) => { + format!("{} {}-{} {}", m.home, m.home_goals, m.away_goals, m.away) + } + SimOutcome::League { champion, .. } => format!("Champion: {}", champion), + SimOutcome::Knockout { champion } => format!("Champion: {}", champion), + } + } + + fn push_log(&mut self, line: String) { + if self.logs.len() == MAX_LOG_LINES { + self.logs.pop_front(); + } + self.logs.push_back(line); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..81e091e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,135 @@ +mod app; +mod data; +mod export; +mod instance; +mod sim; +mod ui; +mod utils; + +use std::fs::File; +use std::io::{self, Write}; + +use clap::{Parser, Subcommand, ValueEnum}; + +use app::{run_tui, App, Speed}; +use data::{display_name, TEAMS}; +use export::simulation_to_csv_bytes; +use sim::{run_simulation, SimulationType}; +use utils::{derive_seed, Rng}; + +#[derive(Debug, Parser)] +#[command(name = "soccercloud")] +#[command(about = "MentalNet SoccerCloud - Rust CLI/TUI simulator")] +struct Cli { + #[arg(long, global = true)] + seed: Option, + + #[arg(long, global = true, value_enum, default_value_t = Speed::X1)] + speed: Speed, + + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Commands { + Quick { + #[arg(long)] + home: String, + #[arg(long)] + away: String, + }, + List, + Export { + #[arg(long, value_enum)] + mode: ModeArg, + #[arg(long)] + out: String, + #[arg(long = "team", required = true)] + teams: Vec, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum ModeArg { + Single, + League4, + Knockout4, +} + +impl From for SimulationType { + fn from(value: ModeArg) -> Self { + match value { + ModeArg::Single => SimulationType::Single, + ModeArg::League4 => SimulationType::League4, + ModeArg::Knockout4 => SimulationType::Knockout4, + } + } +} + +fn main() -> io::Result<()> { + let cli = Cli::parse(); + let base_seed = cli.seed.unwrap_or_else(|| Rng::from_time().next_u64()); + + match cli.command { + None => { + let app = App::new(base_seed, cli.speed); + run_tui(app) + } + Some(Commands::Quick { home, away }) => { + quick_mode(&home, &away, base_seed); + Ok(()) + } + Some(Commands::List) => { + for team in TEAMS { + println!("{}", display_name(team)); + } + Ok(()) + } + Some(Commands::Export { mode, out, teams }) => export_mode(mode, out, teams, base_seed), + } +} + +fn quick_mode(home: &str, away: &str, base_seed: u64) { + let teams = vec![home.to_string(), away.to_string()]; + let seed = derive_seed(base_seed, 1); + let mut rng = Rng::new(seed); + let prepared = run_simulation(SimulationType::Single, &teams, &mut rng); + + println!("seed={seed}"); + if let sim::SimOutcome::Single(m) = prepared.outcome { + println!("{} {}-{} {}", m.home, m.home_goals, m.away_goals, m.away); + println!("xG {:.2} - {:.2}", m.stats.home.xg, m.stats.away.xg); + } + + println!("-- log --"); + for frame in prepared.frames { + for line in frame.logs { + println!("{}", line); + } + } +} + +fn export_mode(mode: ModeArg, out: String, teams: Vec, base_seed: u64) -> io::Result<()> { + let required = match mode { + ModeArg::Single => 2, + ModeArg::League4 | ModeArg::Knockout4 => 4, + }; + if teams.len() != required { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "mode {:?} requires exactly {} --team values", + mode, required + ), + )); + } + + let mut rng = Rng::new(derive_seed(base_seed, 1)); + let prepared = run_simulation(mode.into(), &teams, &mut rng); + let bytes = simulation_to_csv_bytes(&prepared)?; + let mut f = File::create(&out)?; + f.write_all(&bytes)?; + println!("Wrote {}", out); + Ok(()) +} diff --git a/src/sim.rs b/src/sim.rs new file mode 100644 index 0000000..d4bd0da --- /dev/null +++ b/src/sim.rs @@ -0,0 +1,688 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; + +use crate::data::{display_name, profile_for, tactic_by_key, TeamProfile}; +use crate::utils::{pad2, Rng}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimulationType { + Single, + League4, + Knockout4, +} + +impl SimulationType { + pub fn as_str(self) -> &'static str { + match self { + SimulationType::Single => "single", + SimulationType::League4 => "league4", + SimulationType::Knockout4 => "knockout4", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct TeamStats { + pub shots: u16, + pub sot: u16, + pub xg: f64, + pub corners: u16, + pub fouls: u16, + pub yellows: u16, + pub offsides: u16, + pub saves: u16, + pub attacks: u16, +} + +#[derive(Debug, Clone)] +pub struct MatchStats { + pub home: TeamStats, + pub away: TeamStats, +} + +#[derive(Debug, Clone)] +pub struct MatchResult { + pub home: String, + pub away: String, + pub home_goals: u8, + pub away_goals: u8, + pub home_profile: TeamProfile, + pub away_profile: TeamProfile, + pub stats: MatchStats, +} + +#[derive(Debug, Clone)] +pub struct StandingsRow { + pub team: String, + pub p: u8, + pub w: u8, + pub d: u8, + pub l: u8, + pub gf: u16, + pub ga: u16, + pub gd: i16, + pub pts: u8, +} + +#[derive(Debug, Clone)] +pub struct SimFrame { + pub scoreboard: String, + pub logs: Vec, +} + +#[derive(Debug, Clone)] +pub enum SimOutcome { + Single(MatchResult), + League { + champion: String, + final_table: Vec, + }, + Knockout { + champion: String, + }, +} + +#[derive(Debug, Clone)] +pub struct PreparedSimulation { + pub frames: Vec, + pub outcome: SimOutcome, + pub stats_lines: Vec, + pub competition_lines: Vec, + pub history_lines: Vec, +} + +fn chance(rng: &mut Rng, p: f64) -> bool { + rng.chance(p) +} + +pub fn penalties(rng: &mut Rng) -> (u8, u8, bool) { + let mut h = 0u8; + let mut a = 0u8; + for _ in 0..5 { + if chance(rng, 0.76) { + h += 1; + } + if chance(rng, 0.76) { + a += 1; + } + } + let mut rounds = 0; + while h == a && rounds < 20 { + if chance(rng, 0.76) { + h += 1; + } + if chance(rng, 0.76) { + a += 1; + } + rounds += 1; + } + (h, a, h > a) +} + +pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Vec) { + let home_profile = profile_for(home); + let away_profile = profile_for(away); + + let home_tactic = tactic_by_key(home_profile.tactic); + let away_tactic = tactic_by_key(away_profile.tactic); + + let mut minute: u8 = 0; + let mut home_goals: u8 = 0; + let mut away_goals: u8 = 0; + + let mut stats = MatchStats { + home: TeamStats::default(), + away: TeamStats::default(), + }; + + let kickoff = format!( + "Kickoff! {} ({}, {}) vs {} ({}, {})", + display_name(home), + home_profile.formation, + home_tactic.label, + display_name(away), + away_profile.formation, + away_tactic.label + ); + + let mut frames = vec![SimFrame { + scoreboard: format!( + "{} ({}) {} - {} {} ({}) | {}'", + display_name(home), + home_profile.formation, + home_goals, + away_goals, + display_name(away), + away_profile.formation, + pad2(minute) + ), + logs: vec![kickoff], + }]; + + while minute < 90 { + minute += 1; + let pressure_boost = if minute < 15 || minute > 75 { 1.2 } else { 1.0 }; + let mut logs: Vec = Vec::new(); + + let home_bias = home_tactic.attack_bias; + let away_bias = away_tactic.attack_bias; + let home_attacks = rng.next_f64() * (home_bias + away_bias) < home_bias; + + let (atk_team, def_team, atk_prof, def_prof, atk_stats, def_stats) = if home_attacks { + ( + home, + away, + home_profile, + away_profile, + &mut stats.home, + &mut stats.away, + ) + } else { + ( + away, + home, + away_profile, + home_profile, + &mut stats.away, + &mut stats.home, + ) + }; + + let atk_tactic = tactic_by_key(atk_prof.tactic); + let def_tactic = tactic_by_key(def_prof.tactic); + + if chance(rng, 0.24 * pressure_boost) { + atk_stats.attacks += 1; + let fast_break = chance(rng, atk_tactic.fast_break); + if chance(rng, (if fast_break { 0.75 } else { 0.55 }) * pressure_boost) { + atk_stats.shots += 1; + let mut xg = if fast_break { + 0.20 + rng.next_f64() * 0.25 + } else { + 0.05 + rng.next_f64() * 0.22 + }; + xg *= atk_tactic.goal_mult; + xg /= def_tactic.block_mult; + + let on_target = chance(rng, 0.52); + if on_target { + atk_stats.sot += 1; + } + + let is_goal = on_target && chance(rng, xg); + if is_goal { + if home_attacks { + home_goals += 1; + } else { + away_goals += 1; + } + let finish = if fast_break { + "cut-back finish" + } else { + "drilled low" + }; + logs.push(format!( + "{}' GOOOOAL - {} ({}, xG {:.2})", + pad2(minute), + display_name(atk_team), + finish, + xg + )); + } else if on_target { + def_stats.saves += 1; + logs.push(format!( + "{}' Big save by {}'s keeper!", + pad2(minute), + display_name(def_team) + )); + } else if chance(rng, 0.25) { + logs.push(format!( + "{}' {} fire it just wide.", + pad2(minute), + display_name(atk_team) + )); + } + atk_stats.xg += xg; + } + + if chance(rng, 0.05 * atk_tactic.attack_bias) { + atk_stats.corners += 1; + logs.push(format!( + "{}' Corner to {}.", + pad2(minute), + display_name(atk_team) + )); + } + + if chance(rng, 0.035 + 0.02 * atk_tactic.fast_break) { + atk_stats.offsides += 1; + logs.push(format!( + "{}' Flag up - {} caught offside.", + pad2(minute), + display_name(atk_team) + )); + } + } + + if chance(rng, 0.07 * atk_tactic.press_mult * atk_tactic.foul_mult) { + def_stats.fouls += 1; + if chance(rng, 0.22 * atk_tactic.press_mult) { + def_stats.yellows += 1; + logs.push(format!( + "{}' Yellow card to {}.", + pad2(minute), + display_name(def_team) + )); + } + } + + if minute == 45 { + logs.push(format!( + "Halftime - {} {}-{} {}", + display_name(home), + home_goals, + away_goals, + display_name(away) + )); + } + + if minute == 90 { + logs.push(format!( + "Full time - {} {}-{} {}", + display_name(home), + home_goals, + away_goals, + display_name(away) + )); + } + + frames.push(SimFrame { + scoreboard: format!( + "{} ({}) {} - {} {} ({}) | {}'", + display_name(home), + home_profile.formation, + home_goals, + away_goals, + display_name(away), + away_profile.formation, + pad2(minute) + ), + logs, + }); + } + + ( + MatchResult { + home: home.to_string(), + away: away.to_string(), + home_goals, + away_goals, + home_profile, + away_profile, + stats, + }, + frames, + ) +} + +pub fn match_stats_lines(result: &MatchResult) -> Vec { + let home_tactic = tactic_by_key(result.home_profile.tactic); + let away_tactic = tactic_by_key(result.away_profile.tactic); + let home_poss_base = (result.stats.home.attacks as f64) + * if result.home_profile.tactic == "possession" { + 1.15 + } else { + 1.0 + }; + let away_poss_base = (result.stats.away.attacks as f64) + * if result.away_profile.tactic == "possession" { + 1.15 + } else { + 1.0 + }; + let home_poss = if (home_poss_base + away_poss_base) > 0.0 { + ((home_poss_base / (home_poss_base + away_poss_base)) * 100.0).round() as u8 + } else { + 50 + }; + let away_poss = 100 - home_poss; + + vec![ + format!( + "Tactics: {} {} | {} {}", + display_name(&result.home), + home_tactic.label, + display_name(&result.away), + away_tactic.label + ), + format!( + "Shots (On Target): {} ({}) vs {} ({})", + result.stats.home.shots, + result.stats.home.sot, + result.stats.away.shots, + result.stats.away.sot + ), + format!( + "xG: {:.2} vs {:.2}", + result.stats.home.xg, result.stats.away.xg + ), + format!( + "Corners: {} vs {}", + result.stats.home.corners, result.stats.away.corners + ), + format!( + "Fouls (Yellows): {} ({}) vs {} ({})", + result.stats.home.fouls, + result.stats.home.yellows, + result.stats.away.fouls, + result.stats.away.yellows + ), + format!( + "Offsides: {} vs {}", + result.stats.home.offsides, result.stats.away.offsides + ), + format!( + "Saves: {} vs {}", + result.stats.home.saves, result.stats.away.saves + ), + format!("Possession: {}% vs {}%", home_poss, away_poss), + ] +} + +fn standings_cmp(a: &StandingsRow, b: &StandingsRow) -> Ordering { + b.pts + .cmp(&a.pts) + .then(b.gd.cmp(&a.gd)) + .then(b.gf.cmp(&a.gf)) + .then(a.team.cmp(&b.team)) +} + +fn init_table(teams: &[String]) -> BTreeMap { + let mut map = BTreeMap::new(); + for team in teams { + map.insert( + team.clone(), + StandingsRow { + team: team.clone(), + p: 0, + w: 0, + d: 0, + l: 0, + gf: 0, + ga: 0, + gd: 0, + pts: 0, + }, + ); + } + map +} + +pub fn run_single(teams: &[String], rng: &mut Rng) -> PreparedSimulation { + let home = teams[0].clone(); + let away = teams[1].clone(); + let (result, frames) = simulate_match(&home, &away, rng); + let stats_lines = match_stats_lines(&result); + PreparedSimulation { + frames, + outcome: SimOutcome::Single(result), + stats_lines, + competition_lines: vec![], + history_lines: vec![], + } +} + +pub fn run_league4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { + let fixtures = vec![ + (teams[0].clone(), teams[1].clone()), + (teams[2].clone(), teams[3].clone()), + (teams[0].clone(), teams[2].clone()), + (teams[1].clone(), teams[3].clone()), + (teams[0].clone(), teams[3].clone()), + (teams[1].clone(), teams[2].clone()), + ]; + + let mut table = init_table(teams); + let mut frames = Vec::new(); + let mut history = Vec::new(); + let mut last_stats = Vec::new(); + + for (idx, (home, away)) in fixtures.iter().enumerate() { + frames.push(SimFrame { + scoreboard: format!("Running League Match {}/{}", idx + 1, fixtures.len()), + logs: vec![format!( + "League fixture {}/{}: {} vs {}", + idx + 1, + fixtures.len(), + display_name(home), + display_name(away) + )], + }); + + let (res, mut match_frames) = simulate_match(home, away, rng); + frames.append(&mut match_frames); + last_stats = match_stats_lines(&res); + + { + let home_row = table.get_mut(home).expect("home in table"); + home_row.p += 1; + home_row.gf += res.home_goals as u16; + home_row.ga += res.away_goals as u16; + home_row.gd = home_row.gf as i16 - home_row.ga as i16; + if res.home_goals > res.away_goals { + home_row.w += 1; + home_row.pts += 3; + } else if res.home_goals == res.away_goals { + home_row.d += 1; + home_row.pts += 1; + } else { + home_row.l += 1; + } + } + + { + let away_row = table.get_mut(away).expect("away in table"); + away_row.p += 1; + away_row.gf += res.away_goals as u16; + away_row.ga += res.home_goals as u16; + away_row.gd = away_row.gf as i16 - away_row.ga as i16; + if res.away_goals > res.home_goals { + away_row.w += 1; + away_row.pts += 3; + } else if res.away_goals == res.home_goals { + away_row.d += 1; + away_row.pts += 1; + } else { + away_row.l += 1; + } + } + + history.push(format!( + "{} {}-{} {}", + display_name(home), + res.home_goals, + res.away_goals, + display_name(away) + )); + } + + let mut final_table: Vec = table.into_values().collect(); + final_table.sort_by(standings_cmp); + let champion = final_table[0].team.clone(); + history.push(format!( + "Champion: {} with {} pts", + display_name(&champion), + final_table[0].pts + )); + + let competition = final_table + .iter() + .map(|r| { + format!( + "{} | P:{} W:{} D:{} L:{} GF:{} GA:{} GD:{} Pts:{}", + display_name(&r.team), + r.p, + r.w, + r.d, + r.l, + r.gf, + r.ga, + r.gd, + r.pts + ) + }) + .collect(); + + PreparedSimulation { + frames, + outcome: SimOutcome::League { + champion, + final_table, + }, + stats_lines: last_stats, + competition_lines: competition, + history_lines: history, + } +} + +pub fn run_knockout4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { + let semis = vec![ + (teams[0].clone(), teams[3].clone()), + (teams[1].clone(), teams[2].clone()), + ]; + let mut winners = Vec::new(); + let mut history = Vec::new(); + let mut frames = Vec::new(); + + for (idx, (home, away)) in semis.iter().enumerate() { + frames.push(SimFrame { + scoreboard: format!("Running Semi-final {}/2", idx + 1), + logs: vec![format!( + "Semi {}: {} vs {}", + idx + 1, + display_name(home), + display_name(away) + )], + }); + + let (res, mut semi_frames) = simulate_match(home, away, rng); + frames.append(&mut semi_frames); + + let winner = if res.home_goals == res.away_goals { + let (ph, pa, home_wins) = penalties(rng); + history.push(format!( + "Semi {}: {} {}-{} {} (pens {}-{})", + idx + 1, + display_name(home), + res.home_goals, + res.away_goals, + display_name(away), + ph, + pa + )); + if home_wins { + home.clone() + } else { + away.clone() + } + } else if res.home_goals > res.away_goals { + history.push(format!( + "Semi {}: {} {}-{} {}", + idx + 1, + display_name(home), + res.home_goals, + res.away_goals, + display_name(away) + )); + home.clone() + } else { + history.push(format!( + "Semi {}: {} {}-{} {}", + idx + 1, + display_name(home), + res.home_goals, + res.away_goals, + display_name(away) + )); + away.clone() + }; + winners.push(winner); + } + + frames.push(SimFrame { + scoreboard: "Running Final".to_string(), + logs: vec![format!( + "Final: {} vs {}", + display_name(&winners[0]), + display_name(&winners[1]) + )], + }); + + let (final_res, mut final_frames) = simulate_match(&winners[0], &winners[1], rng); + frames.append(&mut final_frames); + let last_stats = match_stats_lines(&final_res); + + let champion = if final_res.home_goals == final_res.away_goals { + let (ph, pa, home_wins) = penalties(rng); + history.push(format!( + "Final: {} {}-{} {} (pens {}-{})", + display_name(&winners[0]), + final_res.home_goals, + final_res.away_goals, + display_name(&winners[1]), + ph, + pa + )); + if home_wins { + winners[0].clone() + } else { + winners[1].clone() + } + } else if final_res.home_goals > final_res.away_goals { + history.push(format!( + "Final: {} {}-{} {}", + display_name(&winners[0]), + final_res.home_goals, + final_res.away_goals, + display_name(&winners[1]) + )); + winners[0].clone() + } else { + history.push(format!( + "Final: {} {}-{} {}", + display_name(&winners[0]), + final_res.home_goals, + final_res.away_goals, + display_name(&winners[1]) + )); + winners[1].clone() + }; + + history.push(format!("Champion: {} 🏆", display_name(&champion))); + + PreparedSimulation { + frames, + outcome: SimOutcome::Knockout { + champion: champion.clone(), + }, + stats_lines: last_stats, + competition_lines: vec![ + "Bracket: Semi 1 = Team1 vs Team4".to_string(), + "Bracket: Semi 2 = Team2 vs Team3".to_string(), + format!("Champion: {}", display_name(&champion)), + ], + history_lines: history, + } +} + +pub fn run_simulation( + sim_type: SimulationType, + teams: &[String], + rng: &mut Rng, +) -> PreparedSimulation { + match sim_type { + SimulationType::Single => run_single(teams, rng), + SimulationType::League4 => run_league4(teams, rng), + SimulationType::Knockout4 => run_knockout4(teams, rng), + } +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs new file mode 100644 index 0000000..db57d99 --- /dev/null +++ b/src/ui/dashboard.rs @@ -0,0 +1,83 @@ +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +use crate::app::App; +use crate::instance::SimStatus; +use crate::ui::widgets::status_badge; + +pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(area); + + let mut items: Vec = Vec::new(); + if app.instances.is_empty() { + items.push(ListItem::new( + "No instances yet. Press n, l, or o to create one.", + )); + } else { + for (idx, inst) in app.instances.iter().enumerate() { + let marker = if idx == app.selected { ">" } else { " " }; + let line = format!( + "{} sim-{} [{}] {} | {}", + marker, + inst.id, + inst.sim_type.as_str(), + status_badge(&inst.status), + inst.progress_text() + ); + let mut item = ListItem::new(line); + if idx == app.selected { + item = item.style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + } + items.push(item); + } + } + + let list = List::new(items).block(Block::default().title("Instances").borders(Borders::ALL)); + f.render_widget(list, chunks[0]); + + let detail_text = if let Some(inst) = app.selected_instance() { + let status = match &inst.status { + SimStatus::Pending => "pending", + SimStatus::Running { .. } => "running", + SimStatus::Completed => "completed", + }; + format!( + "ID: sim-{}\nType: {}\nStatus: {}\nSeed: {}\nTeams:\n- {}\n- {}{}\n\nOutcome:\n{}\n\nTip: Press Enter or v to open live detail view.", + inst.id, + inst.sim_type.as_str(), + status, + inst.seed, + inst.teams.first().cloned().unwrap_or_default(), + inst.teams.get(1).cloned().unwrap_or_default(), + if inst.teams.len() > 2 { + format!( + "\n- {}\n- {}", + inst.teams.get(2).cloned().unwrap_or_default(), + inst.teams.get(3).cloned().unwrap_or_default() + ) + } else { + String::new() + }, + inst.outcome_summary() + ) + } else { + "No selection".to_string() + }; + + let details = Paragraph::new(detail_text) + .block( + Block::default() + .title("Selected Instance") + .borders(Borders::ALL), + ) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(details, chunks[1]); +} diff --git a/src/ui/detail.rs b/src/ui/detail.rs new file mode 100644 index 0000000..318529a --- /dev/null +++ b/src/ui/detail.rs @@ -0,0 +1,109 @@ +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs}; + +use crate::app::App; +use crate::instance::SimStatus; + +pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) { + let Some(inst) = app.selected_instance() else { + let empty = Paragraph::new("No selected instance") + .block(Block::default().title("Detail").borders(Borders::ALL)); + f.render_widget(empty, area); + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(8), + ]) + .split(area); + + let score = Paragraph::new(inst.scoreboard.clone()) + .block( + Block::default() + .title(format!("sim-{} Scoreboard", inst.id)) + .borders(Borders::ALL), + ) + .style(Style::default().fg(Color::White).bg(Color::Black)); + f.render_widget(score, chunks[0]); + + let middle = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(chunks[1]); + + let logs: Vec = inst + .logs + .iter() + .rev() + .take((middle[0].height as usize).saturating_sub(2)) + .rev() + .map(|line| ListItem::new(line.clone())) + .collect(); + let log_widget = + List::new(logs).block(Block::default().title("Live Log").borders(Borders::ALL)); + f.render_widget(log_widget, middle[0]); + + let mut right_lines: Vec = Vec::new(); + let status = match &inst.status { + SimStatus::Pending => "pending", + SimStatus::Running { .. } => "running", + SimStatus::Completed => "completed", + }; + right_lines.push(ListItem::new(format!("Status: {}", status))); + right_lines.push(ListItem::new(format!("Seed: {}", inst.seed))); + right_lines.push(ListItem::new(format!("Mode: {}", inst.sim_type.as_str()))); + right_lines.push(ListItem::new("")); + right_lines.push(ListItem::new("Teams:")); + for t in &inst.teams { + right_lines.push(ListItem::new(format!("- {}", t))); + } + let side = List::new(right_lines).block( + Block::default() + .title("Instance Info") + .borders(Borders::ALL), + ); + f.render_widget(side, middle[1]); + + let bottom = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(chunks[2]); + + let stats = List::new( + inst.stats_lines + .iter() + .map(|s| ListItem::new(s.clone())) + .collect::>(), + ) + .block(Block::default().title("Stats").borders(Borders::ALL)); + let comp = List::new( + inst.competition_lines + .iter() + .map(|s| ListItem::new(s.clone())) + .collect::>(), + ) + .block(Block::default().title("Competition").borders(Borders::ALL)); + let hist = List::new( + inst.history_lines + .iter() + .map(|s| ListItem::new(s.clone())) + .collect::>(), + ) + .block(Block::default().title("History").borders(Borders::ALL)); + + f.render_widget(stats, bottom[0]); + f.render_widget(comp, bottom[1]); + f.render_widget(hist, bottom[2]); + + let tabs = Tabs::new(vec!["Dashboard", "Detail"]).select(1); + let _ = tabs; +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..7fa114e --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,41 @@ +pub mod dashboard; +pub mod detail; +pub mod modal; +pub mod widgets; + +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::App; + +pub fn draw(f: &mut Frame<'_>, app: &App) { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(f.area()); + + let header = Paragraph::new("MentalNet SoccerCloud | n=single l=league4 o=knockout4 s=start c=clone d=delete e=export v=detail q=quit") + .block(Block::default().title("Dashboard").borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan)); + f.render_widget(header, areas[0]); + + if app.show_detail { + detail::render(f, areas[1], app); + } else { + dashboard::render(f, areas[1], app); + } + + let footer = Paragraph::new(format!( + "{} | speed={} (1/2/4/0)", + app.status_line, + app.speed.label() + )) + .block(Block::default().borders(Borders::ALL).title("Status")) + .style(Style::default().fg(Color::Green)); + f.render_widget(footer, areas[2]); +} diff --git a/src/ui/modal.rs b/src/ui/modal.rs new file mode 100644 index 0000000..d3434f9 --- /dev/null +++ b/src/ui/modal.rs @@ -0,0 +1,2 @@ +// Placeholder module for future interactive creation modal. +// Current MVP uses keyboard shortcuts for fast instance creation. diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs new file mode 100644 index 0000000..37bbef7 --- /dev/null +++ b/src/ui/widgets.rs @@ -0,0 +1,9 @@ +use crate::instance::SimStatus; + +pub fn status_badge(status: &SimStatus) -> &'static str { + match status { + SimStatus::Pending => "PENDING", + SimStatus::Running { .. } => "RUNNING", + SimStatus::Completed => "COMPLETED", + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..94b8a41 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,90 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const CSV_INJECTION_PREFIX: char = '\''; + +#[derive(Debug, Clone)] +pub struct Rng { + state: u64, +} + +impl Rng { + pub fn new(seed: u64) -> Self { + let seeded = if seed == 0 { 0x9E3779B97F4A7C15 } else { seed }; + Self { + state: splitmix64(seeded), + } + } + + pub fn from_time() -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0xA5A5_A5A5_A5A5_A5A5); + Self::new(now) + } + + pub fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.state = x; + x.wrapping_mul(0x2545F4914F6CDD1D) + } + + pub fn next_f64(&mut self) -> f64 { + let v = self.next_u64() >> 11; + (v as f64) * (1.0 / ((1u64 << 53) as f64)) + } + + pub fn chance(&mut self, p: f64) -> bool { + self.next_f64() < p.clamp(0.0, 1.0) + } + + pub fn range_usize(&mut self, upper_exclusive: usize) -> usize { + if upper_exclusive <= 1 { + return 0; + } + (self.next_u64() % (upper_exclusive as u64)) as usize + } +} + +pub fn derive_seed(base_seed: u64, salt: u64) -> u64 { + splitmix64(base_seed ^ salt.wrapping_mul(0x9E3779B97F4A7C15)) +} + +pub fn splitmix64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E3779B97F4A7C15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) +} + +pub fn pad2(minute: u8) -> String { + format!("{:02}", minute) +} + +pub fn sanitize_csv_cell(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.starts_with('=') + || trimmed.starts_with('+') + || trimmed.starts_with('-') + || trimmed.starts_with('@') + { + format!("{}{}", CSV_INJECTION_PREFIX, raw) + } else { + raw.to_string() + } +} + +pub fn csv_escape(field: &str) -> String { + let sanitized = sanitize_csv_cell(field); + let needs_quotes = + sanitized.contains(',') || sanitized.contains('\n') || sanitized.contains('"'); + if needs_quotes { + format!("\"{}\"", sanitized.replace('"', "\"\"")) + } else { + sanitized + } +}