Initial Rust CLI/TUI Rebuild (Seeded Sim + CSV Export)

This commit is contained in:
markmental 2026-02-10 16:44:07 -05:00
commit dae34cce41
16 changed files with 2739 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

709
Cargo.lock generated Normal file
View file

@ -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"

14
Cargo.toml Normal file
View file

@ -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

39
DEVLOG.md Normal file
View file

@ -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.

251
src/app.rs Normal file
View file

@ -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<SimulationInstance>,
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<String> {
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<String> {
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
}

255
src/data.rs Normal file
View file

@ -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",
},
}
}

153
src/export.rs Normal file
View file

@ -0,0 +1,153 @@
use std::io::{self, Write};
use crate::sim::{PreparedSimulation, SimOutcome};
use crate::utils::csv_escape;
fn write_row<W: Write>(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<Vec<u8>> {
let mut out: Vec<u8> = 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)
}

160
src/instance.rs Normal file
View file

@ -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<String>,
pub seed: u64,
pub status: SimStatus,
pub scoreboard: String,
pub logs: VecDeque<String>,
pub stats_lines: Vec<String>,
pub competition_lines: Vec<String>,
pub history_lines: Vec<String>,
prepared: Option<PreparedSimulation>,
}
impl SimulationInstance {
pub fn new(id: usize, sim_type: SimulationType, teams: Vec<String>, 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<Vec<u8>, 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);
}
}

135
src/main.rs Normal file
View file

@ -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<u64>,
#[arg(long, global = true, value_enum, default_value_t = Speed::X1)]
speed: Speed,
#[command(subcommand)]
command: Option<Commands>,
}
#[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<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ModeArg {
Single,
League4,
Knockout4,
}
impl From<ModeArg> 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<String>, 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(())
}

688
src/sim.rs Normal file
View file

@ -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<String>,
}
#[derive(Debug, Clone)]
pub enum SimOutcome {
Single(MatchResult),
League {
champion: String,
final_table: Vec<StandingsRow>,
},
Knockout {
champion: String,
},
}
#[derive(Debug, Clone)]
pub struct PreparedSimulation {
pub frames: Vec<SimFrame>,
pub outcome: SimOutcome,
pub stats_lines: Vec<String>,
pub competition_lines: Vec<String>,
pub history_lines: Vec<String>,
}
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<SimFrame>) {
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<String> = 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<String> {
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<String, StandingsRow> {
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<StandingsRow> = 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),
}
}

83
src/ui/dashboard.rs Normal file
View file

@ -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<ListItem> = 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]);
}

109
src/ui/detail.rs Normal file
View file

@ -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<ListItem> = 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<ListItem> = 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::<Vec<_>>(),
)
.block(Block::default().title("Stats").borders(Borders::ALL));
let comp = List::new(
inst.competition_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.block(Block::default().title("Competition").borders(Borders::ALL));
let hist = List::new(
inst.history_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.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;
}

41
src/ui/mod.rs Normal file
View file

@ -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]);
}

2
src/ui/modal.rs Normal file
View file

@ -0,0 +1,2 @@
// Placeholder module for future interactive creation modal.
// Current MVP uses keyboard shortcuts for fast instance creation.

9
src/ui/widgets.rs Normal file
View file

@ -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",
}
}

90
src/utils.rs Normal file
View file

@ -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
}
}