Browse Source

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.
pull/8/head
Anton Tarasenko 3 years ago
parent
commit
62fcc2f323
  1. 2
      config/AcediaSystem.ini
  2. 7
      sources/Commands/BroadcastListener_Commands.uc
  3. 10
      sources/Commands/BuiltInCommands/ACommandHelp.uc
  4. 144
      sources/Commands/Commands.uc
  5. 183
      sources/Commands/Commands_Feature.uc
  6. 117
      sources/Features/Feature.uc
  7. 316
      sources/Features/FeatureConfig.uc
  8. 0
      sources/Features/FeatureService.uc
  9. 49
      sources/Features/Tests/MockFeature.uc
  10. 113
      sources/Features/Tests/TEST_FeatureConfig.uc
  11. 3
      sources/Manifest.uc

2
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

7
sources/Commands/BroadcastListener_Commands.uc

@ -30,14 +30,15 @@ static function bool HandleText(
local Text messageAsText;
local APlayer callerPlayer;
local Parser parser;
local Commands commandFeature;
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());
commandFeature =
Commands_Feature(class'Commands_Feature'.static.GetInstance());
if (commandFeature == none) return true;
if (!commandFeature.useChatInput) 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())

10
sources/Commands/BuiltInCommands/ACommandHelp.uc

@ -75,9 +75,10 @@ private final function DisplayCommandList(APlayer player)
local Command nextCommand;
local Command.Data nextData;
local array<Text> commandNames;
local Commands commandsFeature;
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();
@ -103,9 +104,10 @@ private final function DisplayCommandHelpPages(
{
local int i;
local Command nextCommand;
local Commands commandsFeature;
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

144
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 <https://www.gnu.org/licenses/>.
*/
class Commands extends Feature
class Commands extends FeatureConfig
perobjectconfig
config(AcediaSystem);
// Delimiters that always separate command name from it's parameters
var private array<Text> commandDelimiters;
// Registered commands, recorded as (<command_name>, <command_instance>) 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<Command> 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<Text> GetCommandNames()
protected function FromData(AssociativeArray source)
{
local int i;
local array<AcediaObject> keys;
local Text nextKeyAsText;
local array<Text> 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"
}

183
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 <https://www.gnu.org/licenses/>.
*/
class Commands_Feature extends Feature
config(AcediaSystem);
// Delimiters that always separate command name from it's parameters
var private array<Text> commandDelimiters;
// Registered commands, recorded as (<command_name>, <command_instance>) 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<Command> 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<Text> GetCommandNames()
{
local int i;
local array<AcediaObject> keys;
local Text nextKeyAsText;
local array<Text> 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.")
}

117
sources/Feature.uc → 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<FeatureConfig> 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<Listener> > requiredListeners;
// One should never launch or shut down this service manually.
var protected const class<FeatureService> 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\".")
}

316
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 <FEATURE_NAME> extends FeatureConfig
* perobjectconfig
* config(<FEATURE_CONFIG>);
*
* // ...
*
* defaultproperties
* {
* configName = "<FEATURE_CONFIG>"
* }
* ```
*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string> 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<Text> AvailableConfigs()
{
local array<Text> 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.")
}

0
sources/FeatureService.uc → sources/Features/FeatureService.uc

49
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 <https://www.gnu.org/licenses/>.
*/
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"
}

113
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 <https://www.gnu.org/licenses/>.
*/
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<Text> 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"
}

3
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'
}
Loading…
Cancel
Save