diff --git a/Cargo.lock b/Cargo.lock index f6bfb73..b4dc4f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,12 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "bytes" version = "1.10.1" @@ -96,6 +102,73 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a48563284b67c003ba0fb7243c87fab68885e1532c605704228a80238512e31" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "derive_more", + "document-features", + "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 = "dashmap" version = "5.5.3" @@ -109,13 +182,38 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "dev_tests" version = "0.1.0" dependencies = [ "chardet", "encoding_rs", + "is-terminal", + "libc", "rottlib", + "sysinfo", "walkdir", ] @@ -130,6 +228,21 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -139,6 +252,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fnv" version = "1.0.7" @@ -243,6 +366,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "httparse" version = "1.10.1" @@ -367,6 +496,17 @@ dependencies = [ "libc", ] +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + [[package]] name = "itoa" version = "1.0.15" @@ -385,12 +525,24 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.13" @@ -401,6 +553,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "logos" version = "0.15.0" @@ -470,10 +628,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi", "windows-sys", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "object" version = "0.36.7" @@ -577,6 +745,26 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -596,6 +784,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" name = "rottlib" version = "0.1.0" dependencies = [ + "backtrace", + "bumpalo", + "crossterm", "logos", ] @@ -623,6 +814,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "ryu" version = "1.0.20" @@ -693,6 +897,27 @@ dependencies = [ "syn", ] +[[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.5" @@ -752,6 +977,21 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -903,6 +1143,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "url" version = "2.5.4" @@ -937,6 +1183,22 @@ 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-util" version = "0.1.9" @@ -946,6 +1208,31 @@ dependencies = [ "windows-sys", ] +[[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" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 4e9485c..1142916 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ codegen-units = 1 # Reduce number of codegen units to increase optimizations debug = false # strip all debug info [profile.flamegraph] -inherits = "release" # start from release +inherits = "dev" # start from release strip = false debug = true # full DWARF info for unwinding split-debuginfo = "unpacked" # keep symbols inside the binary diff --git a/dev_tests/Cargo.toml b/dev_tests/Cargo.toml index 70f7783..560fe5f 100644 --- a/dev_tests/Cargo.toml +++ b/dev_tests/Cargo.toml @@ -3,22 +3,21 @@ name = "dev_tests" version = "0.1.0" edition = "2024" -[[bin]] -name = "dump_tokens" -path = "src/dump_tokens.rs" - [[bin]] name = "uc_lexer_verify" path = "src/uc_lexer_verify.rs" [[bin]] -name = "temp" -path = "src/temp.rs" +name = "verify_expr" +path = "src/verify_expr.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] rottlib = { version = "0", path = "../rottlib", features = ["debug"] } +is-terminal = "0.4" +libc = "0.2" +sysinfo = "0.30" walkdir="2.5" encoding_rs="0.8" chardet="0.2" diff --git a/dev_tests/src/dump_tokens.rs b/dev_tests/src/dump_tokens.rs deleted file mode 100644 index 1a5c846..0000000 --- a/dev_tests/src/dump_tokens.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use encoding_rs::{Encoding, UTF_8}; -use rottlib::lexer::{DebugTools, TokenizedFile}; - -/// Recursively search `root` for the first file whose *basename* matches -/// `needle` (case-sensitive). -/// -/// Returns the absolute path. -fn find_file(root: &Path, needle: &str) -> Option { - for entry in walkdir::WalkDir::new(root) - .into_iter() - .filter_map(Result::ok) - { - let path = entry.path(); - if path.is_file() && (path.file_name().and_then(|name| name.to_str()) == Some(needle)) { - return fs::canonicalize(path).ok(); - } - } - None -} - -/// CLI: `dump_tokens ` - searches for `` -/// recursively inside ``. -/// -/// This utility takes *root directory* and *file name* instead of the full path -/// to help us avoid searching for them typing names out: -/// -/// - We know where all the sources are; -/// - We usually just know the name of the file that is being problematic. -fn main() { - let mut args = std::env::args().skip(1); - let root_dir = args.next().unwrap_or_else(|| { - eprintln!("Usage: inspect_uc "); - std::process::exit(1); - }); - let file_name = args.next().unwrap_or_else(|| { - eprintln!("Usage: inspect_uc "); - std::process::exit(1); - }); - - let root = PathBuf::from(&root_dir); - if !root.exists() { - eprintln!("Root directory '{root_dir}' does not exist."); - std::process::exit(1); - } - - let found_path = find_file(&root, &file_name).map_or_else( - || { - eprintln!("File '{file_name}' not found under '{root_dir}'."); - std::process::exit(1); - }, - |path| path, - ); - - // Read & decode - let raw_bytes = match fs::read(&found_path) { - Ok(sources) => sources, - Err(error) => { - eprintln!("Could not read {}: {error}", found_path.display()); - std::process::exit(1); - } - }; - - let (encoding_label, _, _) = chardet::detect(&raw_bytes); - let encoding = Encoding::for_label(encoding_label.as_bytes()).unwrap_or(UTF_8); - let (decoded_str, _, _) = encoding.decode(&raw_bytes); - - let source_text = decoded_str.to_string(); - let tokenized_file = TokenizedFile::from_str(&source_text); - - tokenized_file.dump_debug_layout(); -} diff --git a/dev_tests/src/temp.rs b/dev_tests/src/temp.rs deleted file mode 100644 index 34cd8af..0000000 --- a/dev_tests/src/temp.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! src/main.rs -//! -------------------------------------------- -//! Build & run: -//! cargo run -//! -------------------------------------------- - -use std::env; -use std::fs; -use std::io::{self, Read, Write}; -use std::path::Path; - -use rottlib::arena::Arena; -use rottlib::lexer::TokenizedFile; -use rottlib::parser::{ParseError, Parser, pretty::ExprTree}; - -/* -- Convenient array definitions: [1, 3, 5, 2, 4] -- Boolean dynamic arrays -- Structures in default properties -- Auto conversion of arrays into strings -- Making 'var' and 'local' unnecessary -- Allowing variable creation in 'for' loops -- Allowing variable creation at any place inside a function -- Default parameters for functions -- Function overloading? -- repeat/until -- The syntax of the default properties block is pretty strict for an arcane reason. Particularly adding spaces before or after the "=" will lead to errors in pre-UT2003 versions. -- Scopes -- different names for variables and in config file -- anonymous pairs (objects?) and value destruction ->>> AST > HIR > MIR > byte code -*/ - -/// Closest plan: -/// - Add top-level declaration parsing -/// - Handle pretty.rs shit somehow -/// - COMMITS -/// --------------------------------------- -/// - Add fancy error reporting -/// - Make a fancy REPL -/// - Add evaluation -/// -/// WARNINGS: -/// - Empty code/switch blocks - -fn parse_and_print(src: &str) -> Result<(), ParseError> { - let tokenized = TokenizedFile::from_str(src); - let arena = Arena::new(); - let mut parser = Parser::new(&tokenized, &arena); - - let expr = parser.parse_expression(); // ArenaNode - println!("{}", ExprTree(&*expr)); // if ArenaNode - // or: println!("{}", ExprTree(expr.as_ref())); // if no Deref - Ok(()) -} - -fn repl_once() -> Result<(), ParseError> { - print!("Enter an statement > "); - io::stdout().flush().unwrap(); - - let mut input = String::new(); - if io::stdin().read_line(&mut input).is_err() { - eprintln!("failed to read input"); - return Ok(()); - } - - if input.trim().is_empty() { - return Ok(()); - } - - parse_and_print(&input) -} - -fn read_stdin_all() -> io::Result { - let mut buf = String::new(); - io::stdin().read_to_string(&mut buf)?; - Ok(buf) -} - -fn read_file_to_string(path: &Path) -> io::Result { - fs::read_to_string(path) -} - -fn main() -> Result<(), ParseError> { - // Accept a single positional arg as the input path. - // "-" means read all of stdin. - let mut args = env::args().skip(1); - - if let Some(arg1) = args.next() { - if arg1 == "-h" || arg1 == "--help" { - println!("Usage:"); - println!( - " {} # REPL", - env::args().next().unwrap_or_else(|| "prog".into()) - ); - println!( - " {} # parse file", - env::args().next().unwrap_or_else(|| "prog".into()) - ); - println!( - " {} - # read source from stdin", - env::args().next().unwrap_or_else(|| "prog".into()) - ); - return Ok(()); - } - - if arg1 == "-" { - match read_stdin_all() { - Ok(src) => return parse_and_print(&src), - Err(e) => { - eprintln!("stdin read error: {}", e); - return Ok(()); - } - } - } else { - let path = Path::new(&arg1); - match read_file_to_string(path) { - Ok(src) => return parse_and_print(&src), - Err(e) => { - eprintln!("file read error ({}): {}", path.display(), e); - return Ok(()); - } - } - } - } - - // No filename provided -> keep REPL behavior - repl_once() -} diff --git a/dev_tests/src/uc_lexer_verify.rs b/dev_tests/src/uc_lexer_verify.rs index 30273c6..e451402 100644 --- a/dev_tests/src/uc_lexer_verify.rs +++ b/dev_tests/src/uc_lexer_verify.rs @@ -1,122 +1,356 @@ -use std::{collections::HashSet, fs, path::PathBuf}; +#![allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::restriction +)] -use rottlib::lexer::{DebugTools, TokenizedFile}; +use std::{ + collections::HashSet, + fs, + io::{self, Write}, + path::PathBuf, + time::Instant, +}; -/// Read `ignore.txt` (one path per line, `#` for comments) from root directory -/// and turn it into a canonicalized [`HashSet`]. +use encoding_rs::Encoding; +use rottlib::diagnostics::Diagnostic as Diag; +use rottlib::lexer::TokenizedFile; +use rottlib::parser::Parser; + +// ---------- CONFIG ---------- +const FILE_LIMIT: usize = 10000; // cap on files scanned +const DIAG_SHOW_FIRST: usize = 12; // show first N diagnostics +const DIAG_SHOW_LAST: usize = 12; // show last N diagnostics +/// If true, print the old debug struct dump after each pretty diagnostic. +const ALSO_PRINT_DEBUG_AFTER_PRETTY: bool = true; + +// Cargo.toml additions: +// is-terminal = "0.4" +// sysinfo = { version = "0.30", features = ["multithread"] } +// walkdir = "2" +// chardet = "0.2" +// encoding_rs = "0.8" + +fn render_diagnostic(diag: &Diag, file: &TokenizedFile<'_>, file_name: &str) { + diag.render(file, file_name); +} + +fn render_diagnostics_window(diags: &[Diag], tf: &TokenizedFile<'_>, file_name: &str) { + let total = diags.len(); + let first_n = DIAG_SHOW_FIRST.min(total); + let last_n = DIAG_SHOW_LAST.min(total.saturating_sub(first_n)); + + if total > first_n + last_n { + for (k, d) in diags.iter().take(first_n).enumerate() { + render_diagnostic(d, tf, file_name); + if ALSO_PRINT_DEBUG_AFTER_PRETTY { + eprintln!("#{}: {:#?}", k + 1, d); + } + } + + eprintln!("... {} diagnostics omitted ...", total - (first_n + last_n)); + + let start = total - last_n; + for (offset, d) in diags.iter().skip(start).enumerate() { + let idx_global = start + offset + 1; + render_diagnostic(d, tf, file_name); + if ALSO_PRINT_DEBUG_AFTER_PRETTY { + eprintln!("#{idx_global}: {d:#?}"); + } + } + } else { + for (k, d) in diags.iter().enumerate() { + render_diagnostic(d, tf, file_name); + if ALSO_PRINT_DEBUG_AFTER_PRETTY { + eprintln!("#{}: {:#?}", k + 1, d); + } + } + } +} + +// Linux-only accurate RSS in MB. Fallback uses sysinfo. +fn rss_mb() -> u64 { + #[cfg(target_os = "linux")] + { + use std::io::Read; + let mut s = String::new(); + if let Ok(mut f) = std::fs::File::open("/proc/self/statm") + && f.read_to_string(&mut s).is_ok() + && let Some(rss_pages) = s + .split_whitespace() + .nth(1) + .and_then(|x| x.parse::().ok()) + { + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) as u64 }; + return (rss_pages * page) / (1024 * 1024); + } + } + use sysinfo::{System, get_current_pid}; + let mut sys = System::new(); + sys.refresh_processes(); + let Ok(pid) = get_current_pid() else { return 0 }; + sys.process(pid).map_or(0, |p| p.memory() / 1024) +} + +fn mark(label: &str, t0: Instant) { + println!( + "[{:>14}] t={:>8.2?} rss={} MB", + label, + t0.elapsed(), + rss_mb() + ); +} + +/// Read `ignore.txt` next to `root` and build a canonicalized set. fn load_ignore_set(root: &std::path::Path) -> HashSet { let ignore_file = root.join("ignore.txt"); if !ignore_file.exists() { return HashSet::new(); } - let content = match fs::read_to_string(&ignore_file) { - Ok(content) => content, - Err(error) => { - eprintln!("Could not read {}: {error}", ignore_file.display()); + Ok(s) => s, + Err(e) => { + eprintln!("Could not read {}: {e}", ignore_file.display()); return HashSet::new(); } }; - content .lines() .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter(|l| !l.is_empty() && !l.starts_with('#')) .filter_map(|line| { - let next_path = PathBuf::from(line); - let absolute_path = if next_path.is_absolute() { - next_path - } else { - root.join(next_path) - }; - fs::canonicalize(absolute_path).ok() + let p = PathBuf::from(line); + let abs = if p.is_absolute() { p } else { root.join(p) }; + fs::canonicalize(abs).ok() }) .collect() } -/// CLI: `verify_uc ` - find all `.uc` files in the provided directory -/// (except those listed in `ignore.txt` in the root) and test them all. -/// -/// Reported execution time is the tokenization time, without considering time -/// it takes to read files from disk. -/// -/// `ignore.txt` is for listing specific files, not directories. -fn main() { - let root_dir = std::env::args().nth(1).unwrap(); // it is fine to crash debug utility - let root = PathBuf::from(&root_dir); +/// Wait for Enter if running in a TTY, shown before printing errors. +fn wait_before_errors(msg: &str) { + let _ = io::stdout().flush(); + if is_terminal::is_terminal(io::stdin()) { + eprint!("{msg}"); + let _ = io::stderr().flush(); + let mut s = String::new(); + let _ = io::stdin().read_line(&mut s); + } +} +/// CLI: `verify_uc [file_name]` +/// +fn main() { + let mut args = std::env::args().skip(1); + let root_dir = args.next().unwrap_or_else(|| { + eprintln!("Usage: verify_uc [file_name]"); + std::process::exit(1); + }); + + let target_raw = args.next(); // optional file name hint + let target_ci = target_raw.as_ref().map(|s| s.to_ascii_lowercase()); + let single_mode = target_ci.is_some(); + + let root = PathBuf::from(&root_dir); if !root.exists() { eprintln!("Root directory '{root_dir}' does not exist."); std::process::exit(1); } - // Load files - let ignored_paths = load_ignore_set(&root); + let t0 = Instant::now(); + mark("baseline", t0); + + // Stage 0: discover + read, bounded by FILE_LIMIT or first match in single_mode + let ignored = load_ignore_set(&root); let mut uc_files: Vec<(PathBuf, String)> = Vec::new(); + let mut seen = 0usize; + let mut picked_any = false; + for entry in walkdir::WalkDir::new(&root) .into_iter() - .filter_map(Result::ok) // for debug tool this is ok - .filter(|entry| { - let path = entry.path(); - // Skip anything explicitly ignored - if let Ok(absolute_path) = fs::canonicalize(path) { - if ignored_paths.contains(&absolute_path) { - return false; - } + .filter_map(Result::ok) + .filter(|e| { + let path = e.path(); + if let Ok(abs) = fs::canonicalize(path) + && ignored.contains(&abs) + { + return false; } - // Must be *.uc path.is_file() && path .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("uc")) + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("uc")) }) { + if !single_mode && seen >= FILE_LIMIT { + break; + } + + // If in single-file mode, keep only the first whose file name matches. + if let Some(needle) = target_ci.as_deref() { + let fname = entry + .path() + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let fname_lc = fname.to_ascii_lowercase(); + if !(fname_lc == needle || fname_lc.contains(needle)) { + continue; + } + } + + seen += 1; + let path = entry.path(); match fs::read(path) { - Ok(raw_bytes) => { - // Auto-detect encoding for old Unreal script sources - let (encoding_label, _, _) = chardet::detect(&raw_bytes); - let encoding = encoding_rs::Encoding::for_label(encoding_label.as_bytes()) - .unwrap_or(encoding_rs::UTF_8); - let (decoded_text, _, _) = encoding.decode(&raw_bytes); - uc_files.push((path.to_path_buf(), decoded_text.into_owned())); + Ok(raw) => { + let (label, _, _) = chardet::detect(&raw); + let enc = Encoding::for_label(label.as_bytes()).unwrap_or(encoding_rs::UTF_8); + let (txt, _, _) = enc.decode(&raw); + uc_files.push((path.to_path_buf(), txt.into_owned())); + picked_any = true; + if single_mode { + // Only the first match. + break; + } } - Err(error) => { - eprintln!("Failed to read `{}`: {error}", path.display()); + Err(e) => { + wait_before_errors("Read error detected. Press Enter to print details..."); + eprintln!("Failed to read `{}`: {e}", path.display()); std::process::exit(1); } } } - println!("Loaded {} .uc files into memory.", uc_files.len()); - // Tokenize and measure performance - let start_time = std::time::Instant::now(); - let tokenized_files: Vec<(PathBuf, TokenizedFile)> = uc_files - .iter() - .map(|(path, source_code)| { - let tokenized_file = TokenizedFile::from_str(source_code); - if tokenized_file.has_errors() { - println!("TK: {}", path.display()); - } - (path.clone(), tokenized_file) - }) - .collect(); - let elapsed_time = start_time.elapsed(); + if single_mode && !picked_any { + let needle = target_raw.as_deref().unwrap(); + eprintln!( + "No .uc file matching '{needle}' found under '{}'.", + root.display() + ); + std::process::exit(1); + } + + println!( + "Loaded {} .uc files into memory (cap={}, reached={}).", + uc_files.len(), + FILE_LIMIT, + if !single_mode && uc_files.len() >= FILE_LIMIT { + "yes" + } else { + "no" + } + ); + mark("after_read", t0); + + // Stage 1: tokenize all + let t_tok = Instant::now(); + let mut tokenized: Vec<(PathBuf, TokenizedFile)> = Vec::with_capacity(uc_files.len()); + let mut tk_error_idx: Option = None; + + for (i, (path, source)) in uc_files.iter().enumerate() { + let tf = TokenizedFile::tokenize(source); + if tk_error_idx.is_none() && tf.has_errors() { + tk_error_idx = Some(i); + } + tokenized.push((path.clone(), tf)); + } println!( "Tokenized {} files in {:.2?}", - tokenized_files.len(), - elapsed_time + tokenized.len(), + t_tok.elapsed() ); + mark("after_tokenize", t0); - // Round-trip check - for ((path, original), (_, tokenized_file)) in uc_files.iter().zip(tokenized_files.iter()) { - let reconstructed = tokenized_file.reconstruct_source(); - if original != &reconstructed { - eprintln!("Reconstruction mismatch in `{}`!", path.display()); - std::process::exit(1); + // If tokenization error: wait, dump tokens for the first failing file, then exit. + // If tokenization error: wait, print lexer diagnostics for the first failing file, then exit. + if let Some(idx) = tk_error_idx { + let (bad_path, bad_tf) = &tokenized[idx]; + wait_before_errors("Tokenization issues detected. Press Enter to print diagnostics..."); + + let fname = bad_path.display().to_string(); + + eprintln!("--- Tokenization issues in first failing file ---"); + eprintln!("File: {fname}"); + + let diags = bad_tf.diagnostics(); + + if diags.is_empty() { + eprintln!("(no diagnostics captured)"); + } else { + render_diagnostics_window(diags, bad_tf, &fname); + } + + std::process::exit(1); + } + + // Stage 2: parse all with ONE arena kept alive + let arena = rottlib::arena::Arena::new(); + let t_parse = Instant::now(); + + // First failing parse: (tokenized_index, diagnostics, fatal) + let mut first_fail: Option<(usize, Vec, Option)> = None; + + for (i, (path, tk)) in tokenized.iter().enumerate() { + // --- progress line BEFORE parsing this file --- + { + use std::io::Write; + eprint!( + "Parsing [{}/{}] {} | rss={} MB\r\n", + i + 1, + tokenized.len(), + path.display(), + rss_mb() + ); + let _ = io::stderr().flush(); + } + + let mut parser = Parser::new(tk, &arena); + + match parser.parse_source_file() { + Ok(_) => { + if !parser.diagnostics.is_empty() && first_fail.is_none() { + first_fail = Some((i, parser.diagnostics.clone(), None)); + } + } + Err(e) => { + if first_fail.is_none() { + first_fail = Some((i, parser.diagnostics.clone(), Some(format!("{e:?}")))); + } + } } } - println!("All .uc files matched successfully."); + println!( + "Parsed {} files in {:.2?}", + tokenized.len(), + t_parse.elapsed() + ); + mark("after_parse", t0); + + // Summary + println!("--- Summary ---"); + println!("Files processed: {}", tokenized.len()); + println!("File cap: {FILE_LIMIT}"); + + if let Some((idx, diags, fatal)) = first_fail { + wait_before_errors("Parse issues detected. Press Enter to print diagnostics..."); + let (path, tf) = &tokenized[idx]; + eprintln!("--- Parse issues in first failing file ---"); + eprintln!("File: {}", path.display()); + if let Some(f) = &fatal { + eprintln!("Fatal parse error: {f}"); + } + if diags.is_empty() && fatal.is_none() { + eprintln!("(no diagnostics captured)"); + } else { + let fname = path.display().to_string(); + render_diagnostics_window(&diags, tf, &fname); + } + std::process::exit(1); + } + + println!("All files parsed without diagnostics."); } diff --git a/dev_tests/src/verify_expr.rs b/dev_tests/src/verify_expr.rs new file mode 100644 index 0000000..e1c2f19 --- /dev/null +++ b/dev_tests/src/verify_expr.rs @@ -0,0 +1,303 @@ +#![allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::restriction +)] + +use rottlib::arena::Arena; +use rottlib::diagnostics::Diagnostic; +use rottlib::lexer::TokenizedFile; +use rottlib::parser::Parser; + +#[derive(Clone, Copy, Debug)] +enum ParseMode { + Expression, + SourceFile, +} + +const PARSE_MODE: ParseMode = ParseMode::SourceFile; + +const TEST_CASES: &[(&str, &str)] = &[ + // P0057 - VarEditorSpecifierExpected + ( + "files/P0057_01.uc", + concat!("class Test extends Actor;\n", "var(Category,) int A;\n"), + ), + ( + "files/P0057_02.uc", + concat!("class Test extends Actor;\n", "var(,) int A;\n"), + ), + ( + "files/P0057_03.uc", + concat!( + "class Test extends Actor;\n", + "var(\n ,\n ,\n Category\n) int A;\n" + ), + ), + ( + "files/P0057_04.uc", + concat!( + "class Test extends Actor;\n", + "var(\n Category,\n\n\n) int A;\n" + ), + ), + + // P0058 - VarEditorSpecifierListMissingSeparator + ( + "files/P0058_01.uc", + concat!( + "class Test extends Actor;\n", + "var(Category DisplayName) int A;\n" + ), + ), + ( + "files/P0058_02.uc", + concat!( + "class Test extends Actor;\n", + "var(\"Display\" \"Tooltip\") int A;\n" + ), + ), + ( + "files/P0058_03.uc", + concat!( + "class Test extends Actor;\n", + "var(\n Category\n DisplayName\n) int A;\n" + ), + ), + ( + "files/P0058_04.uc", + concat!( + "class Test extends Actor;\n", + "var(\n Category\n\n \"Display Name\",\n Advanced\n) int A;\n" + ), + ), + + // P0059 - VarEditorSpecifierListExpectedCommaOrClosingParenthesis + ( + "files/P0059_01.uc", + concat!("class Test extends Actor;\n", "var(Category :) int A;\n"), + ), + ( + "files/P0059_02.uc", + concat!( + "class Test extends Actor;\n", + "var(\"Display\" *) int A;\n" + ), + ), + ( + "files/P0059_03.uc", + concat!( + "class Test extends Actor;\n", + "var(\n Category\n [\n) int A;\n" + ), + ), + ( + "files/P0059_04.uc", + concat!( + "class Test extends Actor;\n", + "var(\n \"Display Name\"\n\n ]\n) int A;\n" + ), + ), + + // P0060 - VarEditorSpecifierListMissingClosingParenthesis + ( + "files/P0060_01.uc", + concat!("class Test extends Actor;\n", "var(Category\n"), + ), + ( + "files/P0060_02.uc", + concat!( + "class Test extends Actor;\n", + "var(\n Category,\n \"Display Name\"\n" + ), + ), + ( + "files/P0060_03.uc", + concat!("class Test extends Actor;\n", "var(\n\n\n"), + ), + ( + "files/P0060_04.uc", + concat!( + "class Test extends Actor;\n", + "var(\n Category,\n\n \"Display Name\"\n\n" + ), + ), +]; + +/// Lexer-focused fixtures. +/// +/// Keep these small: the goal is to inspect lexer diagnostics and delimiter +/// recovery behavior, not full parser behavior. +/*const TEST_CASES: &[(&str, &str)] = &[ + // P0061 - TypeSpecifierExpected + ("files/P0061_01.uc", "{\n local ;\n}\n"), + ( + "files/P0061_02.uc", + "{\n local\n ;\n}\n", + ), + ( + "files/P0061_03.uc", + "{\n local\n [\n ] Values;\n}\n", + ), + ( + "files/P0061_04.uc", + "{\n local\n", + ), + + // P0062 - NamedTypeInvalidName + ("files/P0062_01.uc", "{\n local Foo. ;\n}\n"), + ( + "files/P0062_02.uc", + "{\n local\n Foo.\n ;\n}\n", + ), + ( + "files/P0062_03.uc", + "{\n local Foo..Bar Value;\n}\n", + ), + ( + "files/P0062_04.uc", + "{\n local\n SomePackage\n .\n", + ), + + // P0063 - ArrayTypeMissingOpeningAngleBracket + ("files/P0063_01.uc", "{\n local array Values;\n}\n"), + ( + "files/P0063_02.uc", + "{\n local\n array\n Values;\n}\n", + ), + ("files/P0063_03.uc", "{\n local array[] Values;\n}\n"), + ( + "files/P0063_04.uc", + "{\n local\n array", + ), + + // P0064 - ArrayTypeMissingClosingAngleBracket + ("files/P0064_01.uc", "{\n local array ActorClasses;\n}\n", + ), + ( + "files/P0064_04.uc", + "{\n local\n array<\n string", + ), + + // P0065 - ArrayTypeMissingElementType + ("files/P0065_01.uc", "{\n local array<> Values;\n}\n"), + ( + "files/P0065_02.uc", + "{\n local\n array<>\n Values;\n}\n", + ), + ( + "files/P0065_03.uc", + "{\n local array<\n > Values;\n}\n", + ), + ( + "files/P0065_04.uc", + "{\n local array > NestedValues;\n}\n", + ), +];*/ + +/// If true, also run the parser after tokenization. +/// +/// For lexer-focused fixtures this is usually noisy, so keep it off unless you +/// want to inspect how parser recovery behaves after lexer diagnostics. +const RUN_PARSER: bool = true; + +/// If true, print the parsed expression using Debug formatting. +const PRINT_PARSED_EXPR: bool = false; + +const PRINT_LEXER_DIAGNOSTICS: bool = false; +const ALWAYS_PRINT_DIAGNOSTICS: bool = true; + +fn main() { + let arena = Arena::new(); + + let mut had_any_problem = false; + + for (idx, (label, source)) in TEST_CASES.iter().enumerate() { + println!("============================================================"); + println!("Case {}: {}", idx + 1, label); + println!("------------------------------------------------------------"); + + let tf = TokenizedFile::tokenize(source); + + let lexer_diagnostics = tf.diagnostics(); + + if lexer_diagnostics.is_empty() { + println!("Lexer diagnostics: none"); + } else { + had_any_problem = true; + + if PRINT_LEXER_DIAGNOSTICS { + println!("Lexer diagnostics:"); + for diag in lexer_diagnostics { + render_diagnostic(diag, &tf, Some(label), false); + } + } + } + + if RUN_PARSER { + let mut parser = Parser::new(&tf, &arena); + + match PARSE_MODE { + ParseMode::Expression => { + let expr = parser.parse_expression(); + + if PRINT_PARSED_EXPR { + println!("Parsed expression:"); + println!("{expr:#?}"); + } + } + ParseMode::SourceFile => { + if let Err(error) = parser.parse_source_file() { + parser.report_error(error); + } + + if PRINT_PARSED_EXPR { + println!("Parsed source file: "); + } + } + } + + if parser.diagnostics.is_empty() { + println!("Parser diagnostics: none"); + } else { + had_any_problem = true; + + if ALWAYS_PRINT_DIAGNOSTICS { + println!("Parser diagnostics:"); + for diag in &parser.diagnostics { + render_diagnostic(diag, &tf, Some(label), false); + } + } + } + } + + println!(); + } + + println!("============================================================"); + + if had_any_problem { + println!("Done. At least one case had lexer or parser diagnostics."); + std::process::exit(1); + } else { + println!("Done. All cases completed without diagnostics."); + } +} + +fn render_diagnostic( + diag: &Diagnostic, + file: &TokenizedFile<'_>, + file_name: Option<&str>, + _colors: bool, +) { + diag.render(file, file_name.unwrap_or("")); +} \ No newline at end of file diff --git a/kf_sources/Acedia/Classes/BaseGameMode.uc b/kf_sources/Acedia/Classes/BaseGameMode.uc new file mode 100644 index 0000000..6bfd5f9 --- /dev/null +++ b/kf_sources/Acedia/Classes/BaseGameMode.uc @@ -0,0 +1,413 @@ +/** + * Base class for a game mode config, contains all the information Acedia's + * game modes must have, including settings + * (`includeFeature`, `includeFeatureAs` and `excludeFeature`) + * for picking used `Feature`s. + * + * Contains three types of methods: + * 1. Getters for its values; + * 2. `UpdateFeatureArray()` method for updating list of `Feature`s to + * be used based on game info's settings; + * 3. `Report...()` methods that perform various validation checks + * (and log them) on config data. + * Copyright 2021-2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class BaseGameMode extends AcediaConfig + dependson(Packages) + abstract; + +// Name of the game mode players will see in voting (formatted string) +var protected config string title; +// Preferable game length (plain string) +var protected config string length; +// Preferable difficulty level (plain string) +var protected config string difficulty; +// `Mutator`s to add with this game mode +var protected config array includeMutator; +// `Feature`s to include (with "default" config) +var protected config array includeFeature; +// `Feature`s to exclude from game mode, regardless of other settings +// (this one has highest priority) +var protected config array excludeFeature; + +struct FeatureConfigPair +{ + var public string feature; + var public string config; +}; +// `Feature`s to include (with specified config). +// Higher priority than `includeFeature`, but lower than `excludeFeature`. +var protected config array includeFeatureAs; + +var private LoggerAPI.Definition warnBadMutatorName, warnBadFeatureName; + +protected function HashTable ToData() +{ + local int i; + local HashTable result; + local HashTable nextPair; + local ArrayList nextArray; + + result = _.collections.EmptyHashTable(); + result.SetFormattedString(P("title"), title); + result.SetString(P("length"), length); + result.SetString(P("difficulty"), difficulty); + nextArray = _.collections.EmptyArrayList(); + for (i = 0; i < includeFeature.length; i += 1) { + nextArray.AddString(includeFeature[i]); + } + result.SetItem(P("includeFeature"), nextArray); + _.memory.Free(nextArray); + nextArray = _.collections.EmptyArrayList(); + for (i = 0; i < excludeFeature.length; i += 1) { + nextArray.AddItem(_.text.FromString(excludeFeature[i])); + } + result.SetItem(P("excludeFeature"), nextArray); + _.memory.Free(nextArray); + nextArray = _.collections.EmptyArrayList(); + for (i = 0; i < includeMutator.length; i += 1) { + nextArray.AddItem(_.text.FromString(includeFeature[i])); + } + result.SetItem(P("includeMutator"), nextArray); + _.memory.Free(nextArray); + nextArray = _.collections.EmptyArrayList(); + for (i = 0; i < includeFeatureAs.length; i += 1) + { + nextPair = _.collections.EmptyHashTable(); + nextPair.SetString(P("feature"), includeFeatureAs[i].feature); + nextPair.SetString(P("config"), includeFeatureAs[i].config); + nextArray.AddItem(nextPair); + _.memory.Free(nextPair); + } + result.SetItem(P("includeFeatureAs"), nextArray); + _.memory.Free(nextArray); + return result; +} + +protected function FromData(HashTable source) +{ + local int i; + local ArrayList nextArray; + local HashTable nextPair; + + if (source == none) { + return; + } + title = source.GetFormattedString(P("title")); + length = source.GetString(P("length")); + difficulty = source.GetString(P("difficulty")); + nextArray = source.GetArrayList(P("includeFeature")); + includeFeature = DynamicIntoStringArray(nextArray); + _.memory.Free(nextArray); + nextArray = source.GetArrayList(P("excludeFeature")); + excludeFeature = DynamicIntoStringArray(nextArray); + _.memory.Free(nextArray); + nextArray = source.GetArrayList(P("includeMutator")); + includeMutator = DynamicIntoStringArray(nextArray); + _.memory.Free(nextArray); + nextArray = source.GetArrayList(P("includeFeatureAs")); + if (nextArray == none) { + return; + } + includeFeatureAs.length = 0; + for (i = 0; i < nextArray.GetLength(); i += 1) + { + nextPair = nextArray.GetHashTable(i); + includeFeatureAs[i] = HashTableIntoPair(nextPair); + _.memory.Free(nextPair); + } + _.memory.Free(nextArray); +} + +private final function FeatureConfigPair HashTableIntoPair(HashTable source) +{ + local Text nextText; + local FeatureConfigPair result; + + if (source == none) { + return result; + } + nextText = source.GetText(P("feature")); + if (nextText != none) { + result.feature = nextText.ToString(); + } + nextText = source.GetText(P("config")); + if (nextText != none) { + result.config = nextText.ToString(); + } + return result; +} + +private final function array DynamicIntoStringArray(ArrayList source) +{ + local int i; + local Text nextText; + local array result; + + if (source == none) { + return result; + } + for (i = 0; i < source.GetLength(); i += 1) + { + nextText = source.GetText(i); + if (nextText != none) { + includeFeature[i] = nextText.ToString(); + } + } +} + +protected function array StringToTextArray(array input) +{ + local int i; + local array result; + + for (i = 0; i < input.length; i += 1) { + result[i] = _.text.FromString(input[i]); + } + return result; +} + +/** + * @return Name of the `GameInfo` class to be used with the caller game mode. + */ +public function Text GetGameTypeClass() +{ + return none; +} + +/** + * @return Human-readable name of the caller game mode. + * Players will see it as the name of the mode in the voting options. + */ +public function Text GetTitle() +{ + return _.text.FromFormattedString(title); +} + +/** + * @return Specified game length for the game mode. + * Interpretation of this value can depend on each particular game mode. + */ +public function Text GetLength() +{ + return _.text.FromString(length); +} + +/** + * @return Specified difficulty for the game mode. + * Interpretation of this value can depend on each particular game mode. + */ +public function Text GetDifficulty() +{ + return _.text.FromString(difficulty); +} + +/** + * Checks `Feature`-related settings (`includeFeature`, `includeFeatureAs` and + * `excludeFeature`) for correctness and reports any issues. + * Currently correctness check simply ensures that all listed `Feature`s + * actually exist. + */ +public function ReportIncorrectSettings( + array featuresToEnable) +{ + local int i; + local array featureNames, featuresToReplace; + + for (i = 0; i < featuresToEnable.length; i += 1) { + featureNames[i] = string(featuresToEnable[i].featureClass); + } + ValidateFeatureArray(includeFeature, featureNames, "includeFeatures"); + ValidateFeatureArray(excludeFeature, featureNames, "excludeFeatures"); + for (i = 0; i < includeFeatureAs.length; i += 1) { + featuresToReplace[i] = includeFeatureAs[i].feature; + } + ValidateFeatureArray(featuresToReplace, featureNames, "includeFeatureAs"); +} + +/** + * Checks `Mutator`-related settings (`includeMutator`) for correctness and + * reports any issues. + * Currently correctness check performs a simple validity check for mutator, + * to make sure it would not define a new option in server's URL. + * + * See `ValidateServerURLName()` for more information. + */ +public function ReportBadMutatorNames() +{ + local int i; + + for (i = 0; i < includeMutator.length; i += 1) + { + if (!ValidateServerURLName(includeMutator[i])) + { + _.logger.Auto(warnBadMutatorName) + .Arg(_.text.FromString(includeMutator[i])) + .Arg(_.text.FromString(string(name))); + } + } +} + +/** + * Makes sure that a word to be used in server URL as a part of an option + * does not contain "," / "?" / "=" or whitespace. + * This is useful to make sure that user-specified mutator entries only add + * one mutator or option's key / values will not specify only one pair, + * avoiding "?opt1=value1?opt2=value2" entries. + */ +protected function bool ValidateServerURLName(string entry) +{ + if (InStr(entry, "=") >= 0) return false; + if (InStr(entry, "?") >= 0) return false; + if (InStr(entry, ",") >= 0) return false; + if (InStr(entry, " ") >= 0) return false; + return true; +} + +// Is every element `subset` present inside `whole`? +private function ValidateFeatureArray( + array subset, + array whole, + string arrayName) +{ + local int i, j; + local bool foundItem; + + for (i = 0; i < subset.length; i += 1) + { + foundItem = false; + for (j = 0; j < whole.length; j += 1) + { + if (subset[i] ~= whole[j]) + { + foundItem = true; + break; + } + } + if (!foundItem) + { + _.logger.Auto(warnBadFeatureName) + .Arg(_.text.FromString(includeMutator[i])) + .Arg(_.text.FromString(string(name))) + .Arg(_.text.FromString(arrayName)); + } + } +} + +/** + * Updates passed `Feature` settings according to this game mode's settings. + * + * @param featuresToEnable Settings to update. + * `FeatureConfigPair` is a pair of `Feature` (`featureClass`) and its + * config's name (`configName`). + * If `configName` is set to `none`, then corresponding `Feature` + * should not be enabled. + * Otherwise it should be enabled with a specified config. + */ +public function UpdateFeatureArray( + out array featuresToEnable) +{ + local int i; + local Text newConfigName; + local string nextFeatureClassName; + + for (i = 0; i < featuresToEnable.length; i += 1) + { + nextFeatureClassName = string(featuresToEnable[i].featureClass); + // `excludeFeature` + if (FeatureExcluded(nextFeatureClassName)) + { + _.memory.Free(featuresToEnable[i].configName); + featuresToEnable[i].configName = none; + continue; + } + // `includeFeatureAs` + newConfigName = TryReplacingFeatureConfig(nextFeatureClassName); + if (newConfigName != none) + { + _.memory.Free(featuresToEnable[i].configName); + featuresToEnable[i].configName = newConfigName; + } + // `includeFeature` + if ( featuresToEnable[i].configName == none + && FeatureInIncludedArray(nextFeatureClassName)) + { + featuresToEnable[i].configName = P("default").Copy(); + } + } +} + +private function bool FeatureExcluded(string featureClassName) +{ + local int i; + + for (i = 0; i < excludeFeature.length; i += 1) + { + if (excludeFeature[i] ~= featureClassName) { + return true; + } + } + return false; +} + +private function Text TryReplacingFeatureConfig(string featureClassName) +{ + local int i; + + for (i = 0; i < includeFeatureAs.length; i += 1) + { + if (includeFeatureAs[i].feature ~= featureClassName) { + return _.text.FromString(includeFeatureAs[i].config); + } + } + return none; +} + +private function bool FeatureInIncludedArray(string featureClassName) +{ + local int i; + + for (i = 0; i < includeFeature.length; i += 1) + { + if (includeFeature[i] ~= featureClassName) { + return true; + } + } + return false; +} + +public function array GetIncludedMutators() +{ + local int i; + local array validatedMutators; + + for (i = 0; i < includeMutator.length; i += 1) + { + if (ValidateServerURLName(includeMutator[i])) { + validatedMutators[validatedMutators.length] = includeMutator[i]; + } + } + return StringToTextArray(validatedMutators); +} + +defaultproperties +{ + configName = "AcediaGameModes" + warnBadMutatorName = (l=LOG_Warning,m="Mutator \"%1\" specified for game mode \"%2\" contains invalid characters and will be ignored. This is a configuration error, you should fix it.") + warnBadFeatureName = (l=LOG_Warning,m="Feature \"%1\" specified for game mode \"%2\" in array `%3` does not exist in enabled packages and will be ignored. This is a configuration error, you should fix it.") +} \ No newline at end of file diff --git a/kf_sources/Acedia/Classes/GameMode.uc b/kf_sources/Acedia/Classes/GameMode.uc new file mode 100644 index 0000000..43148c5 --- /dev/null +++ b/kf_sources/Acedia/Classes/GameMode.uc @@ -0,0 +1,280 @@ +/** + * The only implementation for `BaseGameMode` suitable for standard + * killing floor game types. + * Copyright 2021-2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class GameMode extends BaseGameMode + perobjectconfig + config(AcediaGameModes); + +struct GameOption +{ + var public string key; + var public string value; +}; +// Allow to specify additional server options for this game mode +var protected config array option; +// Specify `GameInfo`'s class to use, default is "KFMod.KFGameType" +// (plain string) +var protected config string gameTypeClass; +// Short version of the name of the game mode players will see in +// voting handler messages sometimes (plain string) +var protected config string acronym; +// Map prefix - only maps that start with specified prefix will be voteable for +// this game mode (plain string) +var protected config string mapPrefix; + +// Aliases are an unnecessary overkill for difficulty names, so just define +// them in special `string` arrays. +// We accept not just these exact words, but any of their prefixes. +var private const array beginnerSynonyms; +var private const array normalSynonyms; +var private const array hardSynonyms; +var private const array suicidalSynonyms; +var private const array hoeSynonyms; + +var private LoggerAPI.Definition warnBadOption, warnDifficultyOption; + +protected function DefaultIt() +{ + title = "Acedia game mode"; + length = "long"; + difficulty = "Hell On Earth"; + gameTypeClass = "KFMod.KFGameType"; + acronym = ""; + mapPrefix = "KF"; + includeFeature.length = 0; + excludeFeature.length = 0; + includeMutator.length = 0; + option.length = 0; +} + +protected function HashTable ToData() +{ + local int i; + local ArrayList nextArray; + local HashTable result, nextPair; + + result = super.ToData(); + if (result == none) { + return none; + } + result.SetString(P("gameTypeClass"), gameTypeClass); + result.SetString(P("acronym"), acronym); + result.SetString(P("mapPrefix"), mapPrefix); + nextArray = _.collections.EmptyArrayList(); + for (i = 0; i < option.length; i += 1) + { + nextPair = _.collections.EmptyHashTable(); + nextPair.SetString(P("key"), option[i].key); + nextPair.SetString(P("value"), option[i].value); + nextArray.AddItem(nextPair); + _.memory.Free(nextPair); + } + result.SetItem(P("option"), nextArray); + _.memory.Free(nextArray); + return result; +} + +protected function FromData(HashTable source) +{ + local int i; + local GameOption nextGameOption; + local ArrayList nextArray; + local HashTable nextPair; + + super.FromData(source); + if (source == none) { + return; + } + gameTypeClass = source.GetString(P("gameTypeClass")); + acronym = source.GetString(P("acronym")); + mapPrefix = source.GetString(P("mapPrefix")); + nextArray = source.GetArrayList(P("option")); + if (nextArray == none) { + return; + } + option.length = 0; + for (i = 0; i < nextArray.GetLength(); i += 1) + { + nextPair = HashTable(nextArray.GetItem(i)); + if (nextPair == none) { + continue; + } + nextGameOption.key = nextPair.GetString(P("key")); + nextGameOption.value = nextPair.GetString(P("value")); + option[option.length] = nextGameOption; + _.memory.Free(nextPair); + } + _.memory.Free(nextArray); +} + +public function Text GetGameTypeClass() +{ + if (gameTypeClass == "") { + return P("KFMod.KFGameType").Copy(); + } + else { + return _.text.FromString(gameTypeClass); + } +} + +public function Text GetAcronym() +{ + if (acronym == "") { + return _.text.FromString(string(name)); + } + else { + return _.text.FromFormattedString(acronym); + } +} + +public function Text GetMapPrefix() +{ + if (acronym == "") { + return _.text.FromString("KF-"); + } + else { + return _.text.FromString(mapPrefix); + } +} + +/** + * Checks option-related settings (`option`) for correctness and reports + * any issues. + * Currently correctness check performs a simple validity check for mutator, + * to make sure it would not define a new option in server's URL. + * + * See `ValidateServerURLName()` in `BaseGameMode` for more information. + */ +public function ReportBadOptions() +{ + local int i; + + for (i = 0; i < option.length; i += 1) + { + if ( !ValidateServerURLName(option[i].key) + || !ValidateServerURLName(option[i].value)) + { + _.logger.Auto(warnBadOption) + .Arg(_.text.FromString(option[i].key)) + .Arg(_.text.FromString(option[i].value)) + .Arg(_.text.FromString(string(name))); + } + } +} + +/** + * @return Server options as key-value pairs in an `HashTable`. + */ +public function HashTable GetOptions() +{ + local int i; + local HashTable result; + local Text nextKey, nextValue; + + result = _.collections.EmptyHashTable(); + for (i = 0; i < option.length; i += 1) + { + if (!ValidateServerURLName(option[i].key)) continue; + if (!ValidateServerURLName(option[i].value)) continue; + if (option[i].key ~= "difficulty") + { + _.logger.Auto(warnDifficultyOption); + continue; + } + nextKey = _.text.FromString(option[i].key); + nextValue = _.text.FromString(option[i].value); + result.SetItem(nextKey, nextValue); + nextKey.FreeSelf(); + nextValue.FreeSelf(); + } + // Add difficulty option + nextValue = _.text.FromInt(GetNumericDifficulty()); + result.SetItem(P("difficulty"), nextValue); + nextValue.FreeSelf(); + return result; +} + +// Convert `GameMode`'s difficulty's textual representation into +// KF's numeric one. +private final function int GetNumericDifficulty() +{ + local int i; + local string lowerCaseDifficulty; + + lowerCaseDifficulty = Locs(_.text.IntoString(GetDifficulty())); + for (i = 0; i < default.beginnerSynonyms.length; i += 1) + { + if (IsPrefixOf(lowerCaseDifficulty, default.beginnerSynonyms[i])) { + return 1; + } + } + for (i = 0; i < default.normalSynonyms.length; i += 1) + { + if (IsPrefixOf(lowerCaseDifficulty, default.normalSynonyms[i])) { + return 2; + } + } + for (i = 0; i < default.hardSynonyms.length; i += 1) + { + if (IsPrefixOf(lowerCaseDifficulty, default.hardSynonyms[i])) { + return 4; + } + } + for (i = 0; i < default.suicidalSynonyms.length; i += 1) + { + if (IsPrefixOf(lowerCaseDifficulty, default.suicidalSynonyms[i])) { + return 5; + } + } + for (i = 0; i < default.hoeSynonyms.length; i += 1) + { + if (IsPrefixOf(lowerCaseDifficulty, default.hoeSynonyms[i])) { + return 7; + } + } + return int(lowerCaseDifficulty); +} + +protected final static function bool IsPrefixOf(string prefix, string value) +{ + return (InStr(value, prefix) == 0); +} + +defaultproperties +{ + configName = "AcediaGameModes" + beginnerSynonyms(0) = "easy" + beginnerSynonyms(1) = "beginer" + beginnerSynonyms(2) = "beginner" + beginnerSynonyms(3) = "begginer" + beginnerSynonyms(4) = "begginner" + normalSynonyms(0) = "regular" + normalSynonyms(1) = "default" + normalSynonyms(2) = "normal" + hardSynonyms(0) = "harder" // "hard" is prefix of this, so it will count + hardSynonyms(1) = "difficult" + suicidalSynonyms(0) = "suicidal" + hoeSynonyms(0) = "hellonearth" + hoeSynonyms(1) = "hellon earth" + hoeSynonyms(2) = "hell onearth" + hoeSynonyms(3) = "hoe" + warnBadOption = (l=LOG_Warning,m="Option with key \"%1\" and value \"%2\" specified for game mode \"%3\" contains invalid characters and will be ignored. This is a configuration error, you should fix it.") + warnDifficultyOption = (l=LOG_Warning,m="Option with key \"Difficulty\" is specified. This key reserved and will be ignored. Difficulty value should be set through the game mode's \"Difficulty\" setting in \"AcediaGameModes.ini\" config. This is a configuration error, you should fix it.") +} \ No newline at end of file diff --git a/kf_sources/Acedia/Classes/Packages.uc b/kf_sources/Acedia/Classes/Packages.uc new file mode 100644 index 0000000..9eb56a6 --- /dev/null +++ b/kf_sources/Acedia/Classes/Packages.uc @@ -0,0 +1,274 @@ +/** + * Main and only Acedia mutator used for loading Acedia packages + * and providing access to mutator events' calls. + * Name is chosen to make config files more readable. + * Copyright 2020-2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class Packages extends Mutator + config(Acedia); + +// Acedia's reference to a `Global` object. +var private Global _; +var private ServerGlobal _server; +var private ClientGlobal _client; + +// Load Acedia on the client as well? +var private config bool clientside; +// Array of predefined services that must be started along with Acedia mutator. +var private config array package; +// Set to `true` to activate Acedia's game modes system +var private config bool useGameModes; +// Responsible for setting up Acedia's game modes in current voting system +var VotingHandlerAdapter votingAdapter; + +var Mutator_OnMutate_Signal onMutateSignal; +var Mutator_OnModifyLogin_Signal onModifyLoginSignal; +var Mutator_OnCheckReplacement_Signal onCheckReplacementSignal; + +var private LoggerAPI.Definition infoFeatureEnabled; +var private LoggerAPI.Definition errNoServerLevelCore, errorCannotRunTests; + +struct FeatureConfigPair +{ + var public class featureClass; + var public Text configName; +}; + +// "Constructor" +simulated function PreBeginPlay() +{ + if (level.netMode == NM_DedicatedServer) { + InitializeServer(); + } + else { + InitializeClient(); + } +} + +private simulated function InitializeClient() +{ + _ = class'Global'.static.GetInstance(); + class'ClientLevelCore'.static.CreateLevelCore(self); +} + +private function InitializeServer() +{ + local int i; + local LevelCore serverCore; + local GameMode currentGameMode; + local array availableFeatures; + + if (clientside) { + AddToPackageMap("Acedia"); + } + CheckForGarbage(); + // Launch and setup core Acedia + _ = class'Global'.static.GetInstance(); + _server = class'ServerGlobal'.static.GetInstance(); + _client = class'ClientGlobal'.static.GetInstance(); + serverCore = class'ServerLevelCore'.static.CreateLevelCore(self); + for (i = 0; i < package.length; i += 1) { + _.environment.RegisterPackage_S(package[i]); + } + if (serverCore != none) { + _server.ConnectServerLevelCore(); + } + else + { + _.logger.Auto(errNoServerLevelCore); + return; + } + if (class'TestingService'.default.runTestsOnStartUp) { + RunStartUpTests(); + } + SetupMutatorSignals(); + // Determine required features and launch them + availableFeatures = GetAutoConfigurationInfo(); + if (useGameModes) + { + votingAdapter = VotingHandlerAdapter( + _.memory.Allocate(class'VotingHandlerAdapter')); + currentGameMode = votingAdapter.SetupGameModeAfterTravel(); + if (currentGameMode != none) { + currentGameMode.UpdateFeatureArray(availableFeatures); + } + } + EnableFeatures(availableFeatures); + if (votingAdapter != none) { + votingAdapter.InjectIntoVotingHandler(); + } +} + +// "Finalizer" +function ServerTraveling(string URL, bool bItems) +{ + if (votingAdapter != none) + { + votingAdapter.PrepareForServerTravel(); + votingAdapter.RestoreVotingHandlerConfigBackup(); + _.memory.Free(votingAdapter); + votingAdapter = none; + } + _.environment.ShutDown(); + if (nextMutator != none) { + nextMutator.ServerTraveling(URL, bItems); + } + Destroy(); +} + +// Checks whether Acedia has left garbage after the previous map. +// This can lead to serious problems, so such diagnostic check is warranted. +private function CheckForGarbage() +{ + local int leftoverObjectAmount; + local int leftoverActorAmount; + local int leftoverDBRAmount; + local AcediaObject nextObject; + local AcediaActor nextActor; + local DBRecord nextRecord; + + foreach AllObjects(class'AcediaObject', nextObject) { + leftoverObjectAmount += 1; + } + foreach AllActors(class'AcediaActor', nextActor) { + leftoverActorAmount += 1; + } + foreach AllObjects(class'DBRecord', nextRecord) { + leftoverDBRAmount += 1; + } + if ( leftoverObjectAmount == 0 && leftoverActorAmount == 0 + && leftoverDBRAmount == 0) + { + Log("Acedia garbage check: nothing was found."); + } + else + { + Log("Acedia garbage check: garbage was found." @ + "This can cause problems, report it."); + Log("Leftover object:" @ leftoverObjectAmount); + Log("Leftover actors:" @ leftoverActorAmount); + Log("Leftover database records:" @ leftoverDBRAmount); + } +} + +public final function array GetAutoConfigurationInfo() +{ + local int i; + local array< class > availableFeatures; + local FeatureConfigPair nextPair; + local array result; + + availableFeatures = _.environment.GetAvailableFeatures(); + for (i = 0; i < availableFeatures.length; i += 1) + { + nextPair.featureClass = availableFeatures[i]; + nextPair.configName = availableFeatures[i].static + .GetAutoEnabledConfig(); + result[result.length] = nextPair; + } + return result; +} + +private function EnableFeatures(array features) +{ + local int i; + + for (i = 0; i < features.length; i += 1) + { + if (features[i].featureClass == none) continue; + if (features[i].configName == none) continue; + features[i].featureClass.static.EnableMe(features[i].configName); + _.logger.Auto(infoFeatureEnabled) + .Arg(_.text.FromString(string(features[i].featureClass))) + .Arg(features[i].configName); // consumes `configName` + } +} + +// Fetches and sets up signals that `Mutator` needs to provide +private function SetupMutatorSignals() +{ + local ServerUnrealService service; + + service = ServerUnrealService(class'ServerUnrealService'.static.Require()); + onMutateSignal = Mutator_OnMutate_Signal( + service.GetSignal(class'Mutator_OnMutate_Signal')); + onModifyLoginSignal = Mutator_OnModifyLogin_Signal( + service.GetSignal(class'Mutator_OnModifyLogin_Signal')); + onCheckReplacementSignal = Mutator_OnCheckReplacement_Signal( + service.GetSignal(class'Mutator_OnCheckReplacement_Signal')); +} + +private final function RunStartUpTests() +{ + local TestingService testService; + + testService = TestingService(class'TestingService'.static.Require()); + testService.PrepareTests(); + if (testService.filterTestsByName) { + testService.FilterByName(testService.requiredName); + } + if (testService.filterTestsByGroup) { + testService.FilterByGroup(testService.requiredGroup); + } + if (!testService.Run()) { + _.logger.Auto(errorCannotRunTests); + } +} + +/** + * Below `Mutator` events are redirected into appropriate signals. + */ +function bool CheckReplacement(Actor other, out byte isSuperRelevant) +{ + if (onCheckReplacementSignal != none) { + return onCheckReplacementSignal.Emit(other, isSuperRelevant); + } + return true; +} + +function Mutate(string command, PlayerController sendingController) +{ + if (onMutateSignal != none) { + onMutateSignal.Emit(command, sendingController); + } + super.Mutate(command, sendingController); +} + +function ModifyLogin(out string portal, out string options) +{ + if (onModifyLoginSignal != none) { + onModifyLoginSignal.Emit(portal, options); + } + super.ModifyLogin(portal, options); +} + +defaultproperties +{ + clientside = false + useGameModes = false + // This is a server-only mutator + remoteRole = ROLE_SimulatedProxy + bAlwaysRelevant = true + // Mutator description + GroupName = "Package loader" + FriendlyName = "Acedia loader" + Description = "Launcher for Acedia packages" + infoFeatureEnabled = (l=LOG_Info,m="Feature `%1` enabled with config \"%2\".") + errNoServerLevelCore = (l=LOG_Error,m="Cannot create `ServerLevelCore`!") + errorCannotRunTests = (l=LOG_Error,m="Could not perform Acedia's tests.") +} \ No newline at end of file diff --git a/kf_sources/Acedia/Classes/StartUp.uc b/kf_sources/Acedia/Classes/StartUp.uc new file mode 100644 index 0000000..bdeef6a --- /dev/null +++ b/kf_sources/Acedia/Classes/StartUp.uc @@ -0,0 +1,36 @@ +/** + * This actor's role is to add Acedia mutator on listen and dedicated servers. + * Copyright 2019-2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ + +class StartUp extends Actor; + +function PreBeginPlay() +{ + super.PreBeginPlay(); + if (level != none && level.game != none) { + level.game.AddMutator(string(class'Packages')); + } + Destroy(); +} + +defaultproperties +{ + // This is a server-only actor + remoteRole = ROLE_None +} \ No newline at end of file diff --git a/kf_sources/Acedia/Classes/VotingHandlerAdapter.uc b/kf_sources/Acedia/Classes/VotingHandlerAdapter.uc new file mode 100644 index 0000000..8e53fbd --- /dev/null +++ b/kf_sources/Acedia/Classes/VotingHandlerAdapter.uc @@ -0,0 +1,364 @@ +/** + * Acedia currently lacks its own means to provide a map/mode voting + * (and new voting mod with proper GUI would not be whitelisted anyway). + * This is why this class was made - to inject existing voting handlers with + * data from Acedia's game modes. + * Requires `GameInfo`'s voting handler to be derived from + * `XVotingHandler`, which is satisfied by pretty much every used handler. + * Copyright 2021-2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class VotingHandlerAdapter extends AcediaObject + dependson(VotingHandler) + config(AcediaLauncherData); + +/** + * All usage of this object should start with `InjectIntoVotingHandler()` + * method that will read all the `GameMode` configs and fill voting handler's + * config with their data, while making a backup of all values. + * Backup can be restored with `RestoreVotingHandlerConfigBackup()` method. + * How that affects the clients depends on whether restoration was done before, + * during or after the replication. It is intended to be done after + * server travel has started. + * the process of injection is to create an ordered list of game modes + * (`availableGameModes`) and generate appropriate voting handler's configs + * with `BuildVotingHandlerConfig()`, saving them in the same order inside + * the voting handler. Picked game mode is then determined by index of + * the picked voting handler's option. + * + * Additionally this class has a static internal state that allows it to + * transfer data along the server travel - it is used mainly to remember picked + * game mode and enforce game's difficulty by altering and restoring + * `GameInfo`'s variable. + * To make such transfer happen one must call `PrepareForServerTravel()` before + * server travel to set the internal static state and + * then `SetupGameModeAfterTravel()` after travel (when the new map is loading) + * to read (and forget) from internal state. + */ + +// All available game modes for Acedia, loaded during initialization. +// This array is directly produces replacement for `XVotingHandler`'s +// `gameConfig` array and records of `availableGameModes` relate to those of +// `gameConfig` with the same index. +// So if we know that a voting option with a certain index was chosen - +// it means that user picked game mode from `availableGameModes` with +// the same index. +var private array availableGameModes; + +// Finding voting handler is not cheap, so only do it once and then store it. +var private NativeActorRef votingHandlerReference; +// Save `VotingHandler`'s config to restore it before server travel - +// otherwise Acedia will alter its config +var private array backupVotingHandlerConfig; + +// Setting value of this flag to `true` indicates that map switching just +// occurred and we need to recover some information from the previous map. +var private config bool isServerTraveling; +// We should not rely on "VotingHandler" to inform us from which game mode its +// selected config option originated after server travel, so we need to +// remember it in this config variable before switching maps. +var private config string targetGameMode; +// Acedia's game modes intend on supporting difficulty switching, but +// `KFGameType` does not support appropriate flags, so we enforce default +// difficulty by overwriting default value of its `gameDifficulty` variable. +// But to not affect game's configs we must restore old value after new map is +// loaded. Store it in config variable for that. +var private config int storedGameLength; + +// Aliases are an unnecessary overkill for difficulty names, so just define +// them in special `string` arrays. +// We accept not just these exact words, but any of their prefixes. +var private const array shortSynonyms; +var private const array normalSynonyms; +var private const array longSynonyms; +var private const array customSynonyms; + +var private LoggerAPI.Definition fatNoXVotingHandler, fatBadGameConfigIndexVH; +var private LoggerAPI.Definition fatBadGameConfigIndexAdapter; + +protected function Finalizer() +{ + _.memory.Free(votingHandlerReference); + _.memory.FreeMany(availableGameModes); + votingHandlerReference = none; + availableGameModes.length = 0; +} + +/** + * Replaces `XVotingHandler`'s configs with Acedia's game modes. + * Backup of replaced configs is made internally, so that they can be restored + * on map change. + */ +public final function InjectIntoVotingHandler() +{ + local int i; + local GameMode nextGameMode; + local XVotingHandler votingHandler; + local array newVotingHandlerConfig; + + if (votingHandlerReference != none) { + return; + } + votingHandler = XVotingHandler(_server.unreal.FindActorInstance( + _server.unreal.GetGameType().VotingHandlerClass)); + if (votingHandler == none) + { + _.logger.Auto(fatNoXVotingHandler); + return; + } + votingHandlerReference = _server.unreal.ActorRef(votingHandler); + class'GameMode'.static.Initialize(); + availableGameModes = class'GameMode'.static.AvailableConfigs(); + for (i = 0; i < availableGameModes.length; i += 1) + { + nextGameMode = GameMode(class'GameMode'.static + .GetConfigInstance(availableGameModes[i])); + newVotingHandlerConfig[i] = BuildVotingHandlerConfig(nextGameMode); + // Report omitted mutators / server options + nextGameMode.ReportBadMutatorNames(); + nextGameMode.ReportBadOptions(); + } + backupVotingHandlerConfig = votingHandler.gameConfig; + votingHandler.gameConfig = newVotingHandlerConfig; +} + +private function VotingHandler.MapVoteGameConfig BuildVotingHandlerConfig( + GameMode gameMode) +{ + local MutableText nextColoredName; + local VotingHandler.MapVoteGameConfig result; + + result.gameClass = _.text.IntoString(gameMode.GetGameTypeClass()); + result.prefix = _.text.IntoString(gameMode.GetMapPrefix()); + nextColoredName = gameMode + .GetTitle() + .IntoMutableText() + .ChangeDefaultColor(_.color.white); + result.gameName = _.text.IntoColoredString(nextColoredName) + $ _.color.GetColorTag(_.color.White); + nextColoredName = gameMode + .GetAcronym() + .IntoMutableText() + .ChangeDefaultColor(_.color.white); + result.acronym = _.text.IntoColoredString(nextColoredName) + $ _.color.GetColorTag(_.color.White); + result.mutators = BuildMutatorString(gameMode); + result.options = BuildOptionsString(gameMode); + return result; +} + +private function string BuildMutatorString(GameMode gameMode) +{ + local int i; + local string result; + local array usedMutators; + + usedMutators = gameMode.GetIncludedMutators(); + for (i = 0; i < usedMutators.length; i += 1) + { + if (i > 0) { + result $= ","; + } + result $= _.text.IntoString(usedMutators[i]); + } + return result; +} + +private function string BuildOptionsString(GameMode gameMode) +{ + local bool optionWasAdded; + local string result; + local string nextKey, nextValue; + local CollectionIterator iter; + local HashTable options; + + options = gameMode.GetOptions(); + for (iter = options.Iterate(); !iter.HasFinished(); iter.Next()) + { + nextKey = _.text.IntoString(Text(iter.GetKey())); + nextValue = _.text.IntoString(Text(iter.Get())); + if (optionWasAdded) { + result $= "?"; + } + result $= (nextKey $ "=" $ nextValue); + optionWasAdded = true; + } + options.FreeSelf(); + iter.FreeSelf(); + return result; +} + +/** + * Makes necessary preparations for the server travel. + */ +public final function PrepareForServerTravel() +{ + local int pickedVHConfig; + local GameMode nextGameMode; + local string nextGameClassName; + local class nextGameClass; + local class nextKFGameType; + local XVotingHandler votingHandler; + + if (votingHandlerReference == none) return; + votingHandler = XVotingHandler(votingHandlerReference.Get()); + if (votingHandler == none) return; + // Server travel caused by something else than `XVotingHandler` + if (!votingHandler.bLevelSwitchPending) return; + + pickedVHConfig = votingHandler.currentGameConfig; + if (pickedVHConfig < 0 || pickedVHConfig >= votingHandler.gameConfig.length) + { + _.logger.Auto(fatBadGameConfigIndexVH) + .ArgInt(pickedVHConfig) + .ArgInt(votingHandler.gameConfig.length); + return; + } + if (pickedVHConfig >= availableGameModes.length) + { + _.logger.Auto(fatBadGameConfigIndexAdapter) + .ArgInt(pickedVHConfig) + .ArgInt(availableGameModes.length); + return; + } + nextGameClassName = votingHandler.gameConfig[pickedVHConfig].gameClass; + if (string(_server.unreal.GetGameType().class) ~= nextGameClassName) { + nextGameClass = _server.unreal.GetGameType().class; + } + else + { + nextGameClass = + class(_.memory.LoadClass_S(nextGameClassName)); + } + isServerTraveling = true; + targetGameMode = availableGameModes[pickedVHConfig].ToString(); + nextGameMode = GetConfigFromString(targetGameMode); + nextKFGameType = class(nextGameClass); + if (nextKFGameType != none) + { + storedGameLength = nextKFGameType.default.kfGameLength; + nextKFGameType.default.kfGameLength = + GetNumericGameLength(nextGameMode); + } + nextGameClass.static.StaticSaveConfig(); + SaveConfig(); +} + +/** + * Restore `GameInfo`'s settings after the server travel and + * apply selected `GameMode`. + * + * @return `GameMode` picked before server travel + * (the one that must be running now). + */ +public final function GameMode SetupGameModeAfterTravel() +{ + local KFGameType kfGameType; + + if (!isServerTraveling) { + return none; + } + kfGameType = _server.unreal.GetKFGameType(); + if (kfGameType != none) { + kfGameType.default.kfGameLength = storedGameLength; + } + isServerTraveling = false; + _server.unreal.GetGameType().StaticSaveConfig(); + SaveConfig(); + return GetConfigFromString(targetGameMode); +} + +/** + * Restores `XVotingHandler`'s config to the values that were overridden by + * `VHAdapter`'s `InjectIntoVotingHandler()` method. + */ +public final function RestoreVotingHandlerConfigBackup() +{ + local XVotingHandler votingHandler; + + if (votingHandlerReference == none) return; + votingHandler = XVotingHandler(votingHandlerReference.Get()); + if (votingHandler == none) return; + + votingHandler.gameConfig = backupVotingHandlerConfig; + votingHandler.default.gameConfig = backupVotingHandlerConfig; + votingHandler.SaveConfig(); +} + +// `GameMode`'s name as a `string` -> `GameMode` instance +private function GameMode GetConfigFromString(string configName) +{ + local GameMode result; + local Text nextConfigName; + nextConfigName = _.text.FromString(configName); + result = GameMode(class'GameMode'.static.GetConfigInstance(nextConfigName)); + _.memory.Free(nextConfigName); + return result; +} + +// Convert `GameMode`'s difficulty's textual representation into +// KF's numeric one. +private final function int GetNumericGameLength(BaseGameMode gameMode) +{ + local int i; + local string length; + + length = Locs(_.text.IntoString(gameMode.GetLength())); + for (i = 0; i < default.shortSynonyms.length; i += 1) + { + if (IsPrefixOf(length, default.shortSynonyms[i])) { + return 0; + } + } + for (i = 0; i < default.normalSynonyms.length; i += 1) + { + if (IsPrefixOf(length, default.normalSynonyms[i])) { + return 1; + } + } + for (i = 0; i < default.longSynonyms.length; i += 1) + { + if (IsPrefixOf(length, default.longSynonyms[i])) { + return 2; + } + } + for (i = 0; i < default.customSynonyms.length; i += 1) + { + if (IsPrefixOf(length, default.customSynonyms[i])) { + return 3; + } + } + return 3; +} + +protected final static function bool IsPrefixOf(string prefix, string value) +{ + return (InStr(value, prefix) == 0); +} + +defaultproperties +{ + shortSynonyms(0) = "short" + normalSynonyms(0) = "normal" + normalSynonyms(1) = "medium" + normalSynonyms(2) = "regular" + longSynonyms(0) = "long" + customSynonyms(0) = "custom" + fatNoXVotingHandler = (l=LOG_Fatal,m="`XVotingHandler` class is missing. Make sure your server setup supports Acedia's game modes (by used voting handler derived from `XVotingHandler`).") + fatBadGameConfigIndexVH = (l=LOG_Fatal,m="`XVotingHandler`'s `currentGameConfig` variable value of %1 is out-of-bounds for `XVotingHandler.gameConfig` of length %2. Report this issue.") + fatBadGameConfigIndexAdapter = (l=LOG_Fatal,m="`XVotingHandler`'s `currentGameConfig` variable value of %1 is out-of-bounds for `VHAdapter` of length %2. Report this issue.") +} \ No newline at end of file diff --git a/kf_sources/Acedia/Classes/index.html b/kf_sources/Acedia/Classes/index.html new file mode 100644 index 0000000..ac404b5 --- /dev/null +++ b/kf_sources/Acedia/Classes/index.html @@ -0,0 +1,11 @@ + +Index of /kf_sources/Acedia/Classes/ + +

Index of /kf_sources/Acedia/Classes/


../
+BaseGameMode.uc                                    08-Aug-2022 11:04               13630
+GameMode.uc                                        08-Aug-2022 11:50                9256
+Packages.uc                                        08-Aug-2022 11:38                9297
+StartUp.uc                                         08-Aug-2022 06:17                1196
+VotingHandlerAdapter.uc                            11-Aug-2022 18:26               14483
+

+ diff --git a/kf_sources/Acedia/index.html b/kf_sources/Acedia/index.html new file mode 100644 index 0000000..179304b --- /dev/null +++ b/kf_sources/Acedia/index.html @@ -0,0 +1,7 @@ + +Index of /kf_sources/Acedia/ + +

Index of /kf_sources/Acedia/


../
+Classes/                                           29-Jun-2025 19:43                   -
+

+ diff --git a/kf_sources/AcediaCore/Classes/ACommandFakers.uc b/kf_sources/AcediaCore/Classes/ACommandFakers.uc new file mode 100644 index 0000000..4131e07 --- /dev/null +++ b/kf_sources/AcediaCore/Classes/ACommandFakers.uc @@ -0,0 +1,163 @@ +/** + * Author: dkanus + * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore + * License: GPL + * Copyright 2023 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class ACommandFakers extends Command + dependsOn(VotingModel); + +var private array fakers; + +protected static function StaticFinalizer() { + __().memory.FreeMany(default.fakers); + default.fakers.length = 0; +} + +protected function BuildData(CommandDataBuilder builder) { + builder.Group(P("debug")); + builder.Summary(P("Adds fake voters for testing \"vote\" command.")); + builder.Describe(P("Displays current fake voters.")); + + builder.SubCommand(P("amount")); + builder.Describe(P("Specify amount of faker that are allowed to vote.")); + builder.ParamInteger(P("fakers_amount")); + + builder.SubCommand(P("vote")); + builder.Describe(P("Make a vote as a faker.")); + builder.ParamInteger(P("faker_number")); + builder.ParamBoolean(P("vote_for")); +} + +protected function Executed( + CallData arguments, + EPlayer instigator, + CommandPermissions permissions +) { + if (arguments.subCommandName.IsEmpty()) { + DisplayCurrentFakers(); + } else if (arguments.subCommandName.Compare(P("amount"), SCASE_INSENSITIVE)) { + ChangeAmount(arguments.parameters.GetInt(P("fakers_amount"))); + } else if (arguments.subCommandName.Compare(P("vote"), SCASE_INSENSITIVE)) { + CastVote( + arguments.parameters.GetInt(P("faker_number")), + arguments.parameters.GetBool(P("vote_for"))); + } +} + +public final static function /*borrow*/ array BorrowDebugVoters() { + return default.fakers; +} + +private final function CastVote(int fakerID, bool voteFor) { + local Voting currentVoting; + + if (fakerID < 0 || fakerID >= fakers.length) { + callerConsole + .UseColor(_.color.TextFailure) + .WriteLine(P("Faker number is out of bounds.")); + return; + } + currentVoting = _.commands.GetCurrentVoting(); + if (currentVoting == none) { + callerConsole + .UseColor(_.color.TextFailure) + .WriteLine(P("There is no voting active right now.")); + return; + } + currentVoting.CastVoteByID(fakers[fakerID], voteFor); + _.memory.Free(currentVoting); +} + +private final function ChangeAmount(int newAmount) { + local int i; + local Text nextIDName; + local UserID nextID; + local Voting currentVoting; + + if (newAmount < 0) { + callerConsole + .UseColor(_.color.TextFailure) + .WriteLine(P("Cannot specify negative amount.")); + } + if (newAmount == fakers.length) { + callerConsole + .UseColor(_.color.TextNeutral) + .WriteLine(P("Specified same amount of fakers.")); + } else if (newAmount > fakers.length) { + for (i = fakers.length; i < newAmount; i += 1) { + nextIDName = _.text.FromString("DEBUG:FAKER:" $ i); + nextID = UserID(__().memory.Allocate(class'UserID')); + nextID.Initialize(nextIDName); + _.memory.Free(nextIDName); + fakers[fakers.length] = nextID; + } + } else { + for (i = fakers.length - 1; i >= newAmount; i -= 1) { + _.memory.Free(fakers[i]); + } + fakers.length = newAmount; + } + default.fakers = fakers; + currentVoting = _.commands.GetCurrentVoting(); + if (currentVoting != none) { + currentVoting.SetDebugVoters(default.fakers); + _.memory.Free(currentVoting); + } +} + +private function DisplayCurrentFakers() { + local int i; + local VotingModel.PlayerVoteStatus nextVoteStatus; + local MutableText nextNumber; + local Voting currentVoting; + + if (fakers.length <= 0) { + callerConsole.WriteLine(P("No fakers!")); + return; + } + currentVoting =_.commands.GetCurrentVoting(); + for (i = 0; i < fakers.length; i += 1) { + nextNumber = _.text.FromIntM(i); + callerConsole + .Write(P("Faker #")) + .Write(nextNumber) + .Write(P(": ")); + if (currentVoting != none) { + nextVoteStatus = currentVoting.GetVote(fakers[i]); + } + switch (nextVoteStatus) { + case PVS_NoVote: + callerConsole.WriteLine(P("no vote")); + break; + case PVS_VoteFor: + callerConsole.UseColorOnce(_.color.TextPositive).WriteLine(P("vote for")); + break; + case PVS_VoteAgainst: + callerConsole.UseColorOnce(_.color.TextNegative).WriteLine(P("vote against")); + break; + default: + callerConsole.UseColorOnce(_.color.TextFailure).WriteLine(P("vote !ERROR!")); + } + _.memory.Free(nextNumber); + } +} + +defaultproperties { + preferredName = "fakers" +} \ No newline at end of file diff --git a/kf_sources/AcediaCore/Classes/ACommandHelp.uc b/kf_sources/AcediaCore/Classes/ACommandHelp.uc new file mode 100644 index 0000000..66bf1c3 --- /dev/null +++ b/kf_sources/AcediaCore/Classes/ACommandHelp.uc @@ -0,0 +1,731 @@ +/** + * Command for displaying help information about registered Acedia's commands. + * Copyright 2021-2023 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class ACommandHelp extends Command + dependson(LoggerAPI) + dependson(CommandAPI); + +/** + * # `ACommandHelp` + * + * This built-in command is meant to do two things: + * + * 1. List all available commands and aliases related to them; + * 2. Display help pages for available commands (both by command and + * alias names). + * + * ## Implementation + * + * ### Aliases loading + * + * First thing this command tries to do upon creation is to read all + * command aliases and build a reverse map that allows to access aliases names + * by command + subcommand (even if latter one is empty) that these names + * refer to. This allows us to display relevant aliases names alongside + * command names. Map is stored inside `commandToAliasesMap` variable. + * (If aliases feature isn't yet available, `ACommandHelp` connects to + * `OnFeatureEnabled()` signal to do its job the moment required feature is + * enabled). + * This map is also made to be two-level one - it is... + * + * * a `HashTable` with command names as keys, + * * that points to `HashTable`s with subcommand names as keys, + * * that points at `ArrayList` of aliases. + * + * This allows us to also sort aliases by the subcommand name when displaying + * their list. This work is done by `FillCommandToAliasesMap()` method (that + * uses `ParseCommandNames()` method, which is also used for displaying + * command list). + * + * ### Command list displaying + * + * This is a simple process performed by `DisplayCommandLists()` that calls on + * a bunch of auxiliary `Print...()` methods. + * + * ### Displaying help pages + * + * Similar to the above, this is mostly a simple process that displays + * `Command`s' `CommandData` as a help page. Work is performed by + * `DisplayCommandHelpPages()` method that calls on a bunch of auxiliary + * `Print...()` methods, most notably `PrintHelpPageFor()` that can display + * help pages both for full commands, as well as only for a singular + * subcommand, which allows us to print proper help pages for command aliases + * that refer to a particular subcommand. + */ + +// For each key (given by lower case command name) stores another `HashMap` +// that uses sub-command names as keys and returns `ArrayList` of aliases. +var private HashTable commandToAliasesMap; + +var private User callerUser; + +var public const int TSPACE, TCOMMAND_NAME_FALLBACK, TPLUS; +var public const int TOPEN_BRACKET, TCLOSE_BRACKET, TCOLON_SPACE; +var public const int TKEY, TDOUBLE_KEY, TCOMMA_SPACE, TBOOLEAN, TINDENT; +var public const int TBOOLEAN_TRUE_FALSE, TBOOLEAN_ENABLE_DISABLE; +var public const int TBOOLEAN_ON_OFF, TBOOLEAN_YES_NO; +var public const int TOPTIONS, TCMD_WITH_TARGET, TCMD_WITHOUT_TARGET; +var public const int TSEPARATOR, TLIST_REGIRESTED_CMDS, TEMPTY_GROUP; +var public const int TALIASES_FOR, TEMPTY, TDOT, TNO_COMMAND_BEGIN; +var public const int TNO_COMMAND_END, TEMPTY_GROUP_BEGIN, TEMPTY_GROUP_END; + +protected function Constructor() +{ + local Feature preenabledInstance; + + super.Constructor(); + // We need to update aliases map every time aliases feature is reenabled + _.environment.OnFeatureEnabled(self).connect = FillCommandToAliasesMap; + // If `Aliases_Feature` is already enabled - read its command aliases now + preenabledInstance = class'Aliases_Feature'.static.GetEnabledInstance(); + FillCommandToAliasesMap(Aliases_Feature(preenabledInstance)); + _.memory.Free(preenabledInstance); +} + +protected function Finalizer() +{ + super.Finalizer(); + _.memory.Free(commandToAliasesMap); + commandToAliasesMap = none; +} + +protected function BuildData(CommandDataBuilder builder) +{ + builder.Group(P("core")); + builder.Summary(P("Displays detailed information about available commands.")); + builder.OptionalParams(); + builder.ParamTextList(P("commands")); + + builder.Option(P("aliases")); + builder.Describe(P("When displaying available commands, specifying this flag will additionally" + @ "make command to display all of their available aliases.")); + + builder.Option(P("list")); + builder.Describe(P("Display available commands. Optionally command groups can be specified and" + @ "then only commands from such groups will be listed. Otherwise all commands will" + @ "be displayed.")); + builder.OptionalParams(); + builder.ParamTextList(P("groups")); +} + +protected function Executed( + Command.CallData callData, + EPlayer callerPlayer, + CommandPermissions permissions +) { + local bool printedSomething; + local HashTable parameters, options; + local ArrayList commandsToDisplay, commandGroupsToDisplay; + + callerUser = callerPlayer.GetIdentity(); + parameters = callData.parameters; + options = callData.options; + // Print command list if "--list" option was specified + if (options.HasKey(P("list"))) + { + commandGroupsToDisplay = options.GetArrayListBy(P("/list/groups")); + DisplayCommandLists( + commandGroupsToDisplay, + options.HasKey(P("aliases"))); + _.memory.Free(commandGroupsToDisplay); + printedSomething = true; + } + // Help pages. + // Only need to print them if: + // 1. Any commands are specified as parameters; + // 2. No commands or "--list" option was specified, then we want to + // print a help page for this command. + if (!options.HasKey(P("list")) || parameters.HasKey(P("commands"))) + { + commandsToDisplay = parameters.GetArrayList(P("commands")); + DisplayCommandHelpPages(commandsToDisplay, printedSomething); + _.memory.Free(commandsToDisplay); + } + _.memory.Free(callerUser); + callerUser = none; +} + +// If instance of the `Aliases_Feature` is passed as an argument (allowing this +// method to be used as a slot for `OnFeatureEnabled` signal) and +// `commandAliasSource` is available - empties current `commandToAliasesMap` +// and refills it with available command aliases. +private final function FillCommandToAliasesMap(Feature enabledFeature) +{ + local int i; + local Text aliasValue; + local array availableAliases; + local BaseAliasSource commandAliasSource; + local MutableText commandName, subcommandName; + + if (enabledFeature == none) return; + if (enabledFeature.class != class'Aliases_Feature') return; + commandAliasSource = _.alias.GetCommandSource(); + if (commandAliasSource == none) return; + + // Drop map built by previous aliases (if it was even build up until now) + _.memory.Free(commandToAliasesMap); + commandToAliasesMap = _.collections.EmptyHashTable(); + // Construct a command -> alias map by iterating over aliases + availableAliases = commandAliasSource.GetAllAliases(); + for (i = 0; i < availableAliases.length; i += 1) + { + aliasValue = _.alias.ResolveCommand(availableAliases[i]); + ParseCommandNames(aliasValue, commandName, subcommandName); + aliasValue.FreeSelf(); + InsertIntoAliasesMap(commandName, subcommandName, availableAliases[i]); + commandName.FreeSelf(); + subcommandName.FreeSelf(); + commandName = none; + subcommandName = none; + } + // Clean up + _.memory.FreeMany(availableAliases); + commandAliasSource.FreeSelf(); +} + +// Breaks command name as it is intended to be specified in command aliases +// ("" or ".") into command and subcommand, +// returning both as `out` parameters. +private final function ParseCommandNames( + BaseText source, + out MutableText commandName, + out MutableText subcommandName) +{ + local Parser valueParser; + + if (source == none) { + return; + } + if (subcommandName != none) { + subcommandName.FreeSelf(); + } + valueParser = source.Parse(); + subcommandName = valueParser + .MUntil(commandName, T(TDOT).GetCharacter(0)) + .Match(T(TDOT)) + .GetRemainderM(); + _.memory.Free(valueParser); +} + +// Assumes that `commandToAliasesMap` and its arguments are not `none`. +private final function InsertIntoAliasesMap( + BaseText commandName, + BaseText subcommandName, + BaseText alias) +{ + local Text commandKey, subcommandKey; + local HashTable subcommandToAliasesMap; + local ArrayList aliasesArray; + + commandKey = commandName.LowerCopy(); + subcommandKey = subcommandName.LowerCopy(); + subcommandToAliasesMap = commandToAliasesMap.GetHashTable(commandKey); + if (subcommandToAliasesMap == none) { + subcommandToAliasesMap = _.collections.EmptyHashTable(); + } + commandToAliasesMap.SetItem(commandKey, subcommandToAliasesMap); + aliasesArray = subcommandToAliasesMap.GetArrayList(subcommandKey); + if (aliasesArray == none) { + aliasesArray = _.collections.EmptyArrayList(); + } + subcommandToAliasesMap.SetItem(subcommandKey, aliasesArray); + if (aliasesArray.Find(alias) < 0) { + aliasesArray.AddItem(alias); + } + // Cleaning up local references to collections and keys + subcommandToAliasesMap.FreeSelf(); + aliasesArray.FreeSelf(); + commandKey.FreeSelf(); + subcommandKey.FreeSelf(); +} + +private final function DisplayCommandLists( + ArrayList commandGroupsToDisplay, + bool displayAliases) +{ + local int i; + local array commandNames, groupsNames; + + if (commandGroupsToDisplay == none) { + groupsNames = _.commands.GetGroupsNames(); + } + else + { + for (i = 0; i < commandGroupsToDisplay.GetLength(); i += 1) { + groupsNames[groupsNames.length] = commandGroupsToDisplay.GetText(i); + } + } + callerConsole.WriteLine(T(TLIST_REGIRESTED_CMDS)); + for (i = 0; i < groupsNames.length; i += 1) + { + if (groupsNames[i] == none) { + continue; + } + commandNames = _.commands.GetCommandNamesInGroup(groupsNames[i]); + if (commandNames.length > 0) + { + callerConsole.UseColorOnce(_.color.TextSubHeader); + if (groupsNames[i].IsEmpty()) { + callerConsole.WriteLine(T(TEMPTY_GROUP)); + } + else { + callerConsole.WriteLine(groupsNames[i]); + } + PrintCommandsNamesArray( + commandNames, + displayAliases); + _.memory.FreeMany(commandNames); + } else { + callerConsole.UseColor(_.color.TextFailure); + callerConsole.Write(T(TEMPTY_GROUP_BEGIN)); + callerConsole.Write(groupsNames[i]); + callerConsole.WriteLine(T(TEMPTY_GROUP_END)); + callerConsole.ResetColor(); + } + } + _.memory.FreeMany(groupsNames); +} + +private final function PrintCommandsNamesArray( + array commandsNamesArray, + bool displayAliases +) { + local int i; + local Command.Data nextData; + local CommandAPI.CommandConfigInfo nextCommandPair; + + for (i = 0; i < commandsNamesArray.length; i += 1) + { + nextCommandPair = _.commands.ResolveCommandForUser( + commandsNamesArray[i], + callerUser); + if (nextCommandPair.instance != none && !nextCommandPair.usageForbidden) { + nextData = nextCommandPair.instance.BorrowData(); + callerConsole + .UseColorOnce(_.color.textEmphasis) + .Write(commandsNamesArray[i]) + .Write(T(TCOLON_SPACE)) + .WriteLine(nextData.summary); + if (displayAliases) { + PrintCommandAliases(commandsNamesArray[i]); + } + } + _.memory.Free(nextCommandPair.instance); + } +} + +private final function PrintCommandAliases(BaseText commandName) +{ + local CollectionIterator iter; + local Text commandKey; + local Text nextSubCommand; + local ArrayList nextAliasesArray; + local HashTable subCommandToAliasesMap; + + if (commandName == none) return; + if (commandToAliasesMap == none) return; + + commandKey = commandName.LowerCopy(); + subCommandToAliasesMap = commandToAliasesMap.GetHashTable(commandName); + commandKey.FreeSelf(); + if (subCommandToAliasesMap == none) { + return; + } + // Display aliases to command itself first + nextAliasesArray = subCommandToAliasesMap.GetArrayList(T(TEMPTY)); + PrintAliasesArray(commandName, none, nextAliasesArray); + _.memory.Free(nextAliasesArray); + // Then aliases to all of its subcommands, in no particular order + iter = subCommandToAliasesMap.Iterate(); + while (!iter.HasFinished()) + { + nextSubCommand = Text(iter.Getkey()); // cannot be `none` + if (nextSubCommand.IsEmpty()) + { + nextSubCommand.FreeSelf(); + iter.Next(); + continue; + } + nextAliasesArray = ArrayList(iter.Get()); + PrintAliasesArray(commandName, nextSubCommand, nextAliasesArray); + _.memory.Free(nextAliasesArray); + _.memory.Free(nextSubCommand); + iter.Next(); + } + iter.FreeSelf(); +} + +private final function PrintAliasesArray( + BaseText commandName, + BaseText subcommandName, + ArrayList aliasesArray) +{ + local int i; + local Text nextAlias; + + if (commandName == none) return; + if (aliasesArray == none) return; + + callerConsole + .Write(T(TALIASES_FOR)) + .Write(T(TSPACE)) + .UseColorOnce(_.color.TextEmphasis) + .Write(commandName); + if (subcommandName != none) + { + callerConsole + .Write(T(TSPACE)) + .UseColorOnce(_.color.TextEmphasis) + .Write(subCommandName); + } + callerConsole.Write(T(TCOLON_SPACE)); + for (i = 0; i < aliasesArray.GetLength(); i += 1) + { + if (i > 0) { + callerConsole.Write(T(TCOMMA_SPACE)); + } + nextAlias = aliasesArray.GetText(i); + callerConsole.UseColorOnce(_.color.TextNeutral).Write(nextAlias); + nextAlias.FreeSelf(); + } + callerConsole.WriteBlock(); +} + +private final function DisplayCommandHelpPages(ArrayList commandList, bool printedSomething) { + local int i; + local Text nextUserProvidedName; + local MutableText referredSubcommand; + local CommandAPI.CommandConfigInfo nextPair; + + // If arguments were empty - at least display our own help page + if (commandList == none) { + nextPair.instance = self; + PrintHelpPageFor(usedName, none, nextPair); + return; + } + // Otherwise - print help for specified commands + for (i = 0; i < commandList.GetLength(); i += 1) { + nextUserProvidedName = commandList.GetText(i); + nextPair = GetCommandFromUserProvidedName( + nextUserProvidedName, + /*out*/ referredSubcommand); + if (nextPair.instance != none && !nextPair.usageForbidden) { + if (printedSomething) { + callerConsole.WriteLine(T(TSEPARATOR)); + } + PrintHelpPageFor( + nextUserProvidedName, + referredSubcommand, + nextPair); + printedSomething = true; + } else if (nextPair.instance != none) { + callerConsole.UseColor(_.color.TextFailure); + callerConsole.Write(T(TNO_COMMAND_BEGIN)); + callerConsole.Write(nextUserProvidedName); + callerConsole.WriteLine(T(TNO_COMMAND_END)); + callerConsole.ResetColor(); + } + _.memory.Free(nextPair.instance); + _.memory.Free(nextUserProvidedName); + _.memory.Free(referredSubcommand); + // `referredSubcommand` is passed as an `out` parameter on + // every iteration, so we need to prevent the possibility of its value + // being used. + // NOTE: `nextCommand` and `nextUserProvidedName` are just rewritten. + referredSubcommand = none; + } +} + +// Returns `Command` based on the name, given by user. +// `referredSubcommand` is always overwritten (freed if non-`none` value +// is passed) and is used to return name of the subcommand for returned +// `Command` that is specified by `nextUserProvidedName` (only relevant for +// aliases that refer to a particular subcommand). +private final function CommandAPI.CommandConfigInfo GetCommandFromUserProvidedName( + BaseText nextUserProvidedName, + out MutableText referredSubcommand) +{ + local CommandAPI.CommandConfigInfo result; + local Text commandAliasValue; + local MutableText parsedCommandName; + + // Clear `out` parameter no matter what + if (referredSubcommand != none) { + referredSubcommand.FreeSelf(); + referredSubcommand = none; + } + // Try getting command using `nextUserProvidedName` as a literal name + result = _.commands.ResolveCommandForUser(nextUserProvidedName, callerUser); + if (result.instance != none) { + return result; + } + // On failure - try resolving it as an alias + commandAliasValue = _.alias.ResolveCommand(nextUserProvidedName); + ParseCommandNames(commandAliasValue, parsedCommandName, referredSubcommand); + result = _.commands.ResolveCommandForUser(parsedCommandName, callerUser); + if ( result.instance == none + || !result.instance.IsSubCommandAllowed(referredSubcommand, result.config)) { + _.memory.Free(result.instance); + return result; + } + // Empty subcommand name from the alias is essentially no subcommand name + if (referredSubcommand != none && referredSubcommand.IsEmpty()) { + referredSubcommand.FreeSelf(); + referredSubcommand = none; + } + _.memory.Free2(commandAliasValue, parsedCommandName); + return result; +} + +private final function PrintHelpPageFor( + BaseText commandAlias, + BaseText referredSubcommand, + CommandAPI.CommandConfigInfo commandPair +) { + local Text commandNameLowerCase, commandNameUpperCase; + + // Get capitalized command name + commandNameUpperCase = commandAlias.UpperCopy(); + // Print header: name + basic info + callerConsole.UseColor(_.color.textHeader) + .Write(commandNameUpperCase) + .UseColor(_.color.textDefault); + commandNameUpperCase.FreeSelf(); + if (commandPair.instance.BorrowData().requiresTarget) { + callerConsole.WriteLine(T(TCMD_WITH_TARGET)); + } + else { + callerConsole.WriteLine(T(TCMD_WITHOUT_TARGET)); + } + // Print commands and options + commandNameLowerCase = commandAlias.LowerCopy(); + PrintCommands(commandPair, commandNameLowerCase, referredSubcommand); + commandNameLowerCase.FreeSelf(); + PrintOptions(commandPair.instance.BorrowData()); + // Clean up + callerConsole.ResetColor().Flush(); +} + +private final function PrintCommands( + CommandAPI.CommandConfigInfo commandPair, + BaseText commandName, + BaseText referredSubcommand +) { + local int i; + local array subCommands; + + subCommands = commandPair.instance.BorrowData().subCommands; + for (i = 0; i < subCommands.length; i += 1) { + if (referredSubcommand == none || referredSubcommand.Compare(subCommands[i].name)) { + if (commandPair.instance.IsSubCommandAllowed(subCommands[i].name, commandPair.config)) { + PrintSubCommand(subCommands[i], commandName, referredSubcommand != none); + } + } + } +} + +private final function PrintSubCommand( + SubCommand subCommand, + BaseText commandName, + bool skipSubcommandName) +{ + // Command + parameters + // Command name + sub command name + callerConsole.UseColor(_.color.textEmphasis) + .Write(commandName) + .Write(T(TSPACE)); + if ( !skipSubcommandName + && subCommand.name != none + && !subCommand.name.IsEmpty()) + { + callerConsole.Write(subCommand.name).Write(T(TSPACE)); + } + callerConsole.UseColor(_.color.textDefault); + // Parameters + PrintParameters(subCommand.required, subCommand.optional); + callerConsole.Flush(); + // Description + if (subCommand.description != none && !subCommand.description.IsEmpty()) { + callerConsole.WriteBlock(subCommand.description); + } +} + +private final function PrintOptions(Command.Data data) +{ + local int i; + local array