diff --git a/dev_tests/src/verify_expr.rs b/dev_tests/src/verify_expr.rs index 6f9ffa9..e1c2f19 100644 --- a/dev_tests/src/verify_expr.rs +++ b/dev_tests/src/verify_expr.rs @@ -11,56 +11,118 @@ 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", "var(Category,) int A;\n"), - ("files/P0057_02.uc", "var(,) int A;\n"), + ( + "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", - "var(\n ,\n ,\n Category\n) int A;\n", + concat!( + "class Test extends Actor;\n", + "var(\n ,\n ,\n Category\n) int A;\n" + ), ), ( "files/P0057_04.uc", - "var(\n Category,\n\n\n) int A;\n", + concat!( + "class Test extends Actor;\n", + "var(\n Category,\n\n\n) int A;\n" + ), ), // P0058 - VarEditorSpecifierListMissingSeparator - ("files/P0058_01.uc", "var(Category DisplayName) int A;\n"), - ("files/P0058_02.uc", "var(\"Display\" \"Tooltip\") int A;\n"), + ( + "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", - "var(\n Category\n DisplayName\n) int A;\n", + concat!( + "class Test extends Actor;\n", + "var(\n Category\n DisplayName\n) int A;\n" + ), ), ( "files/P0058_04.uc", - "var(\n Category\n\n \"Display Name\",\n Advanced\n) int A;\n", + 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", "var(Category :) int A;\n"), - ("files/P0059_02.uc", "var(\"Display\" *) int A;\n"), + ( + "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", - "var(\n Category\n [\n) int A;\n", + concat!( + "class Test extends Actor;\n", + "var(\n Category\n [\n) int A;\n" + ), ), ( "files/P0059_04.uc", - "var(\n \"Display Name\"\n\n ]\n) int A;\n", + concat!( + "class Test extends Actor;\n", + "var(\n \"Display Name\"\n\n ]\n) int A;\n" + ), ), // P0060 - VarEditorSpecifierListMissingClosingParenthesis - ("files/P0060_01.uc", "var(Category\n"), + ( + "files/P0060_01.uc", + concat!("class Test extends Actor;\n", "var(Category\n"), + ), ( "files/P0060_02.uc", - "var(\n Category,\n \"Display Name\"\n", + concat!( + "class Test extends Actor;\n", + "var(\n Category,\n \"Display Name\"\n" + ), ), ( "files/P0060_03.uc", - "var(\n\n\n", + concat!("class Test extends Actor;\n", "var(\n\n\n"), ), ( "files/P0060_04.uc", - "var(\n Category,\n\n \"Display Name\"\n\n", + concat!( + "class Test extends Actor;\n", + "var(\n Category,\n\n \"Display Name\"\n\n" + ), ), ]; @@ -183,11 +245,25 @@ fn main() { if RUN_PARSER { let mut parser = Parser::new(&tf, &arena); - let expr = parser.parse_expression(); - if PRINT_PARSED_EXPR { - println!("Parsed expression:"); - println!("{expr:#?}"); + 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() { @@ -224,4 +300,4 @@ fn render_diagnostic( _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