/**
 * 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 <https://www.gnu.org/licenses/>.
 */
class Commands_Feature extends Feature
    dependson(CommandAPI)
    dependson(Commands);

//! This feature manages commands that automatically parse their arguments into standard Acedia
//! collections.
//!
//! # Implementation
//!
//!     Implementation is simple: calling a method `RegisterCommand()` adds
//! command into two caches `registeredCommands` for obtaining registered
//! commands by name and `groupedCommands` for obtaining arrays of commands by
//! their group name. These arrays are used for providing methods for fetching
//! arrays of commands and obtaining pre-allocated `Command` instances by their
//! name.
//!     Depending on settings, this feature also connects to corresponding
//! signals for catching "mutate"/chat input, then it checks user-specified name
//! for being an alias and picks correct command from `registeredCommands`.
//!     Emergency enabling this feature sets `emergencyEnabledMutate` flag that
//! enforces connecting to the "mutate" input.

/// Auxiliary struct for passing name of the command to call with pre-specified
/// sub-command name.
///
/// Normally sub-command name is parsed by the command itself, however command
/// aliases can try to enforce one.
struct CommandCallPair {
    var MutableText commandName;
    /// Not `none` in case it is enforced by an alias
    var MutableText subCommandName;
};

/// Auxiliary struct that stores all the information needed to load
/// a certain command
struct EntityLoadInfo {
    /// Command class to load.
    var public class<AcediaObject> entityClass;
    /// Name to load that command class under.
    var public Text name;
    /// Groups that are authorized to use that command.
    var public array<Text> authorizedGroups;
    /// Groups that are authorized to use that command.
    var public array<Text> groupsConfig;
};

/// Auxiliary struct for describing adding a particular command set to
/// a particular group of users.
struct CommandListGroupPair {
    /// Name of the command set to add
    var public Text commandListName;
    /// Name of the group, for which to add this set
    var public Text permissionGroup;
};

/// Auxiliary struct for describing a rule to rename a particular command for
/// compatibility reasons.
struct RenamingRulePair {
    /// Command class to rename 
    var public class<AcediaObject> class;
    /// Name to use for that class
    var public Text newName;
};

/// Tools that provide functionality of managing registered commands and votings
var private CommandAPI.CommandFeatureTools tools;

/// Delimiters that always separate command name from it's parameters
var private array<Text> commandDelimiters;

/// When this flag is set to true, mutate input becomes available despite
/// [`useMutateInput`] flag to allow to unlock server in case of an error
var private bool emergencyEnabledMutate;

var private /*config*/ bool useChatInput;
var private /*config*/ bool useMutateInput;
var private /*config*/ Text chatCommandPrefix;
var public /*config*/ array<string> commandGroup;
var public /*config*/ array<Commands.CommandSetGroupPair> addCommandSet;
var public /*config*/ array<Commands.RenamingRulePair> renamingRule;
var public /*config*/ array<Commands.RenamingRulePair> votingRenamingRule;

// Converted version of `commandGroup`
var private array<Text> permissionGroupOrder;
/// Converted version of `addCommandSet`
var private array<CommandListGroupPair> usedCommandLists;
/// Converted version of `renamingRule` and `votingRenamingRule`
var private array<RenamingRulePair> commandRenamingRules;
var private array<RenamingRulePair> votingRenamingRules;
// Name, under which `ACommandHelp` is registered
var private Text helpCommandName;

var LoggerAPI.Definition errServerAPIUnavailable, warnDuplicateRenaming, warnNoCommandList;
var LoggerAPI.Definition infoCommandAdded, infoVotingAdded;

protected function OnEnabled() {
    helpCommandName = P("help");
    // Macro selector
    commandDelimiters[0] = _.text.FromString("@");
    // Key selector
    commandDelimiters[1] = _.text.FromString("#");
    // Player array (possibly JSON array)
    commandDelimiters[2] = _.text.FromString("[");
    // Negation of the selector
    // NOT the same thing as default command prefix in chat
    commandDelimiters[3] = _.text.FromString("!");
    if (useChatInput) {
        _.chat.OnMessage(self).connect = HandleCommands;
    }
    else {
        _.chat.OnMessage(self).Disconnect();
    }
    if (useMutateInput || emergencyEnabledMutate) {
        if (__server() != none) {
            __server().unreal.mutator.OnMutate(self).connect = HandleMutate;
        } else {
            _.logger.Auto(errServerAPIUnavailable);
        }
    }
    LoadConfigArrays();
    // `SetPermissionGroupOrder()` must be called *after* loading configs
    tools.commands = CommandsTool(_.memory.Allocate(class'CommandsTool'));
    tools.votings = VotingsTool(_.memory.Allocate(class'VotingsTool'));
    tools.commands.SetPermissionGroupOrder(permissionGroupOrder);
    tools.votings.SetPermissionGroupOrder(permissionGroupOrder);
    _.commands._reloadFeature();
    // Uses `CommandAPI`, so must be done after `_reloadFeature()` call
    LoadCommands();
    LoadVotings();
}

protected function OnDisabled() {
    if (useChatInput) {
        _.chat.OnMessage(self).Disconnect();
    }
    if (useMutateInput && __server() != none) {
        __server().unreal.mutator.OnMutate(self).Disconnect();
    }

    useChatInput = false;
    useMutateInput = false;
    _.memory.Free3(tools.commands, tools.votings, chatCommandPrefix);
    tools.commands = none;
    tools.votings = none;
    chatCommandPrefix = none;

    _.memory.FreeMany(commandDelimiters);
    commandDelimiters.length = 0;

    _.memory.FreeMany(permissionGroupOrder);
    permissionGroupOrder.length = 0;

    FreeUsedCommandSets();
    FreeRenamingRules();

    _.commands._reloadFeature();
}

protected function SwapConfig(FeatureConfig config) {
    local Commands newConfig;

    newConfig = Commands(config);
    if (newConfig == none) {
        return;
    }
    _.memory.Free(chatCommandPrefix);
    chatCommandPrefix = _.text.FromString(newConfig.chatCommandPrefix);
    useChatInput = newConfig.useChatInput;
    useMutateInput = newConfig.useMutateInput;
    commandGroup = newConfig.commandGroup;
    addCommandSet = newConfig.addCommandList;
    renamingRule = newConfig.renamingRule;
    votingRenamingRule = newConfig.votingRenamingRule;
}

/// This method allows to forcefully enable `Command_Feature` along with
/// "mutate" input in case something goes wrong.
///
/// `Command_Feature` is a critical command to have running on your server and,
/// if disabled by accident, there will be no way of starting it again without
/// restarting the level or even editing configs.
public final static function EmergencyEnable() {
    local bool noWayToInputCommands;
    local Text autoConfig;
    local Commands_Feature feature;

    if (!IsEnabled()) {
        autoConfig = GetAutoEnabledConfig();
        EnableMe(autoConfig);
        __().memory.Free(autoConfig);
    }
    feature = Commands_Feature(GetEnabledInstance());
    noWayToInputCommands = !feature.emergencyEnabledMutate
        && !feature.IsUsingMutateInput()
        && !feature.IsUsingChatInput();
    if (noWayToInputCommands) {
        default.emergencyEnabledMutate = true;
        feature.emergencyEnabledMutate = true;
        if (__server() != none) {
            __server().unreal.mutator.OnMutate(feature).connect = HandleMutate;
        } else {
            __().logger.Auto(default.errServerAPIUnavailable);
        }
    }
}

/// Checks if `Commands_Feature` currently uses chat as input.
///
/// If `Commands_Feature` is not enabled, then it does not use anything
/// as input.
public final static function bool IsUsingChatInput() {
    local Commands_Feature instance;

    instance = Commands_Feature(GetEnabledInstance());
    if (instance != none) {
        return instance.useChatInput;
    }
    return false;
}

/// Checks if `Commands_Feature` currently uses mutate command as input.
///
/// If `Commands_Feature` is not enabled, then it does not use anything
/// as input.
public final static function bool IsUsingMutateInput() {
    local Commands_Feature instance;

    instance = Commands_Feature(GetEnabledInstance());
    if (instance != none) {
        return instance.useMutateInput;
    }
    return false;
}

/// Returns prefix that will indicate that chat message is intended to be
/// a command. By default "!".
///
/// If `Commands_Feature` is disabled, always returns `none`.
public final static function Text GetChatPrefix() {
    local Commands_Feature instance;

    instance = Commands_Feature(GetEnabledInstance());
    if (instance != none && instance.chatCommandPrefix != none) {
        return instance.chatCommandPrefix.Copy();
    }
    return none;
}

/// Returns name, under which [`ACommandHelp`] is registered.
///
/// If `Commands_Feature` is disabled, always returns `none`.
public final static function Text GetHelpCommandName() {
    local Commands_Feature instance;

    instance = Commands_Feature(GetEnabledInstance());
    if (instance != none && instance.helpCommandName != none) {
        return instance.helpCommandName.Copy();
    }
    return none;
}

/// Executes command based on the input.
///
/// Takes [`commandLine`] as input with command's call, finds appropriate
/// registered command instance and executes it with parameters specified in
/// the [`commandLine`].
///
/// [`callerPlayer`] has to be specified and represents instigator of this
/// command that will receive appropriate result/error messages.
///
/// Returns `true` iff command was successfully executed.
///
/// # Errors
///
/// Doesn't log any errors, but can complain about errors in name or parameters
/// to the [`callerPlayer`]
public final function bool HandleInput(BaseText input, EPlayer callerPlayer) {
    local bool result;
    local Parser wrapper;

    if (input == none) {
        return false;
    }
    wrapper = input.Parse();
    result = HandleInputWith(wrapper, callerPlayer);
    wrapper.FreeSelf();
    return result;
}

/// Executes command based on the input.
///
/// Takes [`commandLine`] as input with command's call, finds appropriate
/// registered command instance and executes it with parameters specified in
/// the [`commandLine`].
///
/// [`callerPlayer`] has to be specified and represents instigator of this
/// command that will receive appropriate result/error messages.
///
/// Returns `true` iff command was successfully executed.
///
/// # Errors
///
/// Doesn't log any errors, but can complain about errors in name or parameters
/// to the [`callerPlayer`]
public final function bool HandleInputWith(Parser parser, EPlayer callerPlayer) {
    local bool errorOccured;
    local User identity;
    local CommandAPI.CommandConfigInfo commandPair;
    local Command.CallData callData;
    local CommandCallPair callPair;

    if (parser == none)         return false;
    if (callerPlayer == none)   return false;
    if (!parser.Ok())           return false;

    identity = callerPlayer.GetIdentity();
    callPair = ParseCommandCallPairWith(parser);
    commandPair = _.commands.ResolveCommandForUser(callPair.commandName, identity);
    if (commandPair.instance == none || commandPair.usageForbidden) {
        if (callerPlayer != none && callerPlayer.IsExistent()) {
            callerPlayer
                .BorrowConsole()
                .Flush()
                .Say(F("{$TextFailure Command not found!}"));
        }
    }
    if (parser.Ok() && commandPair.instance != none && !commandPair.usageForbidden) {
        callData =
            commandPair.instance.ParseInputWith(parser, callerPlayer, callPair.subCommandName);
        errorOccured = commandPair.instance.Execute(callData, callerPlayer, commandPair.config);
        commandPair.instance.DeallocateCallData(callData);
    }
    _.memory.Free4(commandPair.instance, callPair.commandName, callPair.subCommandName, identity);
    return errorOccured;
}

// Parses command's name into `CommandCallPair` - sub-command is filled in case
// specified name is an alias with specified sub-command name.
private final function CommandCallPair ParseCommandCallPairWith(Parser parser) {
    local Text resolvedValue;
    local MutableText userSpecifiedName;
    local CommandCallPair result;
    local Text.Character dotCharacter;

    if (parser == none) return result;
    if (!parser.Ok())   return result;

    parser.MUntilMany(userSpecifiedName, commandDelimiters, true, true);
    resolvedValue = _.alias.ResolveCommand(userSpecifiedName);
    //  This isn't an alias
    if (resolvedValue == none) {
        result.commandName = userSpecifiedName;
        return result;
    }
    //  It is an alias - parse it
    dotCharacter = _.text.GetCharacter(".");
    resolvedValue.Parse()
        .MUntil(result.commandName, dotCharacter)
        .MatchS(".")
        .MUntil(result.subCommandName, dotCharacter)
        .FreeSelf();
    if (result.subCommandName.IsEmpty()) {
        result.subCommandName.FreeSelf();
        result.subCommandName = none;
    }
    resolvedValue.FreeSelf();
    return result;
}

private function bool HandleCommands(EPlayer sender, MutableText message, bool teamMessage) {
    local Parser parser;

    //  We are only interested in messages that start with `chatCommandPrefix`
    parser = _.text.Parse(message);
    if (!parser.Match(chatCommandPrefix).Ok()) {
        parser.FreeSelf();
        return true;
    }
    //  Pass input to command feature
    HandleInputWith(parser, sender);
    parser.FreeSelf();
    return false;
}

private function HandleMutate(string command, PlayerController sendingPlayer) {
    local Parser parser;
    local EPlayer sender;

    //  A lot of other mutators use these commands
    if (command ~= "help")      return;
    if (command ~= "version")   return;
    if (command ~= "status")    return;
    if (command ~= "credits")   return;

    parser = _.text.ParseString(command);
    sender = _.players.FromController(sendingPlayer);
    HandleInputWith(parser, sender);
    sender.FreeSelf();
    parser.FreeSelf();
}

private final function LoadConfigArrays() {
    local int i;
    local CommandListGroupPair nextCommandSetGroupPair;

    for (i = 0; i < commandGroup.length; i += 1) {
        permissionGroupOrder[i] = _.text.FromString(commandGroup[i]);
    }
    for (i = 0; i < addCommandSet.length; i += 1) {
        nextCommandSetGroupPair.commandListName = _.text.FromString(addCommandSet[i].name);
        nextCommandSetGroupPair.permissionGroup = _.text.FromString(addCommandSet[i].for);
        usedCommandLists[i] = nextCommandSetGroupPair;
    }
    FreeRenamingRules();
    commandRenamingRules = LoadRenamingRules(renamingRule);
    votingRenamingRules = LoadRenamingRules(votingRenamingRule);
}

private final function array<RenamingRulePair> LoadRenamingRules(
    array<Commands.RenamingRulePair> inputRules) {
    local int i, j;
    local RenamingRulePair nextRule;
    local array<RenamingRulePair> result;

    // Clear away duplicates
    for (i = 0; i < inputRules.length; i += 1) {
        j = i + 1;
        while (j < inputRules.length) {
            if (inputRules[i].rename == inputRules[j].rename) {
                _.logger.Auto(warnDuplicateRenaming)
                    .ArgClass(inputRules[i].rename)
                    .Arg(_.text.FromString(inputRules[i].to))
                    .Arg(_.text.FromString(inputRules[j].to));
                inputRules.Remove(j, 1);
            } else {
                j += 1;
            }
        }
    }
    // Translate rules
    for (i = 0; i < inputRules.length; i += 1) {
        nextRule.class = inputRules[i].rename;
        nextRule.newName = _.text.FromString(inputRules[i].to);
        if (nextRule.class == class'ACommandHelp') {
            _.memory.Free(helpCommandName);
            helpCommandName = nextRule.newName.Copy();
        }
        result[result.length] = nextRule;
    }
    return result;
}

private final function LoadCommands() {
    local int i, j;
    local Text nextName;
    local array<EntityLoadInfo> commandClassesToLoad;

    commandClassesToLoad = CollectAllCommandClasses();
    // Load command names to use, according to preferred names and name rules
    for (i = 0; i < commandClassesToLoad.length; i += 1) {
        nextName = none;
        for (j = 0; j < commandRenamingRules.length; j += 1) {
            if (commandClassesToLoad[i].entityClass == commandRenamingRules[j].class) {
                nextName = commandRenamingRules[j].newName.Copy();
                break;
            }
        }
        if (nextName == none) {
            nextName = class<Command>(commandClassesToLoad[i].entityClass)
                .static.GetPreferredName();
        }
        commandClassesToLoad[i].name = nextName;
    }
    // Actually load commands
    for (i = 0; i < commandClassesToLoad.length; i += 1) {
        _.commands.AddCommandAsync(
            class<Command>(commandClassesToLoad[i].entityClass),
            commandClassesToLoad[i].name);
        for (j = 0; j < commandClassesToLoad[i].authorizedGroups.length; j += 1) {
            _.commands.AuthorizeCommandUsageAsync(
                commandClassesToLoad[i].name,
                commandClassesToLoad[i].authorizedGroups[j],
                commandClassesToLoad[i].groupsConfig[j]);
        }
        _.logger.Auto(infoCommandAdded)
            .ArgClass(commandClassesToLoad[i].entityClass)
            .Arg(/*take*/ commandClassesToLoad[i].name);
    }
    for (i = 0; i < commandClassesToLoad.length; i += 1) {
        // `name` field was already released through `Arg()` logger function
        _.memory.FreeMany(commandClassesToLoad[i].authorizedGroups);
        _.memory.FreeMany(commandClassesToLoad[i].groupsConfig);
    }
}

private final function LoadVotings() {
    local int i, j;
    local Text nextName;
    local array<EntityLoadInfo> votingClassesToLoad;

    votingClassesToLoad = CollectAllVotingClasses();
    // Load voting names to use, according to preferred names and name rules
    for (i = 0; i < votingClassesToLoad.length; i += 1) {
        nextName = none;
        for (j = 0; j < votingRenamingRules.length; j += 1) {
            if (votingClassesToLoad[i].entityClass == votingRenamingRules[j].class) {
                nextName = votingRenamingRules[j].newName.Copy();
                break;
            }
        }
        if (nextName == none) {
            nextName = class<Voting>(votingClassesToLoad[i].entityClass)
                .static.GetPreferredName();
        }
        votingClassesToLoad[i].name = nextName;
    }
    // Actually load votings
    for (i = 0; i < votingClassesToLoad.length; i += 1) {
        _.commands.AddVotingAsync(
            class<Voting>(votingClassesToLoad[i].entityClass),
            votingClassesToLoad[i].name);
        for (j = 0; j < votingClassesToLoad[i].authorizedGroups.length; j += 1) {
            _.commands.AuthorizeVotingUsageAsync(
                votingClassesToLoad[i].name,
                votingClassesToLoad[i].authorizedGroups[j],
                votingClassesToLoad[i].groupsConfig[j]);
        }
        _.logger.Auto(infoVotingAdded)
            .ArgClass(votingClassesToLoad[i].entityClass)
            .Arg(/*take*/ votingClassesToLoad[i].name);
    }
    for (i = 0; i < votingClassesToLoad.length; i += 1) {
        // `name` field was already released through `Arg()` logger function
        _.memory.FreeMany(votingClassesToLoad[i].authorizedGroups);
        _.memory.FreeMany(votingClassesToLoad[i].groupsConfig);
    }
}

// Guaranteed to not return `none` items in the array
private final function array<EntityLoadInfo> CollectAllCommandClasses() {
    local int i;
    local bool debugging;
    local CommandList nextList;
    local array<EntityLoadInfo> result;

    debugging = _.environment.IsDebugging();
    class'CommandList'.static.Initialize();
    for (i = 0; i < usedCommandLists.length; i += 1) {
        nextList = CommandList(class'CommandList'.static
            .GetConfigInstance(usedCommandLists[i].commandListName));
        if (nextList != none) {
            if (!debugging && nextList.debugOnly) {
                continue;
            }
            MergeEntityClassArrays(
                result,
                /*take*/ nextList.GetCommandData(),
                usedCommandLists[i].permissionGroup);
        } else {
            _.logger.Auto(warnNoCommandList).Arg(usedCommandLists[i].commandListName.Copy());
        }
    }
    return result;
}

// Guaranteed to not return `none` items in the array
private final function array<EntityLoadInfo> CollectAllVotingClasses() {
    local int i;
    local bool debugging;
    local CommandList nextList;
    local array<EntityLoadInfo> result;

    debugging = _.environment.IsDebugging();
    class'CommandList'.static.Initialize();
    for (i = 0; i < usedCommandLists.length; i += 1) {
        nextList = CommandList(class'CommandList'.static
            .GetConfigInstance(usedCommandLists[i].commandListName));
        if (nextList != none) {
            if (!debugging && nextList.debugOnly) {
                continue;
            }
            MergeEntityClassArrays(
                result,
                /*take*/ nextList.GetVotingData(),
                usedCommandLists[i].permissionGroup);
        } else {
            _.logger.Auto(warnNoCommandList).Arg(usedCommandLists[i].commandListName.Copy());
        }
    }
    return result;
}

// Adds `newCommands` into `infoArray`, adding `commandsGroup` to
// their array `authorizedGroups`
//
// Assumes that all arguments aren't `none`.
//
// Guaranteed to not add `none` commands from `newCommands`.
//
// Assumes that items from `infoArray` all have `name` field set to `none`,
// will also leave them as `none`.
private final function MergeEntityClassArrays(
    out array<EntityLoadInfo> infoArray,
    /*take*/ array<CommandList.EntityConfigPair> newCommands,
    Text commandsGroup
) {
    local int i, infoToEditIndex;
    local EntityLoadInfo infoToEdit;
    local array<Text> editedArray;

    for (i = 0; i < newCommands.length; i += 1) {
        if (newCommands[i].class == none) {
            continue;
        }
        // Search for an existing record of class `newCommands[i]` in
        // `infoArray`. If found, copy to `infoToEdit` and index into
        // `infoToEditIndex`, else `infoToEditIndex` will hold the next unused
        // index in `infoArray`.
        infoToEditIndex = 0;
        while (infoToEditIndex < infoArray.length) {
            if (infoArray[infoToEditIndex].entityClass == newCommands[i].class) {
                infoToEdit = infoArray[infoToEditIndex];
                break;
            }
            infoToEditIndex += 1;
        }
        // Update data inside `infoToEdit`.
        infoToEdit.entityClass = newCommands[i].class;

        editedArray = infoToEdit.authorizedGroups;
        editedArray[editedArray.length] = commandsGroup.Copy();
        infoToEdit.authorizedGroups = editedArray;

        editedArray = infoToEdit.groupsConfig;
        editedArray[editedArray.length] = newCommands[i].config; // moving here
        infoToEdit.groupsConfig = editedArray;
        // Update `infoArray` with the modified record.
        infoArray[infoToEditIndex] = infoToEdit;
        // Forget about data `authorizedGroups` and `groupsConfig`:
        //
        // 1. Their references have already been moved into `infoArray` and
        //  don't need to be released;
        // 2. If we don't find corresponding struct inside `infoArray` on
        //  the next iteration, we'll override `commandClass`,
        //  but not `authorizedGroups`/`groupsConfig`, so we'll just reset them
        //  to empty now.
        //  (`name` field is expected to be `none` during this method)
        infoToEdit.authorizedGroups.length = 0;
        infoToEdit.groupsConfig.length = 0;
    }
}

private final function FreeUsedCommandSets() {
    local int i;

    for (i = 0; i < usedCommandLists.length; i += 1) {
        _.memory.Free(usedCommandLists[i].commandListName);
        _.memory.Free(usedCommandLists[i].permissionGroup);
    }
    usedCommandLists.length = 0;
}

private final function FreeRenamingRules() {
    local int i;

    for (i = 0; i < commandRenamingRules.length; i += 1) {
        _.memory.Free(commandRenamingRules[i].newName);
    }
    commandRenamingRules.length = 0;

    for (i = 0; i < votingRenamingRules.length; i += 1) {
        _.memory.Free(votingRenamingRules[i].newName);
    }
    votingRenamingRules.length = 0;
}

public final function CommandAPI.CommandFeatureTools _borrowTools() {
    return tools;
}

defaultproperties {
    configClass = class'Commands'
    errServerAPIUnavailable = (l=LOG_Error,m="Server API is currently unavailable. Input methods through chat and mutate command will not be available.")
    warnDuplicateRenaming = (l=LOG_Warning,m="Class `%1` has duplicate renaming rules: \"%2\" and \"%3\". Latter will be discarded. It is recommended that you fix your `AcediaCommands` configuration file.")
    warnNoCommandList = (l=LOG_Warning,m="Command list \"%1\" not found.")
    infoCommandAdded = (l=LOG_Info,m="`Commands_Feature` has resolved to load command `%1` as \"%2\".")
    infoVotingAdded = (l=LOG_Info,m="`Commands_Feature` has resolved to load voting `%1` as \"%2\".")
}