From 62fcc2f323b54c66666b842c972bd9f925470017 Mon Sep 17 00:00:00 2001 From: Anton Tarasenko Date: Wed, 28 Jul 2021 02:19:28 +0700 Subject: [PATCH] Add support for multiple configs for `Feature`s This patch changes how `Feature`s configs work - they are now required to use a separate config object (each `Feature` their own class, derived from `FeatureConfig`) that is to be used as a `perconfigobject` storage. `Feature`s themselves are not supposed to interact with their config objects directly, instead using JSON-serializable type, which should allow us a universal way to change `Feature`'s config variables during execution. --- config/AcediaSystem.ini | 2 +- .../Commands/BroadcastListener_Commands.uc | 19 +- .../Commands/BuiltInCommands/ACommandHelp.uc | 24 +- sources/Commands/Commands.uc | 144 +------- sources/Commands/Commands_Feature.uc | 183 ++++++++++ sources/{ => Features}/Feature.uc | 117 ++++++- sources/Features/FeatureConfig.uc | 316 ++++++++++++++++++ sources/{ => Features}/FeatureService.uc | 0 sources/Features/Tests/MockFeature.uc | 49 +++ sources/Features/Tests/TEST_FeatureConfig.uc | 113 +++++++ sources/Manifest.uc | 3 +- 11 files changed, 811 insertions(+), 159 deletions(-) create mode 100644 sources/Commands/Commands_Feature.uc rename sources/{ => Features}/Feature.uc (59%) create mode 100644 sources/Features/FeatureConfig.uc rename sources/{ => Features}/FeatureService.uc (100%) create mode 100644 sources/Features/Tests/MockFeature.uc create mode 100644 sources/Features/Tests/TEST_FeatureConfig.uc diff --git a/config/AcediaSystem.ini b/config/AcediaSystem.ini index 7c99662..5b5a4eb 100644 --- a/config/AcediaSystem.ini +++ b/config/AcediaSystem.ini @@ -41,7 +41,7 @@ timeStamp=true ; Should logger display information about what level message was logged? levelStamp=true -[AcediaCore_0_2.Commands] +[default Commands] ; This feature provides a mechanism to define commands that automatically ; parse their arguments into standard Acedia collection. It also allows to ; manage them (and specify limitation on how they can be called) in a diff --git a/sources/Commands/BroadcastListener_Commands.uc b/sources/Commands/BroadcastListener_Commands.uc index 60ee218..458d514 100644 --- a/sources/Commands/BroadcastListener_Commands.uc +++ b/sources/Commands/BroadcastListener_Commands.uc @@ -27,17 +27,18 @@ static function bool HandleText( out string message, optional name messageType) { - local Text messageAsText; - local APlayer callerPlayer; - local Parser parser; - local Commands commandFeature; - local PlayerService service; + local Text messageAsText; + local APlayer callerPlayer; + local Parser parser; + local Commands_Feature commandFeature; + local PlayerService service; // We only want to catch chat messages // and only if `Commands` feature is active - if (messageType != 'Say') return true; - commandFeature = Commands(class'Commands'.static.GetInstance()); - if (commandFeature == none) return true; - if (!commandFeature.useChatInput) return true; + if (messageType != 'Say') return true; + commandFeature = + Commands_Feature(class'Commands_Feature'.static.GetInstance()); + if (commandFeature == none) return true; + if (!commandFeature.UsingChatInput()) return true; // We are only interested in messages that start with "!" parser = __().text.ParseString(message); if (!parser.Match(P("!")).Ok()) diff --git a/sources/Commands/BuiltInCommands/ACommandHelp.uc b/sources/Commands/BuiltInCommands/ACommandHelp.uc index 27bf947..c7c9ddf 100644 --- a/sources/Commands/BuiltInCommands/ACommandHelp.uc +++ b/sources/Commands/BuiltInCommands/ACommandHelp.uc @@ -70,14 +70,15 @@ protected function Executed(CommandCall callInfo) private final function DisplayCommandList(APlayer player) { - local int i; - local ConsoleWriter console; - local Command nextCommand; - local Command.Data nextData; - local array commandNames; - local Commands commandsFeature; + local int i; + local ConsoleWriter console; + local Command nextCommand; + local Command.Data nextData; + local array commandNames; + local Commands_Feature commandsFeature; if (player == none) return; - commandsFeature = Commands(class'Commands'.static.GetInstance()); + commandsFeature = + Commands_Feature(class'Commands_Feature'.static.GetInstance()); if (commandsFeature == none) return; console = player.Console(); @@ -101,11 +102,12 @@ private final function DisplayCommandHelpPages( APlayer player, DynamicArray commandList) { - local int i; - local Command nextCommand; - local Commands commandsFeature; + local int i; + local Command nextCommand; + local Commands_Feature commandsFeature; if (player == none) return; - commandsFeature = Commands(class'Commands'.static.GetInstance()); + commandsFeature = + Commands_Feature(class'Commands_Feature'.static.GetInstance()); if (commandsFeature == none) return; // If arguments were empty - at least display our own help page diff --git a/sources/Commands/Commands.uc b/sources/Commands/Commands.uc index 48ba537..37def06 100644 --- a/sources/Commands/Commands.uc +++ b/sources/Commands/Commands.uc @@ -1,8 +1,5 @@ /** - * This feature provides a mechanism to define commands that automatically - * parse their arguments into standard Acedia collection. It also allows to - * manage them (and specify limitation on how they can be called) in a - * centralized manner. + * Config object for `Commands_Feature`. * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. @@ -20,144 +17,33 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class Commands extends Feature +class Commands extends FeatureConfig + perobjectconfig config(AcediaSystem); -// Delimiters that always separate command name from it's parameters -var private array commandDelimiters; -// Registered commands, recorded as (, ) pairs -var private AssociativeArray registeredCommands; +var private config bool useChatInput; -// Setting this to `true` enables players to input commands right in the chat -// by prepending them with "!" character. -var public config bool useChatInput; - -var LoggerAPI.Definition errCommandDuplicate; - -protected function OnEnabled() -{ - registeredCommands = _.collections.EmptyAssociativeArray(); - // Macro selector - commandDelimiters[0] = P("@"); - // Key selector - commandDelimiters[1] = P("#"); - // Player array (possibly JSON array) - commandDelimiters[2] = P("["); - // Negation of the selector - commandDelimiters[3] = P("!"); -} - -protected function OnDisabled() -{ - _.memory.Free(registeredCommands); - registeredCommands = none; - commandDelimiters.length = 0; -} - -/** - * Registers given command class, making it available for usage. - * - * If `commandClass` provides command with a name that is already taken - * (comparison is case-insensitive) by a different command - a warning will be - * logged and newly passed `commandClass` discarded. - * - * @param commandClass New command class to register. - */ -public final function RegisterCommand(class commandClass) -{ - local Text commandName; - local Command newCommandInstance, existingCommandInstance; - if (commandClass == none) return; - if (registeredCommands == none) return; - - newCommandInstance = Command(_.memory.Allocate(commandClass, true)); - commandName = newCommandInstance.GetName(); - // Check for duplicates and report them - existingCommandInstance = Command(registeredCommands.GetItem(commandName)); - if (existingCommandInstance != none) - { - _.logger.Auto(errCommandDuplicate) - .ArgClass(existingCommandInstance.class) - .Arg(commandName) - .ArgClass(commandClass); - _.memory.Free(newCommandInstance); - return; - } - // Otherwise record new command - // `commandName` used as a key, do not deallocate it - registeredCommands.SetItem(commandName, newCommandInstance, true); -} - -/** - * Returns command based on a given name. - * - * @param commandName Name of the registered `Command` to return. - * Case-insensitive. - * @return Command, registered with a given name `commandName`. - * If no command with such name was registered - returns `none`. - */ -public final function Command GetCommand(Text commandName) +protected function AssociativeArray ToData() { - local Text commandNameLowerCase; - local Command commandInstance; - if (commandName == none) return none; - if (registeredCommands == none) return none; - commandNameLowerCase = commandName.LowerCopy(); - commandInstance = Command(registeredCommands.GetItem(commandNameLowerCase)); - commandNameLowerCase.FreeSelf(); - return commandInstance; + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(__().text.FromString("useChatInput"), useChatInput, true); + return data; } -/** - * Returns array of names of all available commands. - * - * @return Array of names of all available (registered) commands. - */ -public final function array GetCommandNames() +protected function FromData(AssociativeArray source) { - local int i; - local array keys; - local Text nextKeyAsText; - local array keysAsText; - if (registeredCommands == none) return keysAsText; - - keys = registeredCommands.GetKeys(); - for (i = 0; i < keys.length; i += 1) - { - nextKeyAsText = Text(keys[i]); - if (nextKeyAsText != none) { - keysAsText[keysAsText.length] = nextKeyAsText.Copy(); - } + if (source != none) { + useChatInput = source.GetBool(P("useChatInput")); } - return keysAsText; } -/** - * Handles user input: finds appropriate command and passes the rest of - * the arguments to it for further processing. - * - * @param parser Parser filled with user input that is expected to - * contain command's name and it's parameters. - * @param callerPlayer Player that caused this command call. - */ -public final function HandleInput(Parser parser, APlayer callerPlayer) +protected function Reset() { - local Command commandInstance; - local MutableText commandName; - if (parser == none) return; - if (!parser.Ok()) return; - - parser.MUntilMany(commandName, commandDelimiters, true, true); - commandInstance = GetCommand(commandName); - commandName.FreeSelf(); - if (parser.Ok() && commandInstance != none) { - commandInstance.ProcessInput(parser, callerPlayer).FreeSelf(); - } + useChatInput = true; } defaultproperties { - useChatInput = true - requiredListeners(0) = class'BroadcastListener_Commands' - errCommandDuplicate = (l=LOG_Error,m="Command `%1` is already registered with name '%2'. Command `%3` with the same name will be ignored.") + configName = "AcediaSystem" } \ No newline at end of file diff --git a/sources/Commands/Commands_Feature.uc b/sources/Commands/Commands_Feature.uc new file mode 100644 index 0000000..8c6542c --- /dev/null +++ b/sources/Commands/Commands_Feature.uc @@ -0,0 +1,183 @@ +/** + * This feature provides a mechanism to define commands that automatically + * parse their arguments into standard Acedia collection. It also allows to + * manage them (and specify limitation on how they can be called) in a + * centralized manner. + * Copyright 2021 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 Commands_Feature extends Feature + config(AcediaSystem); + +// Delimiters that always separate command name from it's parameters +var private array commandDelimiters; +// Registered commands, recorded as (, ) pairs +var private AssociativeArray registeredCommands; + +// Setting this to `true` enables players to input commands right in the chat +// by prepending them with "!" character. +var private /*config*/ bool useChatInput; + +var LoggerAPI.Definition errCommandDuplicate; + +protected function OnEnabled() +{ + registeredCommands = _.collections.EmptyAssociativeArray(); + // Macro selector + commandDelimiters[0] = P("@"); + // Key selector + commandDelimiters[1] = P("#"); + // Player array (possibly JSON array) + commandDelimiters[2] = P("["); + // Negation of the selector + commandDelimiters[3] = P("!"); +} + +protected function OnDisabled() +{ + _.memory.Free(registeredCommands); + registeredCommands = none; + commandDelimiters.length = 0; +} + +protected function SwapConfig( + AssociativeArray previousConfigData, + AssociativeArray newConfigData) +{ + if (newConfigData == none) { + return; + } + useChatInput = newConfigData.GetBool(P("useChatInput")); +} + +/** + * Checks whether this feature uses in-game chat input for commands. + * + * @return `true` iff this feature uses in-game chat input for commands. + */ +public final function bool UsingChatInput() +{ + return useChatInput; +} + +/** + * Registers given command class, making it available for usage. + * + * If `commandClass` provides command with a name that is already taken + * (comparison is case-insensitive) by a different command - a warning will be + * logged and newly passed `commandClass` discarded. + * + * @param commandClass New command class to register. + */ +public final function RegisterCommand(class commandClass) +{ + local Text commandName; + local Command newCommandInstance, existingCommandInstance; + if (commandClass == none) return; + if (registeredCommands == none) return; + + newCommandInstance = Command(_.memory.Allocate(commandClass, true)); + commandName = newCommandInstance.GetName(); + // Check for duplicates and report them + existingCommandInstance = Command(registeredCommands.GetItem(commandName)); + if (existingCommandInstance != none) + { + _.logger.Auto(errCommandDuplicate) + .ArgClass(existingCommandInstance.class) + .Arg(commandName) + .ArgClass(commandClass); + _.memory.Free(newCommandInstance); + return; + } + // Otherwise record new command + // `commandName` used as a key, do not deallocate it + registeredCommands.SetItem(commandName, newCommandInstance, true); +} + +/** + * Returns command based on a given name. + * + * @param commandName Name of the registered `Command` to return. + * Case-insensitive. + * @return Command, registered with a given name `commandName`. + * If no command with such name was registered - returns `none`. + */ +public final function Command GetCommand(Text commandName) +{ + local Text commandNameLowerCase; + local Command commandInstance; + if (commandName == none) return none; + if (registeredCommands == none) return none; + commandNameLowerCase = commandName.LowerCopy(); + commandInstance = Command(registeredCommands.GetItem(commandNameLowerCase)); + commandNameLowerCase.FreeSelf(); + return commandInstance; +} + +/** + * Returns array of names of all available commands. + * + * @return Array of names of all available (registered) commands. + */ +public final function array GetCommandNames() +{ + local int i; + local array keys; + local Text nextKeyAsText; + local array keysAsText; + if (registeredCommands == none) return keysAsText; + + keys = registeredCommands.GetKeys(); + for (i = 0; i < keys.length; i += 1) + { + nextKeyAsText = Text(keys[i]); + if (nextKeyAsText != none) { + keysAsText[keysAsText.length] = nextKeyAsText.Copy(); + } + } + return keysAsText; +} + +/** + * Handles user input: finds appropriate command and passes the rest of + * the arguments to it for further processing. + * + * @param parser Parser filled with user input that is expected to + * contain command's name and it's parameters. + * @param callerPlayer Player that caused this command call. + */ +public final function HandleInput(Parser parser, APlayer callerPlayer) +{ + local Command commandInstance; + local MutableText commandName; + if (parser == none) return; + if (!parser.Ok()) return; + + parser.MUntilMany(commandName, commandDelimiters, true, true); + commandInstance = GetCommand(commandName); + commandName.FreeSelf(); + if (parser.Ok() && commandInstance != none) { + commandInstance.ProcessInput(parser, callerPlayer).FreeSelf(); + } +} + +defaultproperties +{ + configClass = class'Commands' + requiredListeners(0) = class'BroadcastListener_Commands' + errCommandDuplicate = (l=LOG_Error,m="Command `%1` is already registered with name '%2'. Command `%3` with the same name will be ignored.") +} \ No newline at end of file diff --git a/sources/Feature.uc b/sources/Features/Feature.uc similarity index 59% rename from sources/Feature.uc rename to sources/Features/Feature.uc index 5e3eff1..038e898 100644 --- a/sources/Feature.uc +++ b/sources/Features/Feature.uc @@ -8,6 +8,11 @@ * and `Finalizer()` one should use `OnEnabled() and `OnDisabled()` methods. * Any instances created through other means will be automatically deallocated, * enforcing `Singleton`-like behavior for the `Feature` class. + * `Feature`s store their configuration in a different object + * `FeatureConfig`, that uses per-object-config and allows users to define + * several different versions of `Feature`'s settings. Each `Feature` must be + * in 1-to-1 relationship with one sub-class of `FeatureConfig`, that should be + * defined in `configClass` variable. * Copyright 2019 - 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. @@ -33,6 +38,17 @@ class Feature extends AcediaObject var private Feature activeInstance; var private int activeInstanceLifeVersion; +// Variables that store name and data from the config object that was +// chosen for this `Feature`. +// Data is expected to be in format that allows for JSON deserialization +// (see `JSONAPI.IsCompatible()` for details). +var private Text currentConfigName; +var private AssociativeArray currentConfig; + +// Class of this `Feature`'s config objects. Classes must be in 1-to-1 +// correspondence. +var protected const class configClass; + // Setting default value of this variable to 'true' prevents creation of // a `Feature`, even if no instances of it exist. This is used to ensure active // `Feature`s can only be created through the proper means and behave like @@ -52,6 +68,19 @@ var public const array< class > requiredListeners; // One should never launch or shut down this service manually. var protected const class serviceClass; +var private string defaultConfigName; + +var private LoggerAPI.Definition errorBadConfigData; + +public static function StaticConstructor() +{ + if (StaticConstructorGuard()) return; + super.StaticConstructor(); + if (default.configClass != none) { + default.configClass.static.Initialize(); + } +} + protected function Constructor() { local FeatureService myService; @@ -67,6 +96,8 @@ protected function Constructor() if (myService != none) { myService.SetOwnerFeature(self); } + ApplyConfig(default.currentConfigName); + default.currentConfigName = none; OnEnabled(); } @@ -84,9 +115,48 @@ protected function Finalizer() if (service != none) { service.Destroy(); } + if (currentConfig != none) { + currentConfig.Empty(true); + } + _.memory.Free(currentConfigName); + _.memory.Free(currentConfig); + default.currentConfigName = none; + currentConfigName = none; + currentConfig = none; default.activeInstance = none; } +/** + * Changes config for the caller `Feature` class. + * + * This method should only be called when caller `Feature` is enabled + * (allocated). To set initial config on this `Feature`'s start - specify it + * as a parameter to `EnableMe()` method. + * + * @param newConfigName Name of the config to apply to the caller `Feature`. + */ +public final function ApplyConfig(Text newConfigName) +{ + local AssociativeArray newConfigData; + newConfigData = configClass.static.LoadData(newConfigName); + if (newConfigData == none) + { + _.logger.Auto(errorBadConfigData).ArgClass(class); + // Fallback to "default" config + newConfigName = _.text.FromString(defaultConfigName); + configClass.static.NewConfig(newConfigName); + newConfigData = configClass.static.LoadData(newConfigName); + } + SwapConfig(currentConfig, newConfigData); + if (currentConfig != none) { + currentConfig.Empty(true); + } + _.memory.Free(currentConfigName); + _.memory.Free(currentConfig); + currentConfigName = newConfigName.Copy(); + currentConfig = newConfigData; +} + /** * Returns an instance of the `Feature` of the class used to call * this method. @@ -125,14 +195,18 @@ public final function Service GetService() } /** - * Checks if caller `Feature` should be auto-enabled on game starting. + * Returns name of the config that is configured to be auto-enabled for + * the caller `Feature`. * - * @return `true` if caller `Feature` should be auto-enabled and - * `false` otherwise. + * "Auto-enabled" means that `Feature` must be enabled at the server's start, + * unless launcher is instructed to skip it for a particular game mode. + * + * @return Name of the config configured to be auto-enabled for + * the caller `Feature`. `none` means `Feature` should not be auto-enabled. */ -public static final function bool IsAutoEnabled() +public static final function Text GetAutoEnabledConfig() { - return default.autoEnable; + return default.configClass.static.GetAutoEnabledConfig(); } /** @@ -154,12 +228,17 @@ public static final function bool IsEnabled() * * @return Active instance of the caller `Feature` class. */ -public static final function Feature EnableMe() +public static final function Feature EnableMe(Text configName) { local Feature newInstance; if (IsEnabled()) { return GetInstance(); } + // This value will be copied and forgotten in `Constructor()`, + // so we do not actually retain `configName` reference and it can be freed + // right after `EnableMe()` method call ends. + // Copying it here will mean doing extra work. + default.currentConfigName = configName; default.blockSpawning = false; newInstance = Feature(__().memory.Allocate(default.class)); default.activeInstance = newInstance; @@ -192,7 +271,7 @@ public static final function bool DisableMe() * * AVOID MANUALLY CALLING IT. */ -protected function OnEnabled(){} +protected function OnEnabled() { } /** * When using proper methods for enabling a `Feature`, @@ -200,7 +279,24 @@ protected function OnEnabled(){} * * AVOID MANUALLY CALLING IT. */ -protected function OnDisabled(){} +protected function OnDisabled() { } + +/** + * Will be called whenever caller `Feature` class must change it's config + * parameters. This can be done both when the `Feature` is enabled or disabled. + * + * @param previousConfigData Config data that was previously set for this + * `Feature` class. `none` is passed iff config is set for the first time. + * @param newConfigData New config data that caller `Feature`'s class + * must use. Guaranteed to not be `none`. + * + * AVOID MANUALLY CALLING IT. + * DO NOT DEALLOCATE PASSED VALUES. + * DO NOT USE PASSED VALUES OUTSIDE OF THIS METHOD CALL. + */ +protected function SwapConfig( + AssociativeArray previousConfigData, + AssociativeArray newConfigData) { } private static function SetListenersActiveStatus(bool newStatus) { @@ -216,5 +312,10 @@ defaultproperties { autoEnable = false blockSpawning = true + configClass = none serviceClass = none + + defaultConfigName = "default" + + errorBadConfigData = (l=LOG_Error,m="Bad config value was provided for `%1`. Falling back to the \"default\".") } \ No newline at end of file diff --git a/sources/Features/FeatureConfig.uc b/sources/Features/FeatureConfig.uc new file mode 100644 index 0000000..6ff6079 --- /dev/null +++ b/sources/Features/FeatureConfig.uc @@ -0,0 +1,316 @@ +/** + * Acedia's `Feature`s store their configuration in separate classes + * derived from this one. They allow to provide `Feature`s with several config + * presets and, potentially, swap them on-the-fly. + * To create a new config object for a `Feature` use following template: + * + * ```unrealscript + * class extends FeatureConfig + * perobjectconfig + * config(); + * + * // ... + * + * defaultproperties + * { + * configName = "" + * } + * ``` + * + * You should only define a new child class, along with implementing it's + * `FromData()`, `ToData()` and `DefaultIt()` methods and otherwise avoid + * directly using objects of this class. + * Copyright 2021 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 FeatureConfig extends AcediaObject + dependson(AssociativeArray) + abstract; + +// Name of the config object that was marked as "auto enabled". +// `none` iff none are set to be auto-enabled. +// Only it's default value is ever used. +var private Text autoEnabledConfig; + +// All config of a particular class only get loaded once per session +// (unless new one is created) and then accessed through this collection. +// Only it's default value is ever used. +var private AssociativeArray existingConfigs; + +// Stores name of the config where settings are to be stored. +// Must correspond to value in `config(...)` modifier in class definition. +var protected string configName; + +// Setting that tells Acedia whether or not to enable feature, +// corresponding to this config during initialization. +// Only one version of any specific class should have this flag set to +// `true`. Otherwise any config with this flag will be picked as "auto enabled" +// and log warning will be given. +// Only it's default value is ever used. +var private config bool autoEnable; + +var private LoggerAPI.Definition warningMultipleFeaturesAutoEnabled; + +/* These methods must be overloaded to store and load all the config +* variables inside an `AssociativeArray` collection. How exactly to store +* them is up to each `Feature` to decide, as long as it allows conversion into +* JSON (see `JSONAPI.IsCompatible()` for details). Note, however, that boxes +* can value boxes and references should be considered interchangeable. +* For example, even if you always save `int` value as a `IntRef` in +* `ToData()` method, it might be stored as `IntBox` in `FromData()` call. +* And vice versa. +* NOTE: DO NOT use `P()`, `C()`, `F()` or `T()` methods for keys or +* values in collections you return. All keys and values will be automatically +* deallocated when necessary, so these methods for creating `Text` values are +* not suitable. +*/ +protected function AssociativeArray ToData() { return none; } +protected function FromData(AssociativeArray source) {} + +/** + * This method must be overloaded to setup default values for all config + * variables. You should use it instead of the `defaultproperties` block. + */ +protected function DefaultIt() {} + +/** + * This loads all of the `FeatureConfig`'s settings objects into internal + * arrays. Must be called before any other methods. + */ +public static final function Initialize() +{ + local int i; + local Text nextName; + local FeatureConfig nextConfig; + local array names; + if (default.existingConfigs != none) { + return; + } + default.autoEnabledConfig = none; + default.existingConfigs = __().collections.EmptyAssociativeArray(); + names = GetPerObjectNames( default.configName, string(default.class.name), + MaxInt); + for (i = 0; i < names.length; i += 1) + { + if (names[i] == "") { + continue; + } + nextName = __().text.FromString(names[i]); + nextConfig = new(none, nextName.ToPlainString()) default.class; + default.existingConfigs.SetItem(nextName.LowerCopy(), nextConfig); + if (nextConfig.autoEnable) + { + if (default.autoEnabledConfig == none) + { + default.autoEnabledConfig = nextName; + continue; + } + else + { + __().logger + .Auto(default.warningMultipleFeaturesAutoEnabled) + .ArgClass(default.class) + .Arg(default.autoEnabledConfig.Copy()); + } + } + nextName.FreeSelf(); + } +} + +/** + * Returns name of the config object that is configured to be used for + * auto-enabled `Feature`. + * + * @return Name of the config object (case-insensitive), configured to be used + * for auto-enabled `Feature`. `none` if either class of the caller config + * object was not initialized or no config object was set to be used + * as auto-enabled. + */ +public static function Text GetAutoEnabledConfig() +{ + if (default.autoEnabledConfig != none) { + return default.autoEnabledConfig.Copy(); + } + return none; +} + +/** + * Sets (by name) config object to be used when it's corresponding `Feature` + * is auto-enabled. + * + * @param autoEnabledConfigName Name (case-insensitive) of the config to + * be used when it's corresponding `Feature` is auto-enabled. + * Passing `none` or name of non-existing config will prevent it's + * `Feature` in question from being auto-enabled at all. + * @return `true` iff some config was set to be used when it's `Feature` is + * auto-enabled, even if the same config was already configured to be used. + */ +public static function bool SetAutoEnabledConfig(Text autoEnabledConfigName) +{ + local Iter I; + local bool wasAutoEnabled; + local bool enabledConfig; + local Text nextConfigName; + local FeatureConfig nextConfig; + if (default.existingConfigs == none) { + return false; + } + I = default.existingConfigs.Iterate(); + for (I = default.existingConfigs.Iterate(); !I.HasFinished(); I.Next(true)) + { + nextConfigName = Text(I.GetKey()); + nextConfig = FeatureConfig(I.Get()); + wasAutoEnabled = nextConfig.autoEnable; + if (nextConfigName.Compare(autoEnabledConfigName, SCASE_INSENSITIVE)) + { + default.autoEnabledConfig = autoEnabledConfigName.LowerCopy(); + nextConfig.autoEnable = true; + enabledConfig = true; + } + else { + nextConfig.autoEnable = false; + } + if (wasAutoEnabled != nextConfig.autoEnable) { + nextConfig.SaveConfig(); + } + } + return enabledConfig; +} + +/** + * Returns array containing names of all available config objects. + * + * @return Array with names of all available config objects. + */ +public static function array AvailableConfigs() +{ + local array emptyResult; + if (default.existingConfigs != none) { + return default.existingConfigs.CopyTextKeys(); + } + return emptyResult; +} + +/** + * Loads Acedia's representation of settings data of a particular config + * object, given by the `name`. + * + * @param name Name of the config object, whos settings data is to + * be loaded. + * @return Settings data of a particular config object, given by the `name`. + * Expected to be in format that allows for JSON serialization + * (see `JSONAPI.IsCompatible()` for details). + * For correctly implemented config objects should only return `none` if + * their class was not yet initialized (see `self.Initialize()` method). +*/ +public final static function AssociativeArray LoadData(Text name) +{ + local AssociativeArray result; + local FeatureConfig requiredConfig; + if (default.existingConfigs == none) { + return none; + } + if (name != none) { + name = name.LowerCopy(); + } + requiredConfig = FeatureConfig(default.existingConfigs.GetItem(name)); + if (requiredConfig != none) { + result = requiredConfig.ToData(); + } + __().memory.Free(name); + return result; +} + +/** + * Saves Acedia's representation of settings data (`data`) for a particular + * config object, given by the `name`. + * + * @param name Name of the config object, whos settings data is to + * be modified. + * @param data New data for config variables. Expected to be in format that + * allows for JSON deserialization (see `JSONAPI.IsCompatible()` for + * details). +*/ +public final static function SaveData(Text name, AssociativeArray data) +{ + local FeatureConfig requiredConfig; + if (name != none) { + name = name.LowerCopy(); + } + if (default.existingConfigs != none) { + requiredConfig = FeatureConfig(default.existingConfigs.GetItem(name)); + } + if (requiredConfig != none) + { + requiredConfig.FromData(data); + requiredConfig.SaveConfig(); + } + __().memory.Free(name); +} + +/** + * Creates a brand new config object with a given name. + * + * Fails if config object with that name already exists. + * Names are case-insensitive. + * + * @param name Name of the new config object. + * @return `true` iff new config object was created. +*/ +public final static function bool NewConfig(Text name) +{ + local FeatureConfig oldConfig, newConfig; + if (name == none) return false; + if (default.existingConfigs == none) return false; + oldConfig = FeatureConfig(default.existingConfigs.GetItem(name)); + if (oldConfig != none) return false; + + newConfig = new(none, name.ToPlainString()) default.class; + newConfig.DefaultIt(); + newConfig.SaveConfig(); + default.existingConfigs.SetItem(name.LowerCopy(), newConfig); + return true; +} + +/** + * Deletes config object with a given name. + * Names are case-insensitive. + * + * If given config object exists, this method cannot fail. + * + * @param name Name of the config object to delete. +*/ +public final static function DeleteConfig(Text name) +{ + local AssociativeArray.Entry entry; + if (default.existingConfigs == none) { + return; + } + entry = default.existingConfigs.TakeEntry(name); + if (entry.value != none) { + entry.value.ClearConfig(); + } + __().memory.Free(entry.value); + __().memory.Free(entry.key); +} + +defaultproperties +{ + usesObjectPool = false + autoEnable = false + warningMultipleFeaturesAutoEnabled = (l=LOG_Warning,m="Multiple configs for `%1` were marked as \"auto enabled\". This is likely caused by an erroneous config. \"%2\" config will be used.") +} \ No newline at end of file diff --git a/sources/FeatureService.uc b/sources/Features/FeatureService.uc similarity index 100% rename from sources/FeatureService.uc rename to sources/Features/FeatureService.uc diff --git a/sources/Features/Tests/MockFeature.uc b/sources/Features/Tests/MockFeature.uc new file mode 100644 index 0000000..3e57c18 --- /dev/null +++ b/sources/Features/Tests/MockFeature.uc @@ -0,0 +1,49 @@ +/** + * Mock object for testing config functionality of Acedia's `Feature`s. + * Copyright 2021 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 MockFeature extends FeatureConfig + perobjectconfig + config(AcediaMockFeature); + +var public config int value; + +protected function AssociativeArray ToData() +{ + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetInt(P("value").Copy(), value, true); + return data; +} + +protected function FromData(AssociativeArray source) +{ + if (source != none) { + value = source.GetIntBy(P("/value")); + } +} + +protected function DefaultIt() +{ + value = 13; +} + +defaultproperties +{ + configName = "AcediaMockFeature" +} \ No newline at end of file diff --git a/sources/Features/Tests/TEST_FeatureConfig.uc b/sources/Features/Tests/TEST_FeatureConfig.uc new file mode 100644 index 0000000..852009d --- /dev/null +++ b/sources/Features/Tests/TEST_FeatureConfig.uc @@ -0,0 +1,113 @@ +/** + * Set of tests for `FeatureConfig` class. + * Copyright 2021 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 TEST_FeatureConfig extends TestCase + abstract; + +protected static function TESTS() +{ + class'MockFeature'.static.Initialize(); + Context("Testing `FeatureConfig` functionality."); + TEST_AvailableConfigs(); + TEST_DataGetSet(); + TEST_DataNew(); +} + +protected static function TEST_AvailableConfigs() +{ + local int i; + local bool foundConfig; + local array configNames; + configNames = class'MockFeature'.static.AvailableConfigs(); + Issue("Incorrect amount of configs are loaded."); + TEST_ExpectTrue(configNames.length == 3); + + Issue("Configs with incorrect names or values are loaded."); + for (i = 0; i < configNames.length; i += 1) + { + if (configNames[i].CompareToPlainString("default", SCASE_INSENSITIVE)) { + foundConfig = true; + } + } + TEST_ExpectTrue(foundConfig); + foundConfig = false; + for (i = 0; i < configNames.length; i += 1) + { + if (configNames[i].CompareToPlainString("other", SCASE_INSENSITIVE)) { + foundConfig = true; + } + } + TEST_ExpectTrue(foundConfig); + foundConfig = false; + for (i = 0; i < configNames.length; i += 1) + { + if (configNames[i].CompareToPlainString("another", SCASE_INSENSITIVE)) { + foundConfig = true; + } + } + TEST_ExpectTrue(foundConfig); +} + +protected static function TEST_DataGetSet() +{ + local AssociativeArray data, newData; + data = class'MockFeature'.static.LoadData(P("other")); + Issue("Wrong value is loaded from config."); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == 11); + + newData = __().collections.EmptyAssociativeArray(); + newData.SetItem(P("value"), __().box.int(903)); + class'MockFeature'.static.SaveData(P("other"), newData); + data = class'MockFeature'.static.LoadData(P("other")); + Issue("Wrong value is loaded from config after saving another value."); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == 903); + + Issue("`FeatureConfig` returns `AssociativeArray` reference that was" + @ "passed in `SaveData()` call instead of a new collection."); + TEST_ExpectTrue(data != newData); + + // Restore configs + data.SetItem(P("value"), __().box.int(11)); + class'MockFeature'.static.SaveData(P("other"), data); +} + +protected static function TEST_DataNew() +{ + local AssociativeArray data; + Issue("Creating new config with existing name succeeds."); + TEST_ExpectFalse(class'MockFeature'.static.NewConfig(P("another"))); + data = class'MockFeature'.static.LoadData(P("another")); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == -2956); + + Issue("Cannot create new config."); + TEST_ExpectTrue(class'MockFeature'.static.NewConfig(P("new_one"))); + + Issue("New config does not have expected default value."); + data = class'MockFeature'.static.LoadData(P("new_one")); + TEST_ExpectTrue(data.GetIntBy(P("/value")) == 13); + + // Restore configs, cannot properly test `DeleteConfig()` + class'MockFeature'.static.DeleteConfig(P("new_one")); +} + +defaultproperties +{ + caseName = "FeatureConfig" + caseGroup = "Features" +} \ No newline at end of file diff --git a/sources/Manifest.uc b/sources/Manifest.uc index f0353db..c3a0d96 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -22,7 +22,7 @@ defaultproperties { - features(0) = class'Commands' + features(0) = class'Commands_Feature' commands(0) = class'ACommandHelp' commands(1) = class'ACommandDosh' commands(2) = class'ACommandNick' @@ -55,4 +55,5 @@ defaultproperties testCases(20) = class'TEST_CommandDataBuilder' testCases(21) = class'TEST_LogMessage' testCases(22) = class'TEST_LocalDatabase' + testCases(23) = class'TEST_FeatureConfig' } \ No newline at end of file