Initial Rust CLI/TUI Rebuild (Seeded Sim + CSV Export)
This commit is contained in:
parent
faf0929bb6
commit
dae34cce41
16 changed files with 2739 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
709
Cargo.lock
generated
Normal file
709
Cargo.lock
generated
Normal 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
14
Cargo.toml
Normal 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
39
DEVLOG.md
Normal 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
251
src/app.rs
Normal 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
255
src/data.rs
Normal 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
153
src/export.rs
Normal 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
160
src/instance.rs
Normal 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
135
src/main.rs
Normal 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
688
src/sim.rs
Normal 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
83
src/ui/dashboard.rs
Normal 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
109
src/ui/detail.rs
Normal 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
41
src/ui/mod.rs
Normal 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
2
src/ui/modal.rs
Normal 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
9
src/ui/widgets.rs
Normal 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
90
src/utils.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue