Compare commits

...

22 Commits

Author SHA1 Message Date
Anton Tarasenko e8ae6fd8d1 Change votings to output outcome message in one line 1 year ago
Anton Tarasenko 93604c7690 Remove temporary comment 1 year ago
Anton Tarasenko 159f1dc5a1 Fix storing user groups in databases not working 1 year ago
Anton Tarasenko 15b1abc8c3 Fix infinite loop bug with UnflectAPI rollback 1 year ago
Anton Tarasenko ff31ef2472 Remove duplicate voting tests 1 year ago
Anton Tarasenko c2a8a5c7de Fix configs 1 year ago
Anton Tarasenko a27e893359 Change `InfoQueryHandler` to respect "help" command's name change 1 year ago
Anton Tarasenko 0ad28839bb Change "usergroups" commands to adapt to new `CommandsAPI` 1 year ago
Anton Tarasenko a26b0adf05 Change `UserAPI` to make "all" user group contain all players 1 year ago
Anton Tarasenko 80cecd1d20 Change Voting classes to work with new API 1 year ago
Anton Tarasenko 7ff806b104 Change built-in commands to support new `CommandAPI` 1 year ago
Anton Tarasenko be9ba80549 Add back voting tests 1 year ago
Anton Tarasenko d7ed4776b4 Fix formatting in some commands-related classes 1 year ago
Anton Tarasenko a7f1a98548 Change CommandAPI and feature to use tools 1 year ago
Anton Tarasenko 86228a960c Add signal classes for `CommandAPI` 1 year ago
Anton Tarasenko c1dccfc2d6 Change data convertion flag to `true` for all feature configs 1 year ago
Anton Tarasenko 001170e092 Add `IsSpectator()` check for `EPlayer` 1 year ago
Anton Tarasenko 87c7ee01bb Add `IntoStrings()` method to `TextAPI` 1 year ago
Anton Tarasenko c76f875620 Add tool classes for `Commands_Feature` 1 year ago
Anton Tarasenko 23dc639536 Change what files are used to store permissions 1 year ago
Anton Tarasenko 7ad3ca55f6 Fix return values in some signals/methods 1 year ago
Anton Tarasenko 757ae39b2e Change environment to not be in debug mode by default 1 year ago
  1. 4
      config/AcediaAliases_Commands.ini
  2. 117
      config/AcediaCommands.ini
  3. 45
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc
  4. 183
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc
  5. 9
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
  6. 10
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandSideEffects.uc
  7. 137
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc
  8. 566
      sources/BaseAPI/API/Commands/Command.uc
  9. 1566
      sources/BaseAPI/API/Commands/CommandAPI.uc
  10. 753
      sources/BaseAPI/API/Commands/CommandDataBuilder.uc
  11. 249
      sources/BaseAPI/API/Commands/CommandList.uc
  12. 304
      sources/BaseAPI/API/Commands/CommandParser.uc
  13. 66
      sources/BaseAPI/API/Commands/CommandPermissions.uc
  14. 59
      sources/BaseAPI/API/Commands/CommandRegistrationJob.uc
  15. 182
      sources/BaseAPI/API/Commands/Commands.uc
  16. 806
      sources/BaseAPI/API/Commands/Commands_Feature.uc
  17. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandAdded_Signal.uc
  18. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandAdded_Slot.uc
  19. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandRemoved_Signal.uc
  20. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandRemoved_Slot.uc
  21. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingAdded_Signal.uc
  22. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingAdded_Slot.uc
  23. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingEnded_Signal.uc
  24. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingEnded_Slot.uc
  25. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingRemoved_Signal.uc
  26. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingRemoved_Slot.uc
  27. 239
      sources/BaseAPI/API/Commands/PlayersParser.uc
  28. 351
      sources/BaseAPI/API/Commands/Tests/TEST_Voting.uc
  29. 306
      sources/BaseAPI/API/Commands/Tools/CmdItemsTool.uc
  30. 142
      sources/BaseAPI/API/Commands/Tools/CommandsTool.uc
  31. 177
      sources/BaseAPI/API/Commands/Tools/ItemCard.uc
  32. 119
      sources/BaseAPI/API/Commands/Tools/VotingsTool.uc
  33. 563
      sources/BaseAPI/API/Commands/Voting/TEST_Voting.uc
  34. 826
      sources/BaseAPI/API/Commands/Voting/Voting.uc
  35. 233
      sources/BaseAPI/API/Commands/Voting/VotingModel.uc
  36. 132
      sources/BaseAPI/API/Commands/Voting/VotingPermissions.uc
  37. 75
      sources/BaseAPI/API/Commands/Voting/VotingSettings.uc
  38. 4
      sources/BaseAPI/API/Unflect/UnflectApi.uc
  39. 5
      sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc
  40. 2
      sources/Chat/ChatAPI.uc
  41. 25
      sources/Data/Database/DBAPI.uc
  42. 1
      sources/Features/FeatureConfig.uc
  43. 33
      sources/InfoQueryHandler/InfoQueryHandler.uc
  44. 13
      sources/Players/EPlayer.uc
  45. 87
      sources/Text/TextAPI.uc
  46. 7
      sources/Users/ACommandUserGroups.uc
  47. 35
      sources/Users/Users_Feature.uc

4
config/AcediaAliases_Commands.ini

@ -1,8 +1,8 @@
; This config file allows you to configure command aliases.
; Remember that aliases are case-insensitive.
[AcediaCore.CommandAliasSource]
record=(alias="yes",value="vote yes")
record=(alias="no",value="vote no")
record=(alias="yes",value="vote.yes")
record=(alias="no",value="vote.no")
[help CommandAliases]
Alias="hlp"

117
config/AcediaCommands.ini

@ -0,0 +1,117 @@
[default Commands]
autoEnable=true
;= Setting this to `true` enables players to input commands with "mutate"
;= console command.
;= Default is `true`.
useMutateInput=true
;= Setting this to `true` enables players to input commands right in the chat
;= by prepending them with [`chatCommandPrefix`].
;= Default is `true`.
useChatInput=true
;= Chat messages, prepended by this prefix will be treated as commands.
;= Default is "!". Empty values are also treated as "!".
chatCommandPrefix=!
;= Allows to specify which user groups are used in determining command/votings
;= permission.
;= They must be specified in the order of importance: from the group with
;= highest level of permissions to the lowest. When determining player's
;= permission to use a certain command/voting, his group with the highest
;= available permissions will be used.
commandGroup=admin
commandGroup=moderator
commandGroup=trusted
commandGroup=all
;= Add a specified `CommandList` to the specified user group
addCommandList=(name="default",for="all")
addCommandList=(name="moderator",for="moderator")
addCommandList=(name="admin",for="admin")
addCommandList=(name="debug",for="admin")
;= Allows to specify a name for a certain command class
;=
;= NOTE:By default command choses that name by itself and its not recommended
;= to override it. You should only use this setting in case there is naming
;= conflict between commands from different packages.
;=renamingRule=(rename=class'ACommandHelp',to="lol")
;= Allows to specify a name for a certain voting class
;=
;= NOTE:By default voting choses that name by itself and its not recommended
;= to override it. You should only use this setting in case there is naming
;= conflict between votings from different packages.
;=votingRenamingRule=(rename=class'Voting',to="lol")
;= `CommandList` describes a set of commands and votings that can be made
;= available to users inside Commands feature
;=
;= Optionally, permission configs can be specified for commands and votings,
;= allowing server admins to create command lists for different groups player
;= with the same commands, but different permissions.
[default CommandList]
;= Allows to specify if this list should only be added when server is running
;= in debug mode.
;= `true` means yes, `false` means that list will always be available.
debugOnly=false
;= Adds a command of specified class with a "default" permissions config
command=class'ACommandHelp'
command=class'ACommandVote'
;= Adds a voting of specified class with a "default" permissions config
voting=class'Voting'
;= Adds a command of specified class with specified permissions config
;=commandWith=(cmd=,config="")
;= Adds a voting of specified class with specified permissions config
;=commandWith=(vtn=,config="")
[debug CommandList]
debugOnly=true
command=class'ACommandFakers'
[moderator CommandList]
command=class'ACommandNotify'
[admin CommandList]
command=class'ACommandSideEffects'
;= `VotingPermissions` describe use permission settings for a voting
[default VotingPermissions]
;= Determines the duration of the voting period, specified in seconds.
;= Zero or negative values mean unlimited voting period.
votingTime=30
;= Determines how draw will be interpreted.
;= `true` means draw counts as a vote's success, `false` means draw counts as a vote's failure.
drawEqualsSuccess=false
;= Determines whether spectators are allowed to vote.
allowSpectatorVoting=false
;= Specifies which group(s) of players are allowed to see who makes what vote.
allowedToVoteGroup=all
;= Specifies which group(s) of players are allowed to see who makes what vote.
allowedToSeeVotesGroup=all
;= Specifies which group(s) of players are allowed to forcibly end voting.
allowedToForceGroup=admin
allowedToForceGroup=moderator
[anonymous VotingPermissions]
votingTime=30
drawEqualsSuccess=false
allowSpectatorVoting=false
allowedToVoteGroup=all
allowedToSeeVotesGroup=admin
allowedToSeeVotesGroup=moderator
allowedToForceGroup=admin
allowedToForceGroup=moderator
[moderator VotingPermissions]
votingTime=60
drawEqualsSuccess=false
allowSpectatorVoting=false
allowedToVoteGroup=admin
allowedToVoteGroup=moderator
allowedToSeeVotesGroup=admin
allowedToForceGroup=admin
[admin VotingPermissions]
votingTime=60
drawEqualsSuccess=false
allowSpectatorVoting=true
allowedToVoteGroup=admin
allowedToSeeVotesGroup=admin
allowedToForceGroup=admin

45
sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc

@ -24,8 +24,12 @@ class ACommandFakers extends Command
var private array<UserID> fakers;
protected static function StaticFinalizer() {
__().memory.FreeMany(default.fakers);
default.fakers.length = 0;
}
protected function BuildData(CommandDataBuilder builder) {
builder.Name(P("fakers"));
builder.Group(P("debug"));
builder.Summary(P("Adds fake voters for testing \"vote\" command."));
builder.Describe(P("Displays current fake voters."));
@ -40,7 +44,11 @@ protected function BuildData(CommandDataBuilder builder) {
builder.ParamBoolean(P("vote_for"));
}
protected function Executed(CallData arguments, EPlayer instigator) {
protected function Executed(
CallData arguments,
EPlayer instigator,
CommandPermissions permissions
) {
if (arguments.subCommandName.IsEmpty()) {
DisplayCurrentFakers();
} else if (arguments.subCommandName.Compare(P("amount"), SCASE_INSENSITIVE)) {
@ -52,14 +60,8 @@ protected function Executed(CallData arguments, EPlayer instigator) {
}
}
public final function UpdateFakersForVoting() {
local Voting currentVoting;
currentVoting = GetCurrentVoting();
if (currentVoting != none) {
currentVoting.SetDebugVoters(fakers);
}
_.memory.Free(currentVoting);
public final static function /*borrow*/ array<UserID> BorrowDebugVoters() {
return default.fakers;
}
private final function CastVote(int fakerID, bool voteFor) {
@ -71,7 +73,7 @@ private final function CastVote(int fakerID, bool voteFor) {
.WriteLine(P("Faker number is out of bounds."));
return;
}
currentVoting = GetCurrentVoting();
currentVoting = _.commands.GetCurrentVoting();
if (currentVoting == none) {
callerConsole
.UseColor(_.color.TextFailure)
@ -86,6 +88,7 @@ private final function ChangeAmount(int newAmount) {
local int i;
local Text nextIDName;
local UserID nextID;
local Voting currentVoting;
if (newAmount < 0) {
callerConsole
@ -110,19 +113,12 @@ private final function ChangeAmount(int newAmount) {
}
fakers.length = newAmount;
}
UpdateFakersForVoting();
}
private function Voting GetCurrentVoting() {
local Commands_Feature feature;
local Voting result;
feature = Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
if (feature != none) {
result = feature.GetCurrentVoting();
feature.FreeSelf();
default.fakers = fakers;
currentVoting = _.commands.GetCurrentVoting();
if (currentVoting != none) {
currentVoting.SetDebugVoters(default.fakers);
_.memory.Free(currentVoting);
}
return result;
}
private function DisplayCurrentFakers() {
@ -135,7 +131,7 @@ private function DisplayCurrentFakers() {
callerConsole.WriteLine(P("No fakers!"));
return;
}
currentVoting = GetCurrentVoting();
currentVoting =_.commands.GetCurrentVoting();
for (i = 0; i < fakers.length; i += 1) {
nextNumber = _.text.FromIntM(i);
callerConsole
@ -163,4 +159,5 @@ private function DisplayCurrentFakers() {
}
defaultproperties {
preferredName = "fakers"
}

183
sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc

@ -1,6 +1,6 @@
/**
* Command for displaying help information about registered Acedia's commands.
* Copyright 2021-2022 Anton Tarasenko
* Copyright 2021-2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
@ -18,7 +18,8 @@
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class ACommandHelp extends Command
dependson(LoggerAPI);
dependson(LoggerAPI)
dependson(CommandAPI);
/**
* # `ACommandHelp`
@ -72,6 +73,8 @@ class ACommandHelp extends Command
// that uses sub-command names as keys and returns `ArrayList` of aliases.
var private HashTable commandToAliasesMap;
var private User callerUser;
var public const int TSPACE, TCOMMAND_NAME_FALLBACK, TPLUS;
var public const int TOPEN_BRACKET, TCLOSE_BRACKET, TCOLON_SPACE;
var public const int TKEY, TDOUBLE_KEY, TCOMMA_SPACE, TBOOLEAN, TINDENT;
@ -79,7 +82,8 @@ var public const int TBOOLEAN_TRUE_FALSE, TBOOLEAN_ENABLE_DISABLE;
var public const int TBOOLEAN_ON_OFF, TBOOLEAN_YES_NO;
var public const int TOPTIONS, TCMD_WITH_TARGET, TCMD_WITHOUT_TARGET;
var public const int TSEPARATOR, TLIST_REGIRESTED_CMDS, TEMPTY_GROUP;
var public const int TALIASES_FOR, TEMPTY, TDOT;
var public const int TALIASES_FOR, TEMPTY, TDOT, TNO_COMMAND_BEGIN;
var public const int TNO_COMMAND_END, TEMPTY_GROUP_BEGIN, TEMPTY_GROUP_END;
protected function Constructor()
{
@ -96,13 +100,13 @@ protected function Constructor()
protected function Finalizer()
{
super.Finalizer();
_.memory.Free(commandToAliasesMap);
commandToAliasesMap = none;
}
protected function BuildData(CommandDataBuilder builder)
{
builder.Name(P("help"));
builder.Group(P("core"));
builder.Summary(P("Displays detailed information about available commands."));
builder.OptionalParams();
@ -120,11 +124,16 @@ protected function BuildData(CommandDataBuilder builder)
builder.ParamTextList(P("groups"));
}
protected function Executed(Command.CallData callData, EPlayer callerPlayer)
{
protected function Executed(
Command.CallData callData,
EPlayer callerPlayer,
CommandPermissions permissions
) {
local bool printedSomething;
local HashTable parameters, options;
local ArrayList commandsToDisplay, commandGroupsToDisplay;
callerUser = callerPlayer.GetIdentity();
parameters = callData.parameters;
options = callData.options;
// Print command list if "--list" option was specified
@ -135,6 +144,7 @@ protected function Executed(Command.CallData callData, EPlayer callerPlayer)
commandGroupsToDisplay,
options.HasKey(P("aliases")));
_.memory.Free(commandGroupsToDisplay);
printedSomething = true;
}
// Help pages.
// Only need to print them if:
@ -144,9 +154,11 @@ protected function Executed(Command.CallData callData, EPlayer callerPlayer)
if (!options.HasKey(P("list")) || parameters.HasKey(P("commands")))
{
commandsToDisplay = parameters.GetArrayList(P("commands"));
DisplayCommandHelpPages(commandsToDisplay);
DisplayCommandHelpPages(commandsToDisplay, printedSomething);
_.memory.Free(commandsToDisplay);
}
_.memory.Free(callerUser);
callerUser = none;
}
// If instance of the `Aliases_Feature` is passed as an argument (allowing this
@ -249,15 +261,9 @@ private final function DisplayCommandLists(
{
local int i;
local array<Text> commandNames, groupsNames;
local Commands_Feature commandsFeature;
commandsFeature =
Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
if (commandsFeature == none) {
return;
}
if (commandGroupsToDisplay == none) {
groupsNames = commandsFeature.GetGroupsNames();
groupsNames = _.commands.GetGroupsNames();
}
else
{
@ -271,7 +277,7 @@ private final function DisplayCommandLists(
if (groupsNames[i] == none) {
continue;
}
commandNames = commandsFeature.GetCommandNamesInGroup(groupsNames[i]);
commandNames = _.commands.GetCommandNamesInGroup(groupsNames[i]);
if (commandNames.length > 0)
{
callerConsole.UseColorOnce(_.color.TextSubHeader);
@ -282,41 +288,45 @@ private final function DisplayCommandLists(
callerConsole.WriteLine(groupsNames[i]);
}
PrintCommandsNamesArray(
commandsFeature,
commandNames,
displayAliases);
_.memory.FreeMany(commandNames);
} else {
callerConsole.UseColor(_.color.TextFailure);
callerConsole.Write(T(TEMPTY_GROUP_BEGIN));
callerConsole.Write(groupsNames[i]);
callerConsole.WriteLine(T(TEMPTY_GROUP_END));
callerConsole.ResetColor();
}
}
_.memory.FreeMany(groupsNames);
commandsFeature.FreeSelf();
}
private final function PrintCommandsNamesArray(
Commands_Feature commandsFeature,
array<Text> commandsNamesArray,
bool displayAliases)
{
bool displayAliases
) {
local int i;
local Command nextCommand;
local Command.Data nextData;
local CommandAPI.CommandConfigInfo nextCommandPair;
for (i = 0; i < commandsNamesArray.length; i += 1)
{
nextCommand = commandsFeature.GetCommand(commandsNamesArray[i]);
if (nextCommand == none) {
continue;
}
nextData = nextCommand.BorrowData();
nextCommandPair = _.commands.ResolveCommandForUser(
commandsNamesArray[i],
callerUser);
if (nextCommandPair.instance != none && !nextCommandPair.usageForbidden) {
nextData = nextCommandPair.instance.BorrowData();
callerConsole
.UseColorOnce(_.color.textEmphasis)
.Write(nextData.name)
.Write(commandsNamesArray[i])
.Write(T(TCOLON_SPACE))
.WriteLine(nextData.summary);
if (displayAliases) {
PrintCommandAliases(nextData.name);
PrintCommandAliases(commandsNamesArray[i]);
}
_.memory.Free(nextCommand);
}
_.memory.Free(nextCommandPair.instance);
}
}
@ -397,46 +407,47 @@ private final function PrintAliasesArray(
callerConsole.WriteBlock();
}
private final function DisplayCommandHelpPages(ArrayList commandList)
{
private final function DisplayCommandHelpPages(ArrayList commandList, bool printedSomething) {
local int i;
local bool printedSomething;
local Text nextUserProvidedName;
local MutableText referredSubcommand;
local Command nextCommand;
local CommandAPI.CommandConfigInfo nextPair;
// If arguments were empty - at least display our own help page
if (commandList == none)
{
PrintHelpPageFor(BorrowData().name, none, BorrowData());
if (commandList == none) {
nextPair.instance = self;
PrintHelpPageFor(usedName, none, nextPair);
return;
}
// Otherwise - print help for specified commands
for (i = 0; i < commandList.GetLength(); i += 1)
{
for (i = 0; i < commandList.GetLength(); i += 1) {
nextUserProvidedName = commandList.GetText(i);
nextCommand = GetCommandFromUserProvidedName(
nextPair = GetCommandFromUserProvidedName(
nextUserProvidedName,
referredSubcommand);
if (nextCommand != none)
{
/*out*/ referredSubcommand);
if (nextPair.instance != none && !nextPair.usageForbidden) {
if (printedSomething) {
callerConsole.WriteLine(T(TSEPARATOR));
}
PrintHelpPageFor(
nextUserProvidedName,
referredSubcommand,
nextCommand.BorrowData());
nextPair);
printedSomething = true;
}
_.memory.Free(nextCommand);
} else if (nextPair.instance != none) {
callerConsole.UseColor(_.color.TextFailure);
callerConsole.Write(T(TNO_COMMAND_BEGIN));
callerConsole.Write(nextUserProvidedName);
callerConsole.WriteLine(T(TNO_COMMAND_END));
callerConsole.ResetColor();
}
_.memory.Free(nextPair.instance);
_.memory.Free(nextUserProvidedName);
_.memory.Free(referredSubcommand);
// `referredSubcommand` is passed as an `out` parameter on
// every iteration, so we need to prevent the possibility of its value
// being used.
// NOTE: `nextCommand` and `nextUserProvidedName` are just
// rewritten.
// NOTE: `nextCommand` and `nextUserProvidedName` are just rewritten.
referredSubcommand = none;
}
}
@ -446,56 +457,49 @@ private final function DisplayCommandHelpPages(ArrayList commandList)
// is passed) and is used to return name of the subcommand for returned
// `Command` that is specified by `nextUserProvidedName` (only relevant for
// aliases that refer to a particular subcommand).
private final function Command GetCommandFromUserProvidedName(
private final function CommandAPI.CommandConfigInfo GetCommandFromUserProvidedName(
BaseText nextUserProvidedName,
out MutableText referredSubcommand)
{
local Command result;
local CommandAPI.CommandConfigInfo result;
local Text commandAliasValue;
local Commands_Feature commandsFeature;
local MutableText parsedCommandName;
// Clear `out` parameter no matter what
if (referredSubcommand != none)
{
if (referredSubcommand != none) {
referredSubcommand.FreeSelf();
referredSubcommand = none;
}
// Try accessing (check availability of) `Commands_Feature`
commandsFeature =
Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
if (commandsFeature == none) {
return none;
}
// Try getting command using `nextUserProvidedName` as a literal name
result = commandsFeature.GetCommand(nextUserProvidedName);
if (result != none)
{
commandsFeature.FreeSelf();
result = _.commands.ResolveCommandForUser(nextUserProvidedName, callerUser);
if (result.instance != none) {
return result;
}
// On failure - try resolving it as an alias
commandAliasValue = _.alias.ResolveCommand(nextUserProvidedName);
ParseCommandNames(commandAliasValue, parsedCommandName, referredSubcommand);
result = commandsFeature.GetCommand(parsedCommandName);
result = _.commands.ResolveCommandForUser(parsedCommandName, callerUser);
if ( result.instance == none
|| !result.instance.IsSubCommandAllowed(referredSubcommand, result.config)) {
_.memory.Free(result.instance);
return result;
}
// Empty subcommand name from the alias is essentially no subcommand name
if (referredSubcommand != none && referredSubcommand.IsEmpty())
{
if (referredSubcommand != none && referredSubcommand.IsEmpty()) {
referredSubcommand.FreeSelf();
referredSubcommand = none;
}
_.memory.Free(commandAliasValue);
_.memory.Free(parsedCommandName);
commandsFeature.FreeSelf();
_.memory.Free2(commandAliasValue, parsedCommandName);
return result;
}
private final function PrintHelpPageFor(
BaseText commandAlias,
BaseText referredSubcommand,
Command.Data commandData)
{
CommandAPI.CommandConfigInfo commandPair
) {
local Text commandNameLowerCase, commandNameUpperCase;
// Get capitalized command name
commandNameUpperCase = commandAlias.UpperCopy();
// Print header: name + basic info
@ -503,7 +507,7 @@ private final function PrintHelpPageFor(
.Write(commandNameUpperCase)
.UseColor(_.color.textDefault);
commandNameUpperCase.FreeSelf();
if (commandData.requiresTarget) {
if (commandPair.instance.BorrowData().requiresTarget) {
callerConsole.WriteLine(T(TCMD_WITH_TARGET));
}
else {
@ -511,31 +515,27 @@ private final function PrintHelpPageFor(
}
// Print commands and options
commandNameLowerCase = commandAlias.LowerCopy();
PrintCommands(commandData, commandNameLowerCase, referredSubcommand);
PrintCommands(commandPair, commandNameLowerCase, referredSubcommand);
commandNameLowerCase.FreeSelf();
PrintOptions(commandData);
PrintOptions(commandPair.instance.BorrowData());
// Clean up
callerConsole.ResetColor().Flush();
}
private final function PrintCommands(
Command.Data data,
CommandAPI.CommandConfigInfo commandPair,
BaseText commandName,
BaseText referredSubcommand)
{
BaseText referredSubcommand
) {
local int i;
local array<SubCommand> subCommands;
local array<Command.SubCommand> subCommands;
subCommands = data.subCommands;
for (i = 0; i < subCommands.length; i += 1)
{
if ( referredSubcommand == none
|| referredSubcommand.Compare(subCommands[i].name))
{
PrintSubCommand(
subCommands[i],
commandName,
referredSubcommand != none);
subCommands = commandPair.instance.BorrowData().subCommands;
for (i = 0; i < subCommands.length; i += 1) {
if (referredSubcommand == none || referredSubcommand.Compare(subCommands[i].name)) {
if (commandPair.instance.IsSubCommandAllowed(subCommands[i].name, commandPair.config)) {
PrintSubCommand(subCommands[i], commandName, referredSubcommand != none);
}
}
}
}
@ -719,4 +719,13 @@ defaultproperties
stringConstants(21) = ""
TDOT = 22
stringConstants(22) = "."
TNO_COMMAND_BEGIN = 23
stringConstants(23) = "Command `"
TNO_COMMAND_END = 24
stringConstants(24) = "` not found!"
TEMPTY_GROUP_BEGIN = 25
stringConstants(25) = "No commands in group \""
TEMPTY_GROUP_END = 26
stringConstants(26) = "\"!"
preferredName = "help"
}

9
sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc

@ -23,7 +23,6 @@ class ACommandNotify extends Command
dependsOn(ChatApi);
protected function BuildData(CommandDataBuilder builder) {
builder.Name(P("notify"));
builder.Group(P("core"));
builder.Summary(P("Notifies players with provided message."));
builder.ParamText(P("message"));
@ -43,7 +42,12 @@ protected function BuildData(CommandDataBuilder builder) {
builder.ParamText(P("channel_name"));
}
protected function ExecutedFor(EPlayer target, CallData arguments, EPlayer instigator) {
protected function ExecutedFor(
EPlayer target,
CallData arguments,
EPlayer instigator,
CommandPermissions permissions
) {
local Text title, message, plainTitle, plainMessage;
plainMessage = arguments.parameters.GetText(P("message"));
@ -61,4 +65,5 @@ protected function ExecutedFor(EPlayer target, CallData arguments, EPlayer insti
}
defaultproperties {
preferredName = "notify"
}

10
sources/BaseAPI/API/Commands/BuiltInCommands/ACommandSideEffects.uc

@ -36,8 +36,7 @@ protected function Finalizer() {
}
protected function BuildData(CommandDataBuilder builder) {
builder.Name(P("sideeffects"));
builder.Group(P("core"));
builder.Group(P("debug"));
builder.Summary(P("Displays information about current side effects."));
builder.Describe(P("This command allows to display current side effects, optionally filtering"
@ "them by specified package names."));
@ -54,7 +53,11 @@ protected function BuildData(CommandDataBuilder builder) {
builder.Describe(P("Display verbose information about each side effect."));
}
protected function Executed(CallData arguments, EPlayer instigator) {
protected function Executed(
CallData arguments,
EPlayer instigator,
CommandPermissions permissions
) {
local UserID playerID;
local array<SideEffect> relevantSideEffects;
local ArrayList packagesList, storedSideEffectsList;
@ -190,4 +193,5 @@ private function ShowInfoFor(UserID playerID, int sideEffectIndex) {
}
defaultproperties {
preferredName = "sideeffects"
}

137
sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc

@ -19,62 +19,93 @@
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class ACommandVote extends Command;
class ACommandVote extends Command
dependson(CommandAPI)
dependson(VotingModel);
var private CommandDataBuilder dataBuilder;
protected function Constructor() {
ResetVotingInfo();
_.commands.OnVotingAdded(self).connect = AddVotingInfo;
_.commands.OnVotingRemoved(self).connect = HandleRemovedVoting;
_.chat.OnVoiceMessage(self).connect = VoteWithVoice;
}
protected function Finalizer() {
super.Finalizer();
_.memory.Free(dataBuilder);
dataBuilder = none;
_.commands.OnVotingAdded(self).Disconnect();
_.commands.OnVotingRemoved(self).Disconnect();
_.chat.OnVoiceMessage(self).Disconnect();
}
protected function BuildData(CommandDataBuilder builder) {
builder.Name(P("vote"));
builder.Group(P("core"));
builder.Summary(P("Allows players to initiate any available voting."
@ "Votings themselves are added as sub-commands."));
@ "Voting options themselves are specified as sub-commands."));
builder.Describe(P("Default command simply displaces information about current vote."));
dataBuilder.SubCommand(P("yes"));
builder.Describe(P("Vote `yes` on the current vote."));
dataBuilder.SubCommand(P("no"));
builder.Describe(P("Vote `no` on the current vote."));
builder.Option(P("force"));
builder.Describe(P("Tries to force voting to end immediately with the desired result."));
}
protected function Executed(CallData arguments, EPlayer instigator) {
protected function Executed(
CallData arguments,
EPlayer instigator,
CommandPermissions permissions
) {
local bool forcingVoting;
local VotingModel.ForceEndingType forceType;
local Voting currentVoting;
local Commands_Feature feature;
feature = Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
if (feature == none) {
callerConsole
.UseColor(_.color.TextFailure)
.WriteLine(P("Feature responsible for commands and voting isn't enabled."
@ "This is unexpected, something broke terribly."));
return;
} else {
currentVoting = feature.GetCurrentVoting();
}
forcingVoting = arguments.options.HasKey(P("force"));
currentVoting = _.commands.GetCurrentVoting();
if (arguments.subCommandName.IsEmpty()) {
DisplayInfoAboutVoting(instigator, currentVoting);
} else if (arguments.subCommandName.Compare(P("yes"), SCASE_INSENSITIVE)) {
CastVote(currentVoting, instigator, true);
forceType = FET_Success;
} else if (arguments.subCommandName.Compare(P("no"), SCASE_INSENSITIVE)) {
CastVote(currentVoting, instigator, false);
forceType = FET_Failure;
} else if (StartVoting(arguments, currentVoting, instigator)) {
_.memory.Free(currentVoting);
currentVoting = _.commands.GetCurrentVoting();
forceType = FET_Success;
} else {
StartVoting(arguments.subCommandName, feature, currentVoting, instigator);
forcingVoting = false;
}
if (currentVoting != none && !currentVoting.HasEnded() && forcingVoting) {
if (currentVoting.ForceEnding(instigator, forceType) == FEO_Forbidden) {
callerConsole
.WriteLine(F("You {$TextNegative aren't allowed} to forcibly end current voting"));
}
}
_.memory.Free(currentVoting);
}
private final function VoteWithVoice(EPlayer sender, ChatApi.BuiltInVoiceMessage message) {
local Voting currentVoting;
currentVoting = _.commands.GetCurrentVoting();
if (message == BIVM_AckYes) {
CastVote(currentVoting, sender, true);
}
if (message == BIVM_AckNo) {
CastVote(currentVoting, sender, false);
}
_.memory.Free(feature);
_.memory.Free(currentVoting);
}
/// Adds sub-command information about given voting with a given name.
public final function AddVotingInfo(BaseText processName, class<Voting> processClass) {
public final function AddVotingInfo(class<Voting> processClass, Text processName) {
if (processName == none) return;
if (processClass == none) return;
if (dataBuilder == none) return;
@ -84,6 +115,19 @@ public final function AddVotingInfo(BaseText processName, class<Voting> processC
commandData = dataBuilder.BorrowData();
}
public final function HandleRemovedVoting(class<Voting> votingClass) {
local int i;
local array<Text> votingsNames;
ResetVotingInfo();
// Rebuild the whole voting data
votingsNames = _.commands.GetAllVotingsNames();
for (i = 0; i < votingsNames.length; i += 1) {
AddVotingInfo(_.commands.GetVotingClass(votingsNames[i]), votingsNames[i]);
}
_.memory.FreeMany(votingsNames);
}
/// Clears all sub-command information added from [`Voting`]s.
public final function ResetVotingInfo() {
_.memory.Free(dataBuilder);
@ -109,41 +153,54 @@ private final function CastVote(Voting currentVoting, EPlayer voter, bool voteFo
}
// Assumes all arguments aren't `none`.
private final function StartVoting(
BaseText votingName,
Commands_Feature feature,
private final function bool StartVoting(
CallData arguments,
Voting currentVoting,
EPlayer instigator
) {
local Command fakersCommand;
local Voting newVoting;
local Commands_Feature.StartVotingResult result;
local User callerUser;
local CommandAPI.VotingConfigInfo pair;
local CommandAPI.StartVotingResult result;
result = feature.StartVoting(votingName);
// Handle errors
if (result == SVR_UnknownVoting) {
callerUser = instigator.GetIdentity();
pair = _.commands.ResolveVotingForUser(arguments.subCommandName, callerUser);
_.memory.Free(callerUser);
if (pair.votingClass == none) {
callerConsole
.UseColor(_.color.TextFailure)
.Write(P("Unknown voting option \""))
.Write(votingName)
.Write(arguments.subCommandName)
.WriteLine(P("\""));
return;
} else if (result == SVR_AlreadyInProgress) {
return false;
}
if (pair.usageForbidden) {
callerConsole
.UseColor(_.color.TextFailure)
.Write(P("You aren't allowed to start \""))
.Write(arguments.subCommandName)
.WriteLine(P("\" voting"));
return false;
}
result = _.commands.StartVoting(pair, arguments.parameters);
Log("Result:" @ result);
// Handle errors.
// `SVR_UnknownVoting` is impossible, since we've already checked that
// `pair.votingClass != none`)
if (result == SVR_AlreadyInProgress) {
callerConsole
.UseColor(_.color.TextWarning)
.WriteLine(P("Another voting is already in progress!"));
return;
}
// Inform new voting about fake voters, in case we're debugging
if (currentVoting == none && _.environment.IsDebugging()) {
fakersCommand = feature.GetCommand(P("fakers"));
if (fakersCommand != none && fakersCommand.class == class'ACommandFakers') {
ACommandFakers(fakersCommand).UpdateFakersForVoting();
return false;
}
_.memory.Free(fakersCommand);
if (result == SVR_NoVoters) {
callerConsole
.UseColor(_.color.TextWarning)
.WriteLine(P("There are no players eligible for that voting."));
return false;
}
// Cast a vote from instigator
newVoting = feature.GetCurrentVoting();
newVoting = _.commands.GetCurrentVoting();
if (newVoting != none) {
newVoting.CastVote(instigator, true);
} else {
@ -151,9 +208,13 @@ private final function StartVoting(
.UseColor(_.color.TextFailure)
.WriteLine(P("Voting should be available, but it isn't."
@ "This is unexpected, something broke terribly."));
_.memory.Free(newVoting);
return false;
}
_.memory.Free(newVoting);
return true;
}
defaultproperties {
preferredName = "vote"
}

566
sources/BaseAPI/API/Commands/Command.uc

@ -54,92 +54,97 @@ class Command extends AcediaObject
//!
//! # Implementation
//!
//! The idea of `Command`'s implementation is simple: command is basically the `Command.Data` struct
//! that is filled via `CommandDataBuilder`.
//! Whenever command is called it uses `CommandParser` to parse user's input based on its
//! `Command.Data` and either report error (in case of failure) or pass make
//! `Executed()`/`ExecutedFor()` calls (in case of success).
//! The idea of `Command`'s implementation is simple: command is basically the
//! `Command.Data` struct that is filled via `CommandDataBuilder`.
//! Whenever command is called it uses `CommandParser` to parse user's input
//! based on its `Command.Data` and either report error (in case of failure) or
//! pass make `Executed()`/`ExecutedFor()` calls (in case of success).
//!
//! When command is called is decided by `Commands_Feature` that tracks possible user inputs
//! (and provides `HandleInput()`/`HandleInputWith()` methods for adding custom command inputs).
//! That feature basically parses first part of the command: its name (not the subcommand's names)
//! and target players (using `PlayersParser`, but only if command is targeted).
//! When command is called is decided by `Commands_Feature` that tracks possible
//! user inputs (and provides `HandleInput()`/`HandleInputWith()` methods for
//! adding custom command inputs). That feature basically parses first part of
//! the command: its name (not the subcommand's names) and target players
//! (using `PlayersParser`, but only if command is targeted).
//!
//! Majority of the command-related code either serves to build `Command.Data` or to parse command
//! input by using it (`CommandParser`).
//! Majority of the command-related code either serves to build `Command.Data`
//! or to parse command input by using it (`CommandParser`).
/// Possible errors that can arise when parsing command parameters from user input
/// Possible errors that can arise when parsing command parameters from user
/// input
enum ErrorType {
// No error
/// No error
CET_None,
// Bad parser was provided to parse user input (this should not be possible)
/// Bad parser was provided to parse user input (this should not be possible)
CET_BadParser,
// Sub-command name was not specified or was incorrect
// (this should not be possible)
/// Sub-command name was not specified or was incorrect
/// (this should not be possible)
CET_NoSubCommands,
// Specified sub-command does not exist
// (only relevant when it is enforced for parser, e.g. by an alias)
/// Specified sub-command does not exist
/// (only relevant when it is enforced for parser, e.g. by an alias)
CET_BadSubCommand,
// Required param for command / option was not specified
/// Required param for command / option was not specified
CET_NoRequiredParam,
CET_NoRequiredParamForOption,
// Unknown option key was specified
/// Unknown option key was specified
CET_UnknownOption,
/// Unknown short option key was specified
CET_UnknownShortOption,
// Same option appeared twice in one command call
/// Same option appeared twice in one command call
CET_RepeatedOption,
// Part of user's input could not be interpreted as a part of
// command's call
/// Part of user's input could not be interpreted as a part of
/// command's call
CET_UnusedCommandParameters,
// In one short option specification (e.g. '-lah') several options require parameters:
// this introduces ambiguity and is not allowed
/// In one short option specification (e.g. '-lah') several options require
/// parameters: this introduces ambiguity and is not allowed
CET_MultipleOptionsWithParams,
// (For targeted commands only)
// Targets are specified incorrectly (or none actually specified)
/// Targets are specified incorrectly (for targeted commands only)
CET_IncorrectTargetList,
// No targets are specified (for targeted commands only)
CET_EmptyTargetList
};
/// Structure that contains all the information about how `Command` was called.
struct CallData {
// Targeted players (if applicable)
/// Targeted players (if applicable)
var public array<EPlayer> targetPlayers;
// Specified sub-command and parameters/options
/// Specified sub-command and parameters/options
var public Text subCommandName;
// Provided parameters and specified options
/// Provided parameters and specified options
var public HashTable parameters;
var public HashTable options;
// Errors that occurred during command call processing are described by
// error type and optional error textual name of the object
// (parameter, option, etc.) that caused it.
/// Errors that occurred during command call processing are described by
/// error type.
var public ErrorType parsingError;
/// Optional error textual name of the object (parameter, option, etc.)
/// that caused it.
var public Text errorCause;
};
/// Possible types of parameters.
enum ParameterType {
// Parses into `BoolBox`
/// Parses into `BoolBox`
CPT_Boolean,
// Parses into `IntBox`
/// Parses into `IntBox`
CPT_Integer,
// Parses into `FloatBox`
/// Parses into `FloatBox`
CPT_Number,
// Parses into `Text`
/// Parses into `Text`
CPT_Text,
// Special parameter that consumes the rest of the input into `Text`
/// Special parameter that consumes the rest of the input into `Text`
CPT_Remainder,
// Parses into `HashTable`
/// Parses into `HashTable`
CPT_Object,
// Parses into `ArrayList`
/// Parses into `ArrayList`
CPT_Array,
// Parses into any JSON value
/// Parses into any JSON value
CPT_JSON,
// Parses into an array of specified players
/// Parses into an array of specified players
CPT_Players
};
/// Possible forms a boolean variable can be used as.
/// Boolean parameter can define it's preferred format, which will be used for help page generation.
/// Boolean parameter can define it's preferred format, which will be used for
/// help page generation.
enum PreferredBooleanFormat {
PBF_TrueFalse,
PBF_EnableDisable,
@ -149,91 +154,117 @@ enum PreferredBooleanFormat {
// Defines a singular command parameter
struct Parameter {
// Display name (for the needs of help page displaying)
/// Display name (for the needs of help page displaying)
var Text displayName;
// Type of value this parameter would store
/// Type of value this parameter would store
var ParameterType type;
// Does it take only a singular value or can it contain several of them,
// written in a list
/// Does it take only a singular value or can it contain several of them,
/// written in a list
var bool allowsList;
// Variable name that will be used as a key to store parameter's value
/// Variable name that will be used as a key to store parameter's value
var Text variableName;
// (For `CPT_Boolean` type variables only) - preferred boolean format,
// used in help pages
/// (For `CPT_Boolean` type variables only) - preferred boolean format,
/// used in help pages
var PreferredBooleanFormat booleanFormat;
// `CPT_Text` can be attempted to be auto-resolved as an alias from some source during parsing.
// For command to attempt that, this field must be not-`none` and contain the name of
// the alias source (either "weapon", "color", "feature", "entity" or some kind of custom alias
// source name).
//
// Only relevant when given value is prefixed with "$" character.
/// `CPT_Text` can be attempted to be auto-resolved as an alias from some
/// source during parsing.
/// For command to attempt that, this field must be not-`none` and contain
/// the name of the alias source (either "weapon", "color", "feature",
/// "entity" or some kind of custom alias source name).
///
/// Only relevant when given value is prefixed with "$" character.
var Text aliasSourceName;
};
// Defines a sub-command of a this command (specified as "<command> <sub_command>").
//
// Using sub-command is not optional, but if none defined (in `BuildData()`) / specified by
// the player - an empty (`name.IsEmpty()`) one is automatically created / used.
/// Defines a sub-command of a this command
/// (specified as "<command> <sub_command>").
///
/// Using sub-command is not optional, but if none defined
/// (in `BuildData()`) / specified by the player - an empty (`name.IsEmpty()`)
/// one is automatically created / used.
struct SubCommand {
// Cannot be `none`
/// Name of the sub command. Cannot be `none`.
var Text name;
// Can be `none`
/// Human-readable description of the subcommand. Can be `none`.
var Text description;
/// List of required parameters of this [`Command`].
var array<Parameter> required;
/// List of optional parameters of this [`Command`].
var array<Parameter> optional;
};
// Defines command's option (options are specified by "--long" or "-l").
// Options are independent from sub-commands.
/// Defines command's option (options are specified by "--long" or "-l").
/// Options are independent from sub-commands.
struct Option {
/// [`Option`]'s short name, i.e. a single letter "f" that can be specified
/// in, e.g. "-laf" type option listings
var BaseText.Character shortName;
/// [`Option`]'s full name, e.g. "--force".
var Text longName;
/// Human-readable description of the option. Can be `none`.
var Text description;
// Option can also have their own parameters
/// List of required parameters of this [`Command::Option`].
var array<Parameter> required;
/// List of required parameters of this [`Command::Option`].
var array<Parameter> optional;
};
// Structure that defines what sub-commands and options command has
// (and what parameters they take)
/// Structure that defines what sub-commands and options command has
/// (and what parameters they take)
struct Data {
// Default command name that will be used unless Acedia is configured to
// do otherwise
var protected Text name;
// Command group this command belongs to
/// Command group this command belongs to
var protected Text group;
// Short summary of what command does (recommended to
// keep it to 80 characters)
/// Short summary of what command does (recommended to
/// keep it to 80 characters)
var protected Text summary;
/// Available subcommands.
var protected array<SubCommand> subCommands;
/// Available options, common to all subcommands.
var protected array<Option> options;
/// `true` iff related [`Command`] targets players.
var protected bool requiresTarget;
};
var protected Data commandData;
/// Setting variable that defines a name that will be chosen for command by
/// default.
var protected const string preferredName;
/// Name that was used to register this command.
var protected Text usedName;
/// Settings variable that defines a class to be used for this [`Command`]'s
/// permissions config
var protected const class<CommandPermissions> permissionsConfigClass;
// We do not really ever need to create more than one instance of each class
// of `Command`, so we will simply store and reuse one created instance.
var private Command mainInstance;
// When command is being executed we create several instances of `ConsoleWriter` that can be used
// for command output. They will also be automatically deallocated once command is executed.
//
// DO NOT modify them or deallocate any of them manually.
//
// This should make output more convenient and standardized.
//
// 1. `publicConsole` - sends messages to all present players;
// 2. `callerConsole` - sends messages to the player that called the command;
// 3. `targetConsole` - sends messages to the player that is currently being targeted
// (different each call of `ExecutedFor()` and `none` during `Executed()` call);
// 4. `othersConsole` - sends messaged to every player that is neither "caller" or "target".
/// When command is being executed we create several instances of
/// `ConsoleWriter` that can be used for command output.
/// They will also be automatically deallocated once command is executed.
///
/// DO NOT modify them or deallocate any of them manually.
///
/// This should make output more convenient and standardized.
///
/// 1. `publicConsole` - sends messages to all present players;
/// 2. `callerConsole` - sends messages to the player that called the command;
/// 3. `targetConsole` - sends messages to the player that is currently being
/// targeted (different each call of `ExecutedFor()` and `none` during
/// `Executed()` call);
/// 4. `othersConsole` - sends messaged to every player that is neither
/// "caller" or "target".
var protected ConsoleWriter publicConsole, othersConsole;
var protected ConsoleWriter callerConsole, targetConsole;
protected function Constructor() {
local CommandDataBuilder dataBuilder;
if (permissionsConfigClass != none) {
permissionsConfigClass.static.Initialize();
}
dataBuilder = CommandDataBuilder(_.memory.Allocate(class'CommandDataBuilder'));
// Let user fill-in the rest
BuildData(dataBuilder);
commandData = dataBuilder.BorrowData();
dataBuilder.FreeSelf();
@ -246,8 +277,10 @@ protected function Finalizer() {
local array<Option> options;
DeallocateConsoles();
_.memory.Free(commandData.name);
_.memory.Free(usedName);
_.memory.Free(commandData.summary);
usedName = none;
commandData.summary = none;
subCommands = commandData.subCommands;
for (i = 0; i < options.length; i += 1) {
_.memory.Free(subCommands[i].name);
@ -270,43 +303,55 @@ protected function Finalizer() {
commandData.options.length = 0;
}
private final function CleanParameters(array<Parameter> parameters) {
local int i;
/// Initializes command, providing it with a specific name.
///
/// Argument cannot be `none`, otherwise initialization fails.
/// [`Command`] can only be successfully initialized once.
public final function bool Initialize(BaseText commandName) {
if (commandName == none) return false;
if (usedName != none) return false;
for (i = 0; i < parameters.length; i += 1) {
_.memory.Free(parameters[i].displayName);
_.memory.Free(parameters[i].variableName);
_.memory.Free(parameters[i].aliasSourceName);
}
usedName = commandName.LowerCopy();
return true;
}
/// Overload this method to use `builder` to define parameters and options for your command.
/// Overload this method to use `builder` to define parameters and options for
/// your command.
protected function BuildData(CommandDataBuilder builder){}
/// Overload this method to perform required actions when your command is called.
///
/// [`arguments`] is a `struct` filled with parameters that your command has been called with.
/// Guaranteed to not be in error state.
/// Overload this method to perform required actions when your command is
/// called.
///
/// [`arguments`] is a `struct` filled with parameters that your command has
/// been called with. Guaranteed to not be in error state.
/// [`instigator`] is a player that instigated this execution.
protected function Executed(CallData arguments, EPlayer instigator){}
/// Overload this method to perform required actions when your command is called with a given player
/// as a target.
/// [`permissions`] is a config with permissions for this command call.
protected function Executed(
CallData arguments,
EPlayer instigator,
CommandPermissions permissions) {}
/// Overload this method to perform required actions when your command is called
/// with a given player as a target.
///
/// If several players have been specified - this method will be called once for each.
/// If several players have been specified - this method will be called once
/// for each.
///
/// If your command does not require a target - this method will not be called.
///
/// [`target`] is a player that this command must perform an action on.
/// [`arguments`] is a `struct` filled with parameters that your command has been called with.
/// Guaranteed to not be in error state.
///
/// [`arguments`] is a `struct` filled with parameters that your command has
/// been called with. Guaranteed to not be in error state.
/// [`instigator`] is a player that instigated this execution.
protected function ExecutedFor(EPlayer target, CallData arguments, EPlayer instigator) {}
/// Returns an instance of command (of particular class) that is stored "as a singleton" in
/// command's class itself. Do not deallocate it.
/// [`permissions`] is a config with permissions for this command call.
protected function ExecutedFor(
EPlayer target,
CallData arguments,
EPlayer instigator,
CommandPermissions permissions) {}
/// Returns an instance of command (of particular class) that is stored
/// "as a singleton" in command's class itself. Do not deallocate it.
public final static function Command GetInstance() {
if (default.mainInstance == none) {
default.mainInstance = Command(__().memory.Allocate(default.class));
@ -314,18 +359,19 @@ public final static function Command GetInstance() {
return default.mainInstance;
}
/// Forces command to process (parse) player's input, producing a structure with parsed data in
/// Acedia's format instead.
/// Forces command to process (parse) player's input, producing a structure with
/// parsed data in Acedia's format instead.
///
/// Use `Execute()` for actually performing command's actions.
///
/// [`subCommandName`] can be optionally specified to use as sub-command.
/// If this argument's value is `none` - sub-command name will be parsed from the `parser`'s data.
/// If this argument's value is `none` - sub-command name will be parsed from
/// the `parser`'s data.
///
/// Returns `CallData` structure that contains all the information about parameters specified in
/// `parser`'s contents.
/// Returned structure contains objects that must be deallocated, which can easily be done by
/// the auxiliary `DeallocateCallData()` method.
/// Returns `CallData` structure that contains all the information about
/// parameters specified in `parser`'s contents.
/// Returned structure contains objects that must be deallocated, which can
/// easily be done by the auxiliary `DeallocateCallData()` method.
public final function CallData ParseInputWith(
Parser parser,
EPlayer callerPlayer,
@ -363,16 +409,27 @@ public final function CallData ParseInputWith(
return callData;
}
/// Executes caller `Command` with data provided by `callData` if it is in a correct state and
/// reports error to `callerPlayer` if `callData` is invalid.
/// Executes caller `Command` with data provided by `callData` if it is in
/// a correct state and reports error to `callerPlayer` if `callData` is
/// invalid.
///
/// Returns `true` if command was successfully executed and `false` otherwise.
/// Execution is considered successful if `Execute()` call was made, regardless of whether `Command`
/// can actually perform required action.
/// For example, giving a weapon to a player can fail because he does not have enough space in his
/// inventory, but it will still be considered a successful execution as far as return value is
/// concerned.
public final function bool Execute(CallData callData, EPlayer callerPlayer) {
/// Execution is considered successful if `Execute()` call was made, regardless
/// of whether `Command` can actually perform required action.
/// For example, giving a weapon to a player can fail because he does not have
/// enough space in his inventory, but it will still be considered a successful
/// execution as far as return value is concerned.
///
/// [`permissions`] argument is supposed to specify permissions with which this
/// command runs.
/// If [`permissionsConfigClass`] is `none`, it must always be `none`.
/// If [`permissionsConfigClass`] is not `none`, then [`permissions`] argument
/// being `none` should mean running with minimal priviledges.
public final function bool Execute(
CallData callData,
EPlayer callerPlayer,
CommandPermissions permissions
) {
local int i;
local array<EPlayer> targetPlayers;
@ -389,11 +446,11 @@ public final function bool Execute(CallData callData, EPlayer callerPlayer) {
callerConsole = _.console.For(callerPlayer);
callerConsole
.Write(P("Executing command `"))
.Write(commandData.name)
.Write(usedName)
.Say(P("`"));
// `othersConsole` should also exist in time for `Executed()` call
othersConsole = _.console.ForAll().ButPlayer(callerPlayer);
Executed(callData, callerPlayer);
Executed(callData, callerPlayer, permissions);
_.memory.Free(othersConsole);
if (commandData.requiresTarget) {
for (i = 0; i < targetPlayers.length; i += 1) {
@ -402,7 +459,7 @@ public final function bool Execute(CallData callData, EPlayer callerPlayer) {
.ForAll()
.ButPlayer(callerPlayer)
.ButPlayer(targetPlayers[i]);
ExecutedFor(targetPlayers[i], callData, callerPlayer);
ExecutedFor(targetPlayers[i], callData, callerPlayer, permissions);
_.memory.Free(othersConsole);
_.memory.Free(targetConsole);
}
@ -413,6 +470,209 @@ public final function bool Execute(CallData callData, EPlayer callerPlayer) {
return true;
}
/// Auxiliary method that cleans up all data and deallocates all objects inside provided structure.
public final static function DeallocateCallData(/* take */ CallData callData) {
__().memory.Free(callData.subCommandName);
__().memory.Free(callData.parameters);
__().memory.Free(callData.options);
__().memory.Free(callData.errorCause);
__().memory.FreeMany(callData.targetPlayers);
if (callData.targetPlayers.length > 0) {
callData.targetPlayers.length = 0;
}
}
private final function CleanParameters(array<Parameter> parameters) {
local int i;
for (i = 0; i < parameters.length; i += 1) {
_.memory.Free(parameters[i].displayName);
_.memory.Free(parameters[i].variableName);
_.memory.Free(parameters[i].aliasSourceName);
}
}
/// Returns name (in lower case) of the caller command class.
public final static function Text GetPreferredName() {
return __().text.FromString(Locs(default.preferredName));
}
/// Returns name (in lower case) of the caller command class.
public final static function string GetPreferredName_S() {
return Locs(default.preferredName);
}
/// Returns name (in lower case) of the caller command class.
public final function Text GetName() {
if (usedName == none) {
return P("").Copy();
}
return usedName.LowerCopy();
}
/// Returns name (in lower case) of the caller command class.
public final function string GetName_S() {
if (usedName == none) {
return "";
}
return _.text.IntoString(/*take*/ usedName.LowerCopy());
}
/// Returns group name (in lower case) of the caller command class.
public final function Text GetGroupName() {
if (commandData.group == none) {
return P("").Copy();
}
return commandData.group.LowerCopy();
}
/// Returns group name (in lower case) of the caller command class.
public final function string GetGroupName_S() {
if (commandData.group == none) {
return "";
}
return _.text.IntoString(/*take*/ commandData.group.LowerCopy());
}
/// Loads permissions config with a given name for the caller [`Command`] class.
///
/// Permission configs describe allowed usage of the [`Command`].
/// Basic settings are contained inside [`CommandPermissions`], but commands
/// should derive their own child classes for storing their settings.
///
/// Returns `none` if caller [`Command`] class didn't specify custom permission
/// settings class or provided name is invalid (according to
/// [`BaseText::IsValidName()`]).
/// Otherwise guaranteed to return a config reference.
public final static function CommandPermissions LoadConfig(BaseText configName) {
if (configName == none) return none;
if (default.permissionsConfigClass == none) return none;
// This creates default config if it is missing
default.permissionsConfigClass.static.NewConfig(configName);
return CommandPermissions(default.permissionsConfigClass.static
.GetConfigInstance(configName));
}
/// Loads permissions config with a given name for the caller [`Command`] class.
///
/// Permission configs describe allowed usage of the [`Command`].
/// Basic settings are contained inside [`CommandPermissions`], but commands
/// should derive their own child classes for storing their settings.
///
/// Returns `none` if caller [`Command`] class didn't specify custom permission
/// settings class or provided name is invalid (according to
/// [`BaseText::IsValidName()`]).
/// Otherwise guaranteed to return a config reference.
public final static function CommandPermissions LoadConfig_S(string configName) {
local MutableText wrapper;
local CommandPermissions result;
wrapper = __().text.FromStringM(configName);
result = LoadConfig(wrapper);
__().memory.Free(wrapper);
return result;
}
/// Returns subcommands of caller [`Command`] according to the provided
/// permissions.
///
/// If provided `none` as permissions, returns all available sub commands.
public final function array<Text> GetSubCommands(optional CommandPermissions permissions) {
local int i, j;
local bool addSubCommand;
local array<string> forbiddenCommands;
local array<Text> result;
forbiddenCommands = permissions.forbiddenSubCommands;
if (permissions != none) {
forbiddenCommands = permissions.forbiddenSubCommands;
}
for (i = 0; i < commandData.subCommands.length; i += 1) {
addSubCommand = true;
for (j = 0; j < forbiddenCommands.length; j += 1) {
if (commandData.subCommands[i].name.ToString() ~= forbiddenCommands[j]) {
addSubCommand = false;
break;
}
}
if (addSubCommand) {
result[result.length] = commandData.subCommands[i].name.LowerCopy();
}
}
return result;
}
/// Returns sub commands of caller [`Command`] according to the provided
/// permissions.
///
/// If provided `none` as permissions, returns all available sub commands.
public final function array<string> GetSubCommands_S(optional CommandPermissions permissions) {
return _.text.IntoStrings(GetSubCommands(permissions));
}
/// Checks whether a given sub command (case insensitive) is allowed to be
/// executed with given permissions.
///
/// If `none` is passed as either argument, returns `true`.
///
/// Doesn't check for the existence of sub command, only that permissions do not
/// explicitly forbid it.
/// In case non-existing subcommand is passed as an argument, the result
/// should be considered undefined.
public final function bool IsSubCommandAllowed(
BaseText subCommand,
CommandPermissions permissions
) {
if (subCommand == none) return true;
if (permissions == none) return true;
return IsSubCommandAllowed_S(subCommand.ToString(), permissions);
}
/// Checks whether a given sub command (case insensitive) is allowed to be
/// executed with given permissions.
///
/// If `none` is passed for permissions, always returns `true`.
///
/// Doesn't check for the existence of sub command, only that permissions do not
/// explicitly forbid it.
/// In case non-existing sub command is passed as an argument, the result
/// should be considered undefined.
public final function bool IsSubCommandAllowed_S(
string subCommand,
CommandPermissions permissions
) {
local int i;
local array<string> forbiddenCommands;
if (permissions == none) {
return true;
}
forbiddenCommands = permissions.forbiddenSubCommands;
for (i = 0; i < forbiddenCommands.length; i += 1) {
if (subCommand ~= forbiddenCommands[i]) {
return false;
}
}
return true;
}
/// Returns `Command.Data` struct that describes caller `Command`.
///
/// Returned struct contains `Text` references that are used internally by
/// the `Command` and not their copies.
///
/// Generally this is undesired approach and leaves `Command` more vulnerable to
/// modification, but copying all the data inside would not only introduce
/// a largely pointless computational overhead, but also would require some
/// cumbersome logic.
/// This might change in the future, so deallocating any objects in the returned
/// `struct` would lead to undefined behavior.
public final function Data BorrowData() {
return commandData;
}
private final function DeallocateConsoles() {
if (publicConsole != none && publicConsole.IsAllocated()) {
_.memory.Free(publicConsole);
@ -432,20 +692,8 @@ private final function DeallocateConsoles() {
othersConsole = none;
}
/// Auxiliary method that cleans up all data and deallocates all objects inside provided structure.
public final static function DeallocateCallData(/* take */ CallData callData) {
__().memory.Free(callData.subCommandName);
__().memory.Free(callData.parameters);
__().memory.Free(callData.options);
__().memory.Free(callData.errorCause);
__().memory.FreeMany(callData.targetPlayers);
if (callData.targetPlayers.length > 0) {
callData.targetPlayers.length = 0;
}
}
// Reports given error to the `callerPlayer`, appropriately picking
// message color
/// Reports given error to the `callerPlayer`, appropriately picking
/// message color
private final function ReportError(CallData callData, EPlayer callerPlayer) {
local Text errorMessage;
local ConsoleWriter console;
@ -551,35 +799,7 @@ private final function array<EPlayer> ParseTargets(Parser parser, EPlayer caller
return targetPlayers;
}
/// Returns name (in lower case) of the caller command class.
public final function Text GetName() {
if (commandData.name == none) {
return P("").Copy();
}
return commandData.name.LowerCopy();
}
/// Returns group name (in lower case) of the caller command class.
public final function Text GetGroupName() {
if (commandData.group == none) {
return P("").Copy();
}
return commandData.group.LowerCopy();
}
/// Returns `Command.Data` struct that describes caller `Command`.
///
/// Returned struct contains `Text` references that are used internally by the `Command` and
/// not their copies.
///
/// Generally this is undesired approach and leaves `Command` more vulnerable to modification,
/// but copying all the data inside would not only introduce a largely pointless computational
/// overhead, but also would require some cumbersome logic.
/// This might change in the future, so deallocating any objects in the returned `struct` would lead
/// to undefined behavior.
public final function Data BorrowData() {
return commandData;
}
defaultproperties {
preferredName = ""
permissionsConfigClass = none
}

1566
sources/BaseAPI/API/Commands/CommandAPI.uc

File diff suppressed because it is too large Load Diff

753
sources/BaseAPI/API/Commands/CommandDataBuilder.uc

File diff suppressed because it is too large Load Diff

249
sources/BaseAPI/API/Commands/CommandList.uc

@ -0,0 +1,249 @@
/**
* Config class for storing map lists.
* 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 CommandList extends AcediaConfig
perObjectConfig
config(AcediaCommands);
//! `CommandList` describes a set of commands and votings that can be made
//! available to users inside Commands feature
//!
//! Optionally, permission configs can be specified for commands and votings,
//! allowing server admins to create command lists for different groups player
//! with the same commands, but different permissions.
// For storing `class<Command>` - `string` pairs in the config
struct CommandConfigStoragePair {
var public class<Command> cmd;
var public string config;
};
// For storing `class` - `string` pairs in the config
struct VotingConfigStoragePair {
var public class<Voting> vtn;
var public string config;
};
// For returning `class` - `Text` pairs into other Acedia classes
struct EntityConfigPair {
var public class<AcediaObject> class;
var public Text config;
};
/// Allows to specify if this list should only be added when server is running
/// in debug mode.
/// `true` means yes, `false` means that list will always be available.
var public config bool debugOnly;
/// Adds a command of specified class with a "default" permissions config.
var public config array< class<Command> > command;
/// Adds a command of specified class with specified permissions config
var public config array<CommandConfigStoragePair> commandWith;
/// Adds a voting of specified class with a "default" permissions config
var public config array< class<Voting> > voting;
/// Adds a voting of specified class with specified permissions config
var public config array<VotingConfigStoragePair> votingWith;
public final function array<EntityConfigPair> GetCommandData() {
local int i;
local EntityConfigPair nextPair;
local array<EntityConfigPair> result;
for (i = 0; i < command.length; i += 1) {
if (command[i] != none) {
nextPair.class = command[i];
result[result.length] = nextPair;
}
}
for (i = 0; i < commandWith.length; i += 1) {
if (commandWith[i].cmd != none) {
nextPair.class = commandWith[i].cmd;
if (commandWith[i].config != "") {
nextPair.config = _.text.FromString(commandWith[i].config);
}
result[result.length] = nextPair;
// Moved into the `result`
nextPair.config = none;
}
}
return result;
}
public final function array<EntityConfigPair> GetVotingData() {
local int i;
local EntityConfigPair nextPair;
local array<EntityConfigPair> result;
for (i = 0; i < voting.length; i += 1) {
if (voting[i] != none) {
nextPair.class = voting[i];
result[result.length] = nextPair;
}
}
for (i = 0; i < votingWith.length; i += 1) {
if (votingWith[i].vtn != none) {
nextPair.class = votingWith[i].vtn;
if (votingWith[i].config != "") {
nextPair.config = _.text.FromString(votingWith[i].config);
}
result[result.length] = nextPair;
// Moved into the `result`
nextPair.config = none;
}
}
return result;
}
protected function HashTable ToData() {
local int i;
local ArrayList entityArray;
local HashTable result, innerPair;
result = _.collections.EmptyHashTable();
result.SetBool(P("debugOnly"), debugOnly);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < command.length; i += 1) {
entityArray.AddString(string(command[i]));
}
result.SetItem(P("commands"), entityArray);
_.memory.Free(entityArray);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < voting.length; i += 1) {
entityArray.AddString(string(voting[i]));
}
result.SetItem(P("votings"), entityArray);
_.memory.Free(entityArray);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < commandWith.length; i += 1) {
innerPair = _.collections.EmptyHashTable();
innerPair.SetString(P("command"), string(commandWith[i].cmd));
innerPair.SetString(P("config"), commandWith[i].config);
entityArray.AddItem(innerPair);
_.memory.Free(innerPair);
}
result.SetItem(P("commandsWithConfig"), entityArray);
_.memory.Free(entityArray);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < votingWith.length; i += 1) {
innerPair = _.collections.EmptyHashTable();
innerPair.SetString(P("voting"), string(votingWith[i].vtn));
innerPair.SetString(P("config"), votingWith[i].config);
entityArray.AddItem(innerPair);
_.memory.Free(innerPair);
}
result.SetItem(P("votingsWithConfig"), entityArray);
_.memory.Free(entityArray);
return result;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList entityArray;
local HashTable innerPair;
local class<Command> nextCommandClass;
local class<Voting> nextVotingClass;
local CommandConfigStoragePair nextCommandPair;
local VotingConfigStoragePair nextVotingPair;
if (source == none) {
return;
}
debugOnly = source.GetBool(P("debugOnly"));
command.length = 0;
entityArray = source.GetArrayList(P("commands"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
nextCommandClass = class<Command>(_.memory.LoadClass_S(entityArray.GetString(i)));
if (nextCommandClass != none) {
command[command.length] = nextCommandClass;
}
}
}
_.memory.Free(entityArray);
voting.length = 0;
entityArray = source.GetArrayList(P("votings"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
nextVotingClass = class<Voting>(_.memory.LoadClass_S(entityArray.GetString(i)));
if (nextVotingClass != none) {
voting[voting.length] = nextVotingClass;
}
}
}
_.memory.Free(entityArray);
commandWith.length = 0;
entityArray = source.GetArrayList(P("commandsWithConfig"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
innerPair = entityArray.GetHashTable(i);
if (innerPair == none) {
continue;
}
nextCommandPair.cmd =
class<Command>(_.memory.LoadClass_S(innerPair.GetString(P("command"))));
nextCommandPair.config = innerPair.GetString(P("config"));
_.memory.Free(innerPair);
if (nextCommandPair.cmd != none) {
commandWith[commandWith.length] = nextCommandPair;
}
}
}
_.memory.Free(entityArray);
votingWith.length = 0;
entityArray = source.GetArrayList(P("votingsWithConfig"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
innerPair = entityArray.GetHashTable(i);
if (innerPair == none) {
continue;
}
nextVotingPair.vtn =
class<Voting>(_.memory.LoadClass_S(innerPair.GetString(P("voting"))));
nextVotingPair.config = innerPair.GetString(P("config"));
_.memory.Free(innerPair);
if (nextVotingPair.vtn != none) {
votingWith[votingWith.length] = nextVotingPair;
}
}
}
_.memory.Free(entityArray);
}
protected function DefaultIt() {
debugOnly = false;
command.length = 0;
commandWith.length = 0;
voting.length = 0;
votingWith.length = 0;
command[0] = class'ACommandHelp';
command[1] = class'ACommandVote';
}
defaultproperties {
configName = "AcediaCommands"
supportsDataConversion = true
debugOnly = false
command(0) = class'ACommandHelp'
command(1) = class'ACommandVote'
}

304
sources/BaseAPI/API/Commands/CommandParser.uc

@ -23,51 +23,60 @@ class CommandParser extends AcediaObject
dependson(Command);
//! Class specialized for parsing user input of the command's call into `Command.CallData` structure
//! with the information about all parsed arguments.
//! Class specialized for parsing user input of the command's call into
//![ `Command.CallData`] structure with the information about all parsed
//! arguments.
//!
//! `CommandParser` is not made to parse the whole input:
//! [`CommandParser`] is not made to parse the whole input:
//!
//! * Command's name needs to be parsed and resolved as an alias before using this parser -
//! it won't do this hob for you;
//! * List of targeted players must also be parsed using `PlayersParser` - `CommandParser` won't do
//! this for you;
//! * Optionally one can also decide on the referred subcommand and pass it into `ParseWith()`
//! method. If subcommand's name is not passed - `CommandParser` will try to parse it itself.
//! * Command's name needs to be parsed and resolved as an alias before using
//! this parser - it won't do this hob for you;
//! * List of targeted players must also be parsed using [`PlayersParser`] -
//! [`CommandParser`] won't do this for you;
//! * Optionally one can also decide on the referred subcommand and pass it into
//! [`ParseWith()`] method. If subcommand's name is not passed -
//! [`CommandParser`] will try to parse it itself.
//! This feature is used to add support for subcommand aliases.
//!
//! However, above steps are handled by `Commands_Feature` and one only needs to call that feature's
//! `HandleInput()` methods to pass user input with command call line there.
//! However, above steps are handled by [`Commands_Feature`] and one only needs to
//! call that feature's [`HandleInput()`] methods to pass user input with command
//! call line there.
//!
//! # Usage
//!
//! Allocate `CommandParser` and call `ParseWith()` method, providing it with:
//! Allocate [`CommandParser`] and call [`ParseWith()`] method, providing it with:
//!
//! 1. `Parser`, filled with command call input;
//! 2. Command's data that describes subcommands, options and their parameters for the command,
//! which call we are parsing;
//! 3. (Optionally) `EPlayer` reference to the player that initiated the command call;
//! 4. (Optionally) Subcommand to be used - this will prevent `CommandParser` from parsing
//! subcommand name itself. Used for implementing aliases that refer to a particular subcommand.
//! 1. [`Parser`], filled with command call input;
//! 2. Command's data that describes subcommands, options and their parameters
//! for the command, which call we are parsing;
//! 3. (Optionally) [`EPlayer`] reference to the player that initiated
//! the command call;
//! 4. (Optionally) Subcommand to be used - this will prevent [`CommandParser`]
//! from parsing subcommand name itself. Used for implementing aliases that
//! refer to a particular subcommand.
//!
//! # Implementation
//!
//! `CommandParser` stores both its state and command data, relevant to parsing, as its member
//! variables during the whole parsing process, instead of passing that data around in every single
//! method.
//! [`CommandParser`] stores both its state and command data, relevant to parsing,
//! as its member variables during the whole parsing process, instead of passing
//! that data around in every single method.
//!
//! We will give a brief overview of how around 20 parsing methods below are interconnected.
//! We will give a brief overview of how around 20 parsing methods below are
//! interconnected.
//!
//! The only public method `ParseWith()` is used to start parsing and it uses `PickSubCommand()` to
//! first try and figure out what sub command is intended by user's input.
//! The only public method [`ParseWith()`] is used to start parsing and it uses
//! [`PickSubCommand()`] to first try and figure out what sub command is
//! intended by user's input.
//!
//! Main bulk of the work is done by `ParseParameterArrays()` method, for simplicity broken into two
//! `ParseRequiredParameterArray()` and `ParseOptionalParameterArray()` methods that can parse
//! Main bulk of the work is done by [`ParseParameterArrays()`] method, for
//! simplicity broken into two [`ParseRequiredParameterArray()`] and
//! [`ParseOptionalParameterArray()`] methods that can parse
//! parameters for both command itself and it's options.
//!
//! They go through arrays of required and optional parameters, calling `ParseParameter()` for each
//! parameters, which in turn can make several calls of `ParseSingleValue()` to parse parameters'
//! values: it is called once for single-valued parameters, but possibly several times for list
//! They go through arrays of required and optional parameters, calling
//! [`ParseParameter()`] for each parameters, which in turn can make several
//! calls of [`ParseSingleValue()`] to parse parameters' values: it is called
//! once for single-valued parameters, but possibly several times for list
//! parameters that can contain several values.
//!
//! So main parsing method looks something like:
@ -80,22 +89,25 @@ class CommandParser extends AcediaObject
//! }
//! ```
//!
//! `ParseSingleValue()` is essentially that redirects it's method call to another, more specific,
//! parsing method based on the parameter type.
//! [`ParseSingleValue()`] is essentially that redirects it's method call to
//! another, more specific, parsing method based on the parameter type.
//!
//! Finally, to allow users to specify options at any point in command, we call
//! `TryParsingOptions()` at the beginning of every `ParseSingleValue()` (the only parameter that
//! has higher priority than options is `CPT_Remainder`), since option definition can appear at any
//! place between parameters. We also call `TryParsingOptions()` *after* we've parsed all command's
//! parameters, since that case won't be detected by parsing them *before* every parameter.
//! [`TryParsingOptions()`] at the beginning of every [`ParseSingleValue()`]
//! (the only parameter that has higher priority than options is
//! [`CPT_Remainder`]), since option definition can appear at any place between
//! parameters. We also call `TryParsingOptions()` *after* we've parsed all
//! command's parameters, since that case won't be detected by parsing them
//! *before* every parameter.
//!
//! `TryParsingOptions()` itself simply tries to detect "-" and "--" prefixes (filtering out
//! negative numeric values) and then redirect the call to either of more specialized methods:
//! `ParseLongOption()` or `ParseShortOption()`, that can in turn make another
//! `ParseParameterArrays()` call, if specified option has parameters.
//! [`TryParsingOptions()`] itself simply tries to detect "-" and "--" prefixes
//! (filtering out negative numeric values) and then redirect the call to either
//! of more specialized methods: [`ParseLongOption()`] or
//! [`ParseShortOption()`], that can in turn make another
//! [`ParseParameterArrays()`] call, if specified option has parameters.
//!
//! NOTE: `ParseParameterArrays()` can only nest in itself once, since option declaration always
//! interrupts previous option's parameter list.
//! NOTE: [`ParseParameterArrays()`] can only nest in itself once, since option
//! declaration always interrupts previous option's parameter list.
//! Rest of the methods perform simple auxiliary functions.
// Describes which parameters we are currently parsing, classifying them
@ -107,7 +119,7 @@ class CommandParser extends AcediaObject
// * Still parsing required *parameter* "integer list";
// * But no more integers are *necessary* for successful parsing.
//
// Therefore we consider parameter "necessary" if the lack of it will\
// Therefore we consider parameter "necessary" if the lack of it will
// result in failed parsing and "extra" otherwise.
enum ParsingTarget {
// We are in the process of parsing required parameters, that must all
@ -130,12 +142,12 @@ var private Command.SubCommand pickedSubCommand;
// Options available for the command we are parsing.
var private array<Command.Option> availableOptions;
// Result variable we are filling during the parsing process,
// should be `none` outside of `self.ParseWith()` method call.
// should be `none` outside of [`self.ParseWith()`] method call.
var private Command.CallData nextResult;
// Parser for player parameters, setup with a caller for current parsing
var private PlayersParser currentPlayersParser;
// Current `ParsingTarget`, see it's enum description for more details
// Current [`ParsingTarget`], see it's enum description for more details
var private ParsingTarget currentTarget;
// `true` means we are parsing parameters for a command's option and
// `false` means we are parsing command's own parameters
@ -143,7 +155,7 @@ var private bool currentTargetIsOption;
// If we are parsing parameters for an option (`currentTargetIsOption == true`)
// this variable will store that option's data.
var private Command.Option targetOption;
// Last successful state of `commandParser`.
// Last successful state of [`commandParser`].
var Parser.ParserState confirmedState;
// Options we have so far encountered during parsing, necessary since we want
// to forbid specifying th same option more than once.
@ -159,6 +171,61 @@ protected function Finalizer() {
Reset();
}
/// Parses user's input given in [`parser`] using command's information given by
/// [`commandData`].
///
/// Optionally, sub-command can be specified for the [`CommandParser`] to use
/// via [`specifiedSubCommand`] argument.
/// If this argument's value is `none` - it will be parsed from [`parser`]'s
/// data instead.
///
/// Returns results of parsing, described by [`Command.CallData`].
/// Returned object is guaranteed to be not `none`.
public final function Command.CallData ParseWith(
Parser parser,
Command.Data commandData,
EPlayer callerPlayer,
optional BaseText specifiedSubCommand
) {
local HashTable commandParameters;
// Temporary object to return `nextResult` while setting variable to `none`
local Command.CallData toReturn;
nextResult.parameters = _.collections.EmptyHashTable();
nextResult.options = _.collections.EmptyHashTable();
if (commandData.subCommands.length == 0) {
DeclareError(CET_NoSubCommands, none);
toReturn = nextResult;
Reset();
return toReturn;
}
if (parser == none || !parser.Ok()) {
DeclareError(CET_BadParser, none);
toReturn = nextResult;
Reset();
return toReturn;
}
commandParser = parser;
availableOptions = commandData.options;
currentPlayersParser =
PlayersParser(_.memory.Allocate(class'PlayersParser'));
currentPlayersParser.SetSelf(callerPlayer);
// (subcommand) (parameters, possibly with options) and nothing else!
PickSubCommand(commandData, specifiedSubCommand);
nextResult.subCommandName = pickedSubCommand.name.Copy();
commandParameters = ParseParameterArrays(pickedSubCommand.required, pickedSubCommand.optional);
AssertNoTrailingInput(); // make sure there is nothing else
if (commandParser.Ok()) {
nextResult.parameters = commandParameters;
} else {
_.memory.Free(commandParameters);
}
// Clean up
toReturn = nextResult;
Reset();
return toReturn;
}
// Zero important variables
private final function Reset() {
local Command.CallData blankCallData;
@ -186,11 +253,11 @@ private final function DeclareError(Command.ErrorType type, optional BaseText ca
// Assumes `commandParser != none`, is in successful state.
//
// Picks a sub command based on it's contents (parser's pointer must be before where subcommand's
// name is specified).
// Picks a sub command based on it's contents (parser's pointer must be before
// where subcommand's name is specified).
//
// If `specifiedSubCommand` is not `none` - will always use that value instead of parsing it from
// `commandParser`.
// If [`specifiedSubCommand`] is not `none` - will always use that value instead
// of parsing it from [`commandParser`].
private final function PickSubCommand(Command.Data commandData, BaseText specifiedSubCommand) {
local int i;
local MutableText candidateSubCommandName;
@ -224,64 +291,11 @@ private final function PickSubCommand(Command.Data commandData, BaseText specifi
}
}
// We will only reach here if we did not match any sub commands,
// meaning that whatever consumed by `candidateSubCommandName` probably
// meaning that whatever consumed by[ `candidateSubCommandName`] probably
// has a different meaning.
commandParser.RestoreState(confirmedState);
}
/// Parses user's input given in [`parser`] using command's information given by [`commandData`].
///
/// Optionally, sub-command can be specified for the [`CommandParser`] to use via
/// [`specifiedSubCommand`] argument.
/// If this argument's value is `none` - it will be parsed from `parser`'s data instead.
///
/// Returns results of parsing, described by `Command.CallData`.
/// Returned object is guaranteed to be not `none`.
public final function Command.CallData ParseWith(
Parser parser,
Command.Data commandData,
EPlayer callerPlayer,
optional BaseText specifiedSubCommand
) {
local HashTable commandParameters;
// Temporary object to return `nextResult` while setting variable to `none`
local Command.CallData toReturn;
nextResult.parameters = _.collections.EmptyHashTable();
nextResult.options = _.collections.EmptyHashTable();
if (commandData.subCommands.length == 0) {
DeclareError(CET_NoSubCommands, none);
toReturn = nextResult;
Reset();
return toReturn;
}
if (parser == none || !parser.Ok()) {
DeclareError(CET_BadParser, none);
toReturn = nextResult;
Reset();
return toReturn;
}
commandParser = parser;
availableOptions = commandData.options;
currentPlayersParser =
PlayersParser(_.memory.Allocate(class'PlayersParser'));
currentPlayersParser.SetSelf(callerPlayer);
// (subcommand) (parameters, possibly with options) and nothing else!
PickSubCommand(commandData, specifiedSubCommand);
nextResult.subCommandName = pickedSubCommand.name.Copy();
commandParameters = ParseParameterArrays(pickedSubCommand.required, pickedSubCommand.optional);
AssertNoTrailingInput(); // make sure there is nothing else
if (commandParser.Ok()) {
nextResult.parameters = commandParameters;
} else {
_.memory.Free(commandParameters);
}
// Clean up
toReturn = nextResult;
Reset();
return toReturn;
}
// Assumes `commandParser` is not `none`
// Declares an error if `commandParser` still has any input left
private final function AssertNoTrailingInput() {
@ -296,7 +310,8 @@ private final function AssertNoTrailingInput() {
}
// Assumes `commandParser` is not `none`.
// Parses given required and optional parameters along with any possible option declarations.
// Parses given required and optional parameters along with any possible option
// declarations.
// Returns `HashTable` filled with (variable, parsed value) pairs.
// Failure is equal to `commandParser` entering into a failed state.
private final function HashTable ParseParameterArrays(
@ -354,8 +369,8 @@ private final function ParseRequiredParameterArray(
// Assumes `commandParser` and `parsedParameters` are not `none`.
//
// Parses given optional parameters along with any possible option declarations into given
// `parsedParameters` hash table.
// Parses given optional parameters along with any possible option declarations
// into given `parsedParameters` hash table.
private final function ParseOptionalParameterArray(
HashTable parsedParameters,
array<Command.Parameter> optionalParameters
@ -384,10 +399,11 @@ private final function ParseOptionalParameterArray(
// Assumes `commandParser` and `parsedParameters` are not `none`.
//
// Parses one given parameter along with any possible option declarations into given
// `parsedParameters` `HashTable`.
// Parses one given parameter along with any possible option declarations into
// given `parsedParameters` `HashTable`.
//
// Returns `true` if we've successfully parsed given parameter without any errors.
// Returns `true` if we've successfully parsed given parameter without any
// errors.
private final function bool ParseParameter(
HashTable parsedParameters,
Command.Parameter expectedParameter
@ -423,10 +439,12 @@ private final function bool ParseParameter(
// Assumes `commandParser` and `parsedParameters` are not `none`.
//
// Parses a single value for a given parameter (e.g. one integer for integer or integer list
// parameter types) along with any possible option declarations into given `parsedParameters`.
// Parses a single value for a given parameter (e.g. one integer for integer or
// integer list parameter types) along with any possible option declarations
// into given `parsedParameters`.
//
// Returns `true` if we've successfully parsed a single value without any errors.
// Returns `true` if we've successfully parsed a single value without
// any errors.
private final function bool ParseSingleValue(
HashTable parsedParameters,
Command.Parameter expectedParameter
@ -537,7 +555,8 @@ private final function bool ParseIntegerValue(
}
// Assumes `commandParser` and `parsedParameters` are not `none`.
// Parses a single number (float) value into given `parsedParameters` hash table.
// Parses a single number (float) value into given `parsedParameters`
// hash table.
private final function bool ParseNumberValue(
HashTable parsedParameters,
Command.Parameter expectedParameter
@ -591,8 +610,8 @@ private final function bool ParseTextValue(
return true;
}
// Resolves alias and returns it, along with the resolved value, if parameter was specified to be
// auto-resolved.
// Resolves alias and returns it, along with the resolved value, if parameter
// was specified to be auto-resolved.
// Returns `none` otherwise.
private final function HashTable AutoResolveAlias(MutableText textValue, Text aliasSourceName) {
local HashTable result;
@ -628,8 +647,8 @@ private final function HashTable AutoResolveAlias(MutableText textValue, Text al
// Assumes `commandParser` and `parsedParameters` are not `none`.
//
// Parses a single `Text` value into given `parsedParameters` hash table, consuming all remaining
// contents.
// Parses a single `Text` value into given `parsedParameters` hash table,
// consuming all remaining contents.
private final function bool ParseRemainderValue(
HashTable parsedParameters,
Command.Parameter expectedParameter
@ -748,14 +767,15 @@ private final function RecordParameter(
// Assumes `commandParser` is not `none`.
//
// Tries to parse an option declaration (along with all of it's parameters) with `commandParser`.
// Tries to parse an option declaration (along with all of it's parameters) with
// `commandParser`.
//
// Returns `true` on success and `false` otherwise.
//
// In case of failure to detect option declaration also reverts state of `commandParser` to that
// before `TryParsingOptions()` call.
// However, if option declaration was present, but invalid (or had invalid parameters) parser
// will be left in a failed state.
// In case of failure to detect option declaration also reverts state of
// `commandParser` to that before `TryParsingOptions()` call.
// However, if option declaration was present, but invalid (or had invalid
// parameters) parser will be left in a failed state.
private final function bool TryParsingOptions() {
local int temporaryInt;
@ -795,12 +815,12 @@ private final function bool TryParsingOptions() {
// Assumes `commandParser` is not `none`.
//
// Tries to parse a long option name along with all of it's possible parameters with
// `commandParser`.
// Tries to parse a long option name along with all of it's possible parameters
// with `commandParser`.
//
// Returns `true` on success and `false` otherwise. At the point this method is called, option
// declaration is already assumed to be detected and any failure implies parsing error
// (ending in failed `Command.CallData`).
// Returns `true` on success and `false` otherwise. At the point this method is
// called, option declaration is already assumed to be detected and any failure
// implies parsing error (ending in failed `Command.CallData`).
private final function bool ParseLongOption() {
local int i, optionIndex;
local MutableText optionName;
@ -832,14 +852,13 @@ private final function bool ParseLongOption() {
// Assumes `commandParser` and `nextResult` are not `none`.
//
// Tries to parse a short option name along with all of it's possible parameters with
// `commandParser`.
// Tries to parse a short option name along with all of it's possible parameters
// with `commandParser`.
//
// Returns `true` on success and `false` otherwise. At the point this
// method is called, option declaration is already assumed to be detected
// and any failure implies parsing error (ending in failed `Command.CallData`).
private final function bool ParseShortOption()
{
private final function bool ParseShortOption() {
local int i;
local bool pickedOptionWithParameters;
local MutableText optionsList;
@ -864,17 +883,21 @@ private final function bool ParseShortOption()
// Assumes `commandParser` and `nextResult` are not `none`.
//
// Auxiliary method that adds option by it's short version's character `optionCharacter`.
// Auxiliary method that adds option by it's short version's character
// `optionCharacter`.
//
// It also accepts `optionSourceList` that describes short option expression (e.g. "-rtV") from
// which it originated for error reporting and `forbidOptionWithParameters` that, when set to
// `true`, forces this method to cause the `CET_MultipleOptionsWithParams` error if new option has
// non-empty parameters.
// It also accepts `optionSourceList` that describes short option expression
// (e.g. "-rtV") from
// which it originated for error reporting and `forbidOptionWithParameters`
// that, when set to `true`, forces this method to cause the
// `CET_MultipleOptionsWithParams` error if new option has non-empty parameters.
//
// Method returns `true` if added option had non-empty parameters and `false` otherwise.
// Method returns `true` if added option had non-empty parameters and `false`
// otherwise.
//
// Any parsing failure inside this method always causes `nextError.DeclareError()` call, so you
// can use `nextResult.IsSuccessful()` to check if method has failed.
// Any parsing failure inside this method always causes
// `nextError.DeclareError()` call, so you can use `nextResult.IsSuccessful()`
// to check if method has failed.
private final function bool AddOptionByCharacter(
BaseText.Character optionCharacter,
BaseText optionSourceList,
@ -915,7 +938,8 @@ private final function bool AddOptionByCharacter(
}
// Auxiliary method for parsing option's parameters (including empty ones).
// Automatically fills `nextResult` with parsed parameters (or `none` if option has no parameters).
// Automatically fills `nextResult` with parsed parameters (or `none` if option
// has no parameters).
// Assumes `commandParser` and `nextResult` are not `none`.
private final function bool ParseOptionParameters(Command.Option pickedOption) {
local HashTable optionParameters;

66
sources/BaseAPI/API/Commands/CommandPermissions.uc

@ -0,0 +1,66 @@
/**
* 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 CommandPermissions extends AcediaConfig
perobjectconfig
config(AcediaCommands)
abstract;
var public config array<string> forbiddenSubCommands;
protected function HashTable ToData() {
local int i;
local HashTable data;
local ArrayList forbiddenList;
data = _.collections.EmptyHashTable();
forbiddenList = _.collections.EmptyArrayList();
for (i = 0; i < forbiddenSubCommands.length; i += 1) {
forbiddenList.AddString(Locs(forbiddenSubCommands[i]));
}
data.SetItem(P("forbiddenSubCommands"), forbiddenList);
_.memory.Free(forbiddenList);
return data;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList forbiddenList;
if (source == none) return;
forbiddenList = source.GetArrayList(P("forbiddenSubCommands"));
if (forbiddenList == none) return;
forbiddenSubCommands.length = 0;
for (i = 0; i < forbiddenList.GetLength(); i += 1) {
forbiddenSubCommands[i] = forbiddenList.GetString(i);
}
_.memory.Free(forbiddenList);
}
protected function DefaultIt() {
forbiddenSubCommands.length = 0;
}
defaultproperties {
configName = "AcediaCommands"
supportsDataConversion = true
}

59
sources/BaseAPI/API/Commands/CommandRegistrationJob.uc

@ -19,36 +19,63 @@
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandRegistrationJob extends SchedulerJob;
class CommandRegistrationJob extends SchedulerJob
dependson(CommandAPI);
var private class<Command> nextCommand;
var private CommandAPI.AsyncTask nextItem;
// Expecting 300 units of work, this gives us registering 20 commands per tick
const ADDING_COMMAND_COST = 15;
// Adding voting option is approximately the same as adding a command's
// single sub-command - we'll estimate it as 1/3rd of the full value
const ADDING_VOTING_COST = 5;
// Authorizing is relatively cheap, whether it's commands or voting
const AUTHORIZING_COST = 1;
protected function Constructor() {
nextCommand = _.commands._popPending();
nextItem = _.commands._popPending();
}
protected function Finalizer() {
nextCommand = none;
_.memory.Free3(nextItem.entityName, nextItem.userGroup, nextItem.configName);
nextItem.entityClass = none;
nextItem.entityName = none;
nextItem.userGroup = none;
nextItem.configName = none;
}
public function bool IsCompleted() {
return (nextCommand == none);
return (nextItem.entityName == none);
}
public function DoWork(int allottedWorkUnits) {
local int i, iterationsAmount;
// Expected 300 units per tick, to register 20 commands per tick use about 10
iterationsAmount = Max(allottedWorkUnits / 10, 1);
for (i = 0; i < iterationsAmount; i += 1) {
_.commands.RegisterCommand(nextCommand);
nextCommand = _.commands._popPending();
if (nextCommand == none) {
break;
while (allottedWorkUnits > 0 && nextItem.entityName != none) {
if (nextItem.type == CAJT_AddCommand) {
allottedWorkUnits -= ADDING_COMMAND_COST;
_.commands.AddCommand(class<Command>(nextItem.entityClass), nextItem.entityName);
_.memory.Free(nextItem.entityName);
} else if (nextItem.type == CAJT_AddVoting) {
allottedWorkUnits -= ADDING_VOTING_COST;
_.commands.AddVoting(class<Voting>(nextItem.entityClass), nextItem.entityName);
_.memory.Free(nextItem.entityName);
} else if (nextItem.type == CAJT_AuthorizeCommand) {
allottedWorkUnits -= AUTHORIZING_COST;
_.commands.AuthorizeCommandUsage(
nextItem.entityName,
nextItem.userGroup,
nextItem.configName);
_.memory.Free3(nextItem.entityName, nextItem.userGroup, nextItem.configName);
} else /*if (nextItem.type == CAJT_AuthorizeVoting)*/ {
allottedWorkUnits -= AUTHORIZING_COST;
_.commands.AuthorizeVotingUsage(
nextItem.entityName,
nextItem.userGroup,
nextItem.configName);
_.memory.Free3(nextItem.entityName, nextItem.userGroup, nextItem.configName);
}
nextItem = _.commands._popPending();
}
}
defaultproperties
{
defaultproperties {
}

182
sources/BaseAPI/API/Commands/Commands.uc

@ -21,40 +21,210 @@
*/
class Commands extends FeatureConfig
perobjectconfig
config(AcediaSystem);
config(AcediaCommands);
var public config bool useChatInput;
/// Auxiliary struct for describing adding a particular command set to
/// a particular group of users.
struct CommandSetGroupPair {
/// Name of the command set to add
var public string name;
/// Name of the group, for which to add this set
var public string for;
};
/// Auxiliary struct for describing a rule to rename a particular command for
/// compatibility reasons.
struct RenamingRulePair {
/// Command class to rename
var public class<AcediaObject> rename;
/// Name to use for that class
var public string to;
};
/// Setting this to `true` enables players to input commands with "mutate"
/// console command.
/// Default is `true`.
var public config bool useMutateInput;
/// Setting this to `true` enables players to input commands right in the chat
/// by prepending them with [`chatCommandPrefix`].
/// Default is `true`.
var public config bool useChatInput;
/// Chat messages, prepended by this prefix will be treated as commands.
/// Default is "!". Empty values are also treated as "!".
var public config string chatCommandPrefix;
/// Allows to specify which user groups are used in determining command/votings
/// permission.
/// They must be specified in the order of importance: from the group with
/// highest level of permissions to the lowest. When determining player's
/// permission to use a certain command/voting, his group with the highest
/// available permissions will be used.
var public config array<string> commandGroup;
/// Add a specified `CommandList` to the specified user group
var public config array<CommandSetGroupPair> addCommandList;
/// Allows to specify a name for a certain command class
///
/// NOTE:By default command choses that name by itself and its not recommended
/// to override it. You should only use this setting in case there is naming
/// conflict between commands from different packages.
var public config array<RenamingRulePair> renamingRule;
/// Allows to specify a name for a certain voting class
///
/// NOTE:By default voting choses that name by itself and its not recommended
/// to override it. You should only use this setting in case there is naming
/// conflict between votings from different packages.
var public config array<RenamingRulePair> votingRenamingRule;
protected function HashTable ToData() {
local int i;
local HashTable data;
local ArrayList innerList;
local HashTable innerPair;
data = __().collections.EmptyHashTable();
data.SetBool(P("useChatInput"), useChatInput, true);
data.SetBool(P("useMutateInput"), useMutateInput, true);
data.SetString(P("chatCommandPrefix"), chatCommandPrefix);
// Serialize `commandGroup`
innerList = _.collections.EmptyArrayList();
for (i = 0; i < commandGroup.length; i += 1) {
innerList.AddString(commandGroup[i]);
}
data.SetItem(P("commandGroups"), innerList);
_.memory.Free(innerList);
// Serialize `addCommandSet`
innerList = _.collections.EmptyArrayList();
for (i = 0; i < addCommandList.length; i += 1) {
innerPair = _.collections.EmptyHashTable();
innerPair.SetString(P("name"), addCommandList[i].name);
innerPair.SetString(P("for"), addCommandList[i].for);
innerList.AddItem(innerPair);
_.memory.Free(innerPair);
}
data.SetItem(P("commandSets"), innerList);
_.memory.Free(innerList);
// Serialize `renamingRule`
innerList = _.collections.EmptyArrayList();
for (i = 0; i < renamingRule.length; i += 1) {
innerPair = _.collections.EmptyHashTable();
innerPair.SetString(P("rename"), string(renamingRule[i].rename));
innerPair.SetString(P("to"), renamingRule[i].to);
innerList.AddItem(innerPair);
_.memory.Free(innerPair);
}
data.SetItem(P("renamingRules"), innerList);
_.memory.Free(innerList);
// Serialize `votingRenamingRule`
innerList = _.collections.EmptyArrayList();
for (i = 0; i < votingRenamingRule.length; i += 1) {
innerPair = _.collections.EmptyHashTable();
innerPair.SetString(P("rename"), string(votingRenamingRule[i].rename));
innerPair.SetString(P("to"), votingRenamingRule[i].to);
innerList.AddItem(innerPair);
_.memory.Free(innerPair);
}
data.SetItem(P("votingRenamingRules"), innerList);
_.memory.Free(innerList);
return data;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList innerList;
local HashTable innerPair;
local CommandSetGroupPair nextCommandSetGroupPair;
local RenamingRulePair nextRenamingRule;
local class<AcediaObject> nextClass;
if (source == none) {
return;
}
useChatInput = source.GetBool(P("useChatInput"));
useMutateInput = source.GetBool(P("useMutateInput"));
chatCommandPrefix = source.GetString(P("chatCommandPrefix"), "!");
// De-serialize `commandGroup`
commandGroup.length = 0;
innerList = source.GetArrayList(P("commandGroups"));
if (innerList != none) {
for (i = 0; i < commandGroup.length; i += 1) {
commandGroup[i] = innerList.GetString(i);
}
_.memory.Free(innerList);
}
// De-serialize `addCommandSet`
addCommandList.length = 0;
innerList = source.GetArrayList(P("commandSets"));
if (innerList != none) {
for (i = 0; i < addCommandList.length; i += 1) {
innerPair = innerList.GetHashTable(i);
if (innerPair != none) {
nextCommandSetGroupPair.name = innerPair.GetString(P("name"));
nextCommandSetGroupPair.for = innerPair.GetString(P("for"));
addCommandList[addCommandList.length] = nextCommandSetGroupPair;
_.memory.Free(innerPair);
}
}
_.memory.Free(innerList);
}
// De-serialize `renamingRule`
renamingRule.length = 0;
innerList = source.GetArrayList(P("renamingRules"));
if (innerList != none) {
for (i = 0; i < renamingRule.length; i += 1) {
innerPair = innerList.GetHashTable(i);
if (innerPair != none) {
nextClass =
class<AcediaObject>(_.memory.LoadClass_S(innerPair.GetString(P("rename"))));
nextRenamingRule.rename = nextClass;
nextRenamingRule.to = innerPair.GetString(P("to"));
renamingRule[renamingRule.length] = nextRenamingRule;
_.memory.Free(innerPair);
}
}
_.memory.Free(innerList);
}
// De-serialize `votingRenamingRule`
votingRenamingRule.length = 0;
innerList = source.GetArrayList(P("votingRenamingRules"));
if (innerList != none) {
for (i = 0; i < votingRenamingRule.length; i += 1) {
innerPair = innerList.GetHashTable(i);
if (innerPair != none) {
nextClass =
class<AcediaObject>(_.memory.LoadClass_S(innerPair.GetString(P("rename"))));
nextRenamingRule.rename = nextClass;
nextRenamingRule.to = innerPair.GetString(P("to"));
votingRenamingRule[votingRenamingRule.length] = nextRenamingRule;
_.memory.Free(innerPair);
}
}
_.memory.Free(innerList);
}
}
protected function DefaultIt() {
local CommandSetGroupPair defaultPair;
useChatInput = true;
useMutateInput = true;
chatCommandPrefix = "!";
commandGroup[0] = "admin";
commandGroup[1] = "moderator";
commandGroup[2] = "trusted";
addCommandList.length = 0;
defaultPair.name = "default";
defaultPair.for = "all";
addCommandList[0] = defaultPair;
renamingRule.length = 0;
}
defaultproperties {
configName = "AcediaSystem"
useChatInput = true
useMutateInput = true
chatCommandPrefix = "!"
configName = "AcediaCommands"
}

806
sources/BaseAPI/API/Commands/Commands_Feature.uc

@ -19,7 +19,9 @@
* 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;
class Commands_Feature extends Feature
dependson(CommandAPI)
dependson(Commands);
//! This feature manages commands that automatically parse their arguments into standard Acedia
//! collections.
@ -38,83 +40,81 @@ class Commands_Feature extends Feature;
//! Emergency enabling this feature sets `emergencyEnabledMutate` flag that
//! enforces connecting to the "mutate" input.
/// Pairs [`Voting`] class with a name its registered under in lower case for quick search.
struct NamedVoting {
var public class<Voting> processClass;
/// Must be guaranteed to not be `none` and lower case as an invariant
var public Text processName;
};
/// Auxiliary struct for passing name of the command to call plus, optionally, additional
/// 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.
/// Normally sub-command name is parsed by the command itself, however command
/// aliases can try to enforce one.
struct CommandCallPair {
var MutableText commandName;
/// In case it is enforced by an alias
/// Not `none` in case it is enforced by an alias
var MutableText subCommandName;
};
/// Describes possible outcomes of starting a voting by its name
enum StartVotingResult {
/// Voting was successfully started
SVR_Success,
/// Voting wasn't started because another one was still in progress
SVR_AlreadyInProgress,
/// Voting wasn't started because voting with that name hasn't been registered
SVR_UnknownVoting
/// 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;
/// Registered commands, recorded as (<command_name>, <command_instance>) pairs.
/// Keys should be deallocated when their entry is removed.
var private HashTable registeredCommands;
/// [`HashTable`] of "<command_group_name>" <-> [`ArrayList`] of commands pairs to allow quick fetch
/// of commands belonging to a single group
var private HashTable groupedCommands;
/// [`Voting`]s that were already successfully loaded, ensuring that each has a unique name
var private array<NamedVoting> loadedVotings;
/// Currently running voting process.
/// This feature doesn't actively track when voting ends, so reference can be non-`none` even if
/// voting has already ended.
var private Voting currentVoting;
/// An array of [`Voting`] objects that have been successfully loaded and
/// each object has a unique name.
var private array<NamedVoting> registeredVotings;
/// When this flag is set to true, mutate input becomes available despite [`useMutateInput`] flag to
/// allow to unlock server in case of an error
/// 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;
/// Setting this to `true` enables players to input commands right in the chat by prepending them
/// with [`chatCommandPrefix`].
/// Default is `true`.
var private /*config*/ bool useChatInput;
/// Setting this to `true` enables players to input commands with "mutate" console command.
/// Default is `true`.
var private /*config*/ bool useMutateInput;
/// Chat messages, prepended by this prefix will be treated as commands.
/// Default is "!". Empty values are also treated as "!".
var private /*config*/ Text chatCommandPrefix;
var LoggerAPI.Definition errCommandDuplicate, errServerAPIUnavailable;
var LoggerAPI.Definition errVotingWithSameNameAlreadyRegistered, errYesNoVotingNamesReserved;
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() {
registeredCommands = _.collections.EmptyHashTable();
groupedCommands = _.collections.EmptyHashTable();
RegisterCommand(class'ACommandHelp');
RegisterCommand(class'ACommandNotify');
RegisterCommand(class'ACommandVote');
RegisterCommand(class'ACommandSideEffects');
if (_.environment.IsDebugging()) {
RegisterCommand(class'ACommandFakers');
}
RegisterVotingClass(class'Voting');
helpCommandName = P("help");
// Macro selector
commandDelimiters[0] = _.text.FromString("@");
// Key selector
@ -137,6 +137,16 @@ protected function OnEnabled() {
_.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() {
@ -146,18 +156,27 @@ protected function OnDisabled() {
if (useMutateInput && __server() != none) {
__server().unreal.mutator.OnMutate(self).Disconnect();
}
useChatInput = false;
useMutateInput = false;
_.memory.Free3(registeredCommands, groupedCommands, chatCommandPrefix);
registeredCommands = none;
groupedCommands = none;
_.memory.Free3(tools.commands, tools.votings, chatCommandPrefix);
tools.commands = none;
tools.votings = none;
chatCommandPrefix = none;
_.memory.FreeMany(commandDelimiters);
commandDelimiters.length = 0;
ReleaseNameVotingsArray(/*out*/ registeredVotings);
_.memory.FreeMany(permissionGroupOrder);
permissionGroupOrder.length = 0;
FreeUsedCommandSets();
FreeRenamingRules();
_.commands._reloadFeature();
}
protected function SwapConfig(FeatureConfig config)
{
protected function SwapConfig(FeatureConfig config) {
local Commands newConfig;
newConfig = Commands(config);
@ -168,10 +187,14 @@ protected function SwapConfig(FeatureConfig config)
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.
/// 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
@ -229,7 +252,8 @@ public final static function bool IsUsingMutateInput() {
return false;
}
/// Returns prefix that will indicate that chat message is intended to be a command. By default "!".
/// 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() {
@ -242,309 +266,34 @@ public final static function Text GetChatPrefix() {
return none;
}
/// Returns `true` iff some voting is currently active.
public final function bool IsVotingRunning() {
if (currentVoting != none && currentVoting.HasEnded()) {
_.memory.Free(currentVoting);
currentVoting = none;
}
return (currentVoting != none);
}
/// Returns instance of the active voting.
///
/// `none` iff no voting is currently active.
public final function Voting GetCurrentVoting() {
if (currentVoting != none && currentVoting.HasEnded()) {
_.memory.Free(currentVoting);
currentVoting = none;
}
if (currentVoting != none) {
currentVoting.NewRef();
}
return currentVoting;
}
/// `true` if voting under the given name (case-insensitive) is already registered.
public final function bool IsVotingRegistered(BaseText processName) {
local int i;
for (i = 0; i < registeredVotings.length; i += 1) {
if (processName.Compare(registeredVotings[i].processName, SCASE_INSENSITIVE)) {
return true;
}
}
return false;
}
/// Returns class of the [`Voting`] registered under given name.
public final function StartVotingResult StartVoting(BaseText processName) {
local int i;
local Text votingSettingsName;
local class<Voting> processClass;
if (currentVoting != none && currentVoting.HasEnded()) {
_.memory.Free(currentVoting);
currentVoting = none;
}
if (currentVoting != none) {
return SVR_AlreadyInProgress;
}
for (i = 0; i < registeredVotings.length; i += 1) {
if (processName.Compare(registeredVotings[i].processName, SCASE_INSENSITIVE)) {
processClass = registeredVotings[i].processClass;
}
}
if (processClass == none) {
return SVR_UnknownVoting;
}
currentVoting = Voting(_.memory.Allocate(processClass));
currentVoting.Start(votingSettingsName);
return SVR_Success;
}
/// Registers a new voting class to be accessible through the [`Commands_Feature`].
///
/// When a voting class is registered, players can access it using the standard AcediaCore's "vote"
/// command.
/// However, note that registering a voting class is not mandatory for it to be usable.
/// In fact, if you want to prevent players from initiating a particular voting, you should avoid
/// registering it in this feature.
public final function RegisterVotingClass(class<Voting> newVotingClass) {
local int i;
local ACommandVote votingCommand;
local NamedVoting newRecord;
local Text votingName;
if (newVotingClass == none) return;
votingCommand = GetVotingCommand();
if (votingCommand == none) return;
// We can freely release this reference here, since another reference is guaranteed to be kept in registered command
_.memory.Free(votingCommand);
// But just to make sure
if (!votingCommand.IsAllocated()) return;
// First we check whether we already added this class
for (i = 0; i < registeredVotings.length; i += 1) {
if (registeredVotings[i].processClass == newVotingClass) {
return;
}
}
votingName = newVotingClass.static.GetPreferredName();
if (votingName.Compare(P("yes")) || votingName.Compare(P("no"))) {
_.logger.Auto(errYesNoVotingNamesReserved).ArgClass(newVotingClass).Arg(votingName);
return;
}
// Check for duplicates
for (i = 0; i < registeredVotings.length; i += 1) {
if (registeredVotings[i].processName.Compare(votingName)) {
_.logger
.Auto(errVotingWithSameNameAlreadyRegistered)
.ArgClass(newVotingClass)
.Arg(votingName)
.ArgClass(registeredVotings[i].processClass);
return;
}
}
newRecord.processClass = newVotingClass;
newRecord.processName = votingName;
registeredVotings[registeredVotings.length] = newRecord;
votingCommand.AddVotingInfo(votingName, newVotingClass);
}
/// Unregisters a voting class from the [`Commands_Feature`], preventing players from accessing it
/// through the standard AcediaCore "vote" command.
///
/// This method does not stop any existing voting processes associated with the unregistered class.
///
/// Use this method to remove a voting class that is no longer needed or to prevent players from
/// initiating a particular voting. Note that removing a voting class is a linear operation that may
/// take some time if many votings are currently registered. It is not expected to be a common
/// operation and should be used sparingly.
public final function RemoveVotingClass(class<Voting> newVotingClass) {
local int i;
local ACommandVote votingCommand;
if (newVotingClass == none) {
return;
}
for (i = 0; i < registeredVotings.length; i += 1) {
if (registeredVotings[i].processClass == newVotingClass) {
_.memory.Free(registeredVotings[i].processName);
registeredVotings.Remove(i, 1);
}
}
votingCommand = GetVotingCommand();
if (votingCommand == none) {
return;
}
// Simply rebuild the whole voting set from scratch
votingCommand.ResetVotingInfo();
for (i = 0; i < registeredVotings.length; i += 1) {
votingCommand.AddVotingInfo(
registeredVotings[i].processName,
registeredVotings[i].processClass);
}
_.memory.Free(votingCommand);
}
/// Registers given command class, making it available.
///
/// # Errors
///
/// Returns `true` if command was successfully registered and `false` otherwise`.
///
/// 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.
public final function bool RegisterCommand(class<Command> commandClass) {
local Text commandName, groupName;
local ArrayList groupArray;
local Command newCommandInstance, existingCommandInstance;
if (commandClass == none) return false;
if (registeredCommands == none) return false;
newCommandInstance = Command(_.memory.Allocate(commandClass, true));
commandName = newCommandInstance.GetName();
groupName = newCommandInstance.GetGroupName();
// 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(groupName);
_.memory.Free(newCommandInstance);
_.memory.Free(existingCommandInstance);
return false;
}
// Otherwise record new command
// `commandName` used as a key, do not deallocate it
registeredCommands.SetItem(commandName, newCommandInstance);
// Add to grouped collection
groupArray = groupedCommands.GetArrayList(groupName);
if (groupArray == none) {
groupArray = _.collections.EmptyArrayList();
}
groupArray.AddItem(newCommandInstance);
groupedCommands.SetItem(groupName, groupArray);
_.memory.Free4(groupArray, groupName, commandName, newCommandInstance);
return true;
}
/// Removes command of given class from the list of registered commands.
///
/// Removing once registered commands is not an action that is expected to be performed under normal
/// circumstances and it is not efficient.
/// It is linear on the current amount of commands.
public final function RemoveCommand(class<Command> commandClass) {
local int i;
local CollectionIterator iter;
local Command nextCommand;
local Text nextCommandName;
local array<Text> commandGroup;
local array<Text> keysToRemove;
if (commandClass == none) return;
if (registeredCommands == none) return;
for (iter = registeredCommands.Iterate(); !iter.HasFinished(); iter.Next()) {
nextCommand = Command(iter.Get());
nextCommandName = Text(iter.GetKey());
if (nextCommand == none || nextCommandName == none || nextCommand.class != commandClass) {
_.memory.Free2(nextCommand, nextCommandName);
continue;
}
keysToRemove[keysToRemove.length] = nextCommandName;
commandGroup[commandGroup.length] = nextCommand.GetGroupName();
_.memory.Free(nextCommand);
}
iter.FreeSelf();
for (i = 0; i < keysToRemove.length; i += 1) {
registeredCommands.RemoveItem(keysToRemove[i]);
_.memory.Free(keysToRemove[i]);
}
for (i = 0; i < commandGroup.length; i += 1) {
RemoveClassFromGroup(commandClass, commandGroup[i]);
}
_.memory.FreeMany(commandGroup);
}
/// Returns command based on a given name.
///
/// Name of the registered `Command` to return is case-insensitive.
/// Returns name, under which [`ACommandHelp`] is registered.
///
/// If no command with such name was registered - returns `none`.
public final function Command GetCommand(BaseText 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.
public final function array<Text> GetCommandNames() {
local array<Text> emptyResult;
if (registeredCommands != none) {
return registeredCommands.GetTextKeys();
}
return emptyResult;
}
/// Returns array of names of all available commands belonging to the group [`groupName`].
public final function array<Text> GetCommandNamesInGroup(BaseText groupName) {
local int i;
local ArrayList groupArray;
local Command nextCommand;
local array<Text> result;
if (groupedCommands == none) return result;
groupArray = groupedCommands.GetArrayList(groupName);
if (groupArray == none) return result;
for (i = 0; i < groupArray.GetLength(); i += 1) {
nextCommand = Command(groupArray.GetItem(i));
if (nextCommand != none) {
result[result.length] = nextCommand.GetName();
}
_.memory.Free(nextCommand);
}
return result;
}
/// Returns all available command groups' names.
public final function array<Text> GetGroupsNames() {
local array<Text> emptyResult;
/// If `Commands_Feature` is disabled, always returns `none`.
public final static function Text GetHelpCommandName() {
local Commands_Feature instance;
if (groupedCommands != none) {
return groupedCommands.GetTextKeys();
instance = Commands_Feature(GetEnabledInstance());
if (instance != none && instance.helpCommandName != none) {
return instance.helpCommandName.Copy();
}
return emptyResult;
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`].
/// 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.
/// [`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`]
/// 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;
@ -560,21 +309,23 @@ public final function bool HandleInput(BaseText input, EPlayer callerPlayer) {
/// 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`].
/// 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.
/// [`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`]
/// 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 Command commandInstance;
local User identity;
local CommandAPI.CommandConfigInfo commandPair;
local Command.CallData callData;
local CommandCallPair callPair;
@ -582,34 +333,25 @@ public final function bool HandleInputWith(Parser parser, EPlayer callerPlayer)
if (callerPlayer == none) return false;
if (!parser.Ok()) return false;
identity = callerPlayer.GetIdentity();
callPair = ParseCommandCallPairWith(parser);
commandInstance = GetCommand(callPair.commandName);
if (commandInstance == none && callerPlayer != none && callerPlayer.IsExistent()) {
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() && commandInstance != none) {
callData = commandInstance.ParseInputWith(parser, callerPlayer, callPair.subCommandName);
errorOccured = commandInstance.Execute(callData, callerPlayer);
commandInstance.DeallocateCallData(callData);
}
_.memory.Free2(callPair.commandName, callPair.subCommandName);
return errorOccured;
}
private final function ACommandVote GetVotingCommand() {
local AcediaObject registeredAsVote;
if (registeredCommands != none) {
registeredAsVote = registeredCommands.GetItem(P("vote"));
if (registeredAsVote != none && registeredAsVote.class == class'ACommandVote') {
return ACommandVote(registeredAsVote);
}
_.memory.Free(registeredAsVote);
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);
}
return none;
_.memory.Free4(commandPair.instance, callPair.commandName, callPair.subCommandName, identity);
return errorOccured;
}
// Parses command's name into `CommandCallPair` - sub-command is filled in case
@ -677,44 +419,290 @@ private function HandleMutate(string command, PlayerController sendingPlayer) {
parser.FreeSelf();
}
private final function RemoveClassFromGroup(class<Command> commandClass, BaseText commandGroup) {
private final function LoadConfigArrays() {
local int i;
local ArrayList groupArray;
local Command nextCommand;
local CommandListGroupPair nextCommandSetGroupPair;
groupArray = groupedCommands.GetArrayList(commandGroup);
if (groupArray == none) {
return;
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;
}
while (i < groupArray.GetLength()) {
nextCommand = Command(groupArray.GetItem(i));
if (nextCommand != none && nextCommand.class == commandClass) {
groupArray.RemoveIndex(i);
}
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 {
i += 1;
_.logger.Auto(warnNoCommandList).Arg(usedCommandLists[i].commandListName.Copy());
}
_.memory.Free(nextCommand);
}
if (groupArray.GetLength() == 0) {
groupedCommands.RemoveItem(commandGroup);
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;
}
_.memory.Free(groupArray);
// 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 ReleaseNameVotingsArray(out array<NamedVoting> toRelease) {
private final function FreeRenamingRules() {
local int i;
for (i = 0; i < toRelease.length; i += 1) {
_.memory.Free(toRelease[i].processName);
toRelease[i].processName = none;
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);
}
toRelease.length = 0;
votingRenamingRules.length = 0;
}
public final function CommandAPI.CommandFeatureTools _borrowTools() {
return tools;
}
defaultproperties {
configClass = class'Commands'
errCommandDuplicate = (l=LOG_Error,m="Command `%1` is already registered with name '%2'. Command `%3` with the same name will be ignored.")
errServerAPIUnavailable = (l=LOG_Error,m="Server API is currently unavailable. Input methods through chat and mutate command will not be available.")
errVotingWithSameNameAlreadyRegistered = (l=LOG_Error,m="Attempting to register voting process with class `%1` under the name \"%2\" when voting process `%3` is already registered. This is likely caused by conflicting mods.")
errYesNoVotingNamesReserved = (l=LOG_Error,m="Attempting to register voting process with class `%1` under the reserved name \"%2\". This is an issue with the mod that provided the voting, please contact its author.")
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\".")
}

39
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandAdded_Signal.uc

@ -0,0 +1,39 @@
/**
* 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 CommandsAPI_OnCommandAdded_Signal extends Signal;
public final function bool Emit(class<Command> addedClass, Text usedName) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnCommandAdded_Slot(nextSlot).connect(addedClass, usedName);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnCommandAdded_Slot'
}

38
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandAdded_Slot.uc

@ -0,0 +1,38 @@
/**
* 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 CommandsAPI_OnCommandAdded_Slot extends Slot;
delegate connect(class<Command> addedClass, Text usedName) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

39
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandRemoved_Signal.uc

@ -0,0 +1,39 @@
/**
* 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 CommandsAPI_OnCommandRemoved_Signal extends Signal;
public final function bool Emit(class<Command> removedClass) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnCommandRemoved_Slot(nextSlot).connect(removedClass);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnCommandRemoved_Slot'
}

38
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandRemoved_Slot.uc

@ -0,0 +1,38 @@
/**
* 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 CommandsAPI_OnCommandRemoved_Slot extends Slot;
delegate connect(class<Command> removedClass) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

39
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingAdded_Signal.uc

@ -0,0 +1,39 @@
/**
* 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 CommandsAPI_OnVotingAdded_Signal extends Signal;
public final function bool Emit(class<Voting> addedClass, Text usedName) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnVotingAdded_Slot(nextSlot).connect(addedClass, usedName);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnVotingAdded_Slot'
}

38
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingAdded_Slot.uc

@ -0,0 +1,38 @@
/**
* 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 CommandsAPI_OnVotingAdded_Slot extends Slot;
delegate connect(class<Voting> addedClass, Text usedName) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

39
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingEnded_Signal.uc

@ -0,0 +1,39 @@
/**
* 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 CommandsAPI_OnVotingEnded_Signal extends Signal;
public final function bool Emit(bool success, HashTable arguments) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnVotingEnded_Slot(nextSlot).connect(success, arguments);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnVotingEnded_Slot'
}

38
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingEnded_Slot.uc

@ -0,0 +1,38 @@
/**
* 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 CommandsAPI_OnVotingEnded_Slot extends Slot;
delegate connect(bool success, HashTable arguments) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

39
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingRemoved_Signal.uc

@ -0,0 +1,39 @@
/**
* 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 CommandsAPI_OnVotingRemoved_Signal extends Signal;
public final function bool Emit(class<Voting> removedClass) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnVotingRemoved_Slot(nextSlot).connect(removedClass);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnVotingRemoved_Slot'
}

38
sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingRemoved_Slot.uc

@ -0,0 +1,38 @@
/**
* 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 CommandsAPI_OnVotingRemoved_Slot extends Slot;
delegate connect(class<Voting> removedClass) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

239
sources/BaseAPI/API/Commands/PlayersParser.uc

@ -27,70 +27,74 @@ class PlayersParser extends AcediaObject
//!
//! Basic use is to specify one of the selectors:
//! 1. Key selector: "#<integer>" (examples: "#1", "#5").
//! This one is used to specify players by their key, assigned to them when they enter the game.
//! This one is used to specify players by their key, assigned to them when
//! they enter the game.
//! This type of selectors can be used when players have hard to type names.
//! 2. Macro selector: "@self", "@me", "@all", "@admin" or just "@".
//! "@", "@me", and "@self" are identical and can be used to specify player that called
//! the command.
//! "@", "@me", and "@self" are identical and can be used to specify player
//! that called the command.
//! "@admin" can be used to specify all admins in the game at once.
//! "@all" specifies all current players.
//! In future it is planned to make macros extendable by allowing to bind more names to specific
//! groups of players.
//! 3. Name selectors: quoted strings and any other types of string that do not start with
//! either "#" or "@".
//! In future it is planned to make macros extendable by allowing to bind
//! more names to specific groups of players.
//! 3. Name selectors: quoted strings and any other types of string that do not
//! start with either "#" or "@".
//! These specify name prefixes: any player with specified prefix will be considered to match
//! such selector.
//!
//! Negated selectors: "!<selector>". Specifying "!" in front of selector will select all players
//! that do not match it instead.
//! Negated selectors: "!<selector>". Specifying "!" in front of selector will
//! select all players that do not match it instead.
//!
//! Grouped selectors: "['<selector1>', '<selector2>', ... '<selectorN>']".
//! Specified selectors are process in order: from left to right.
//! First selector works as usual and selects a set of players.
//! All the following selectors either expand that list (additive ones, without "!" prefix) or
//! remove specific players from the list (the ones with "!" prefix).
//! Examples of that:
//! All the following selectors either expand that list (additive ones, without
//! "!" prefix) or remove specific players from the list (the ones with "!"
//! prefix). Examples of that:
//!
//! * "[@admin, !@self]" - selects all admins, except the one who called the command
//! (whether he is admin or not).
//! * "[dkanus, 'mate']" - will select players "dkanus" and "mate". Order also matters, since:
//! * "[@admin, !@admin]" - won't select anyone, since it will first add all the admins and
//! then remove them.
//! * "[!@admin, @admin]" - will select everyone, since it will first select everyone who is
//! not an admin and then adds everyone else.
//! * "[@admin, !@self]" - selects all admins, except the one who called
//! the command (whether he is admin or not).
//! * "[dkanus, 'mate']" - will select players "dkanus" and "mate".
//! Order also matters, since:
//! - "[@admin, !@admin]" - won't select anyone, since it will first add all
//! the admins and then remove them.
//! - "[!@admin, @admin]" - will select everyone, since it will first select
//! everyone who is not an admin and then adds everyone else.
//!
//! # Usage
//!
//! 1. Allocate `PlayerParser`;
//! 2. Set caller player through `SetSelf()` method to make "@" and "@me" selectors usable;
//! 3. Call `Parse()` or `ParseWith()` method with `BaseText`/`Parser` that starts with proper
//! players selector;
//! 2. Set caller player through `SetSelf()` method to make "@" and "@me"
//! selectors usable;
//! 3. Call `Parse()` or `ParseWith()` method with `BaseText`/`Parser` that
//! starts with proper players selector;
//! 4. Call `GetPlayers()` to obtain selected players array.
//!
//! # Implementation
//!
//! When created, `PlayersParser` takes a snapshot (array) of current players on the server.
//! Then `currentSelection` is decided based on whether first selector is positive
//! (initial selection is taken as empty array) or negative
//! When created, `PlayersParser` takes a snapshot (array) of current players on
//! the server. Then `currentSelection` is decided based on whether first
//! selector is positive (initial selection is taken as empty array) or negative
//! (initial selection is taken as full snapshot).
//!
//! After that `PlayersParser` simply goes through specified selectors
//! (in case more than one is specified) and adds or removes appropriate players in
//! `currentSelection`, assuming that `playersSnapshot` is a current full array of players.
//! (in case more than one is specified) and adds or removes appropriate players
//! in `currentSelection`, assuming that `playersSnapshot` is a current full
//! array of players.
// Player for which "@", "@me", and "@self" macros will refer
/// Player for which "@", "@me", and "@self" macros will refer
var private EPlayer selfPlayer;
// Copy of the list of current players at the moment of allocation of
// this `PlayersParser`.
/// Copy of the list of current players at the moment of allocation of
/// this `PlayersParser`.
var private array<EPlayer> playersSnapshot;
// Players, selected according to selectors we have parsed so far
/// Players, selected according to selectors we have parsed so far
var private array<EPlayer> currentSelection;
// Have we parsed our first selector?
// We need this to know whether to start with the list of
// all players (if first selector removes them) or
// with empty list (if first selector adds them).
/// Have we parsed our first selector?
/// We need this to know whether to start with the list of
/// all players (if first selector removes them) or
/// with empty list (if first selector adds them).
var private bool parsedFirstSelector;
// Will be equal to a single-element array [","], used for parsing
/// Will be equal to a single-element array [","], used for parsing
var private array<Text> selectorDelimiters;
var const int TSELF, TME, TADMIN, TALL, TNOT, TKEY, TMACRO, TCOMMA;
@ -118,8 +122,77 @@ public final function SetSelf(EPlayer newSelfPlayer) {
}
}
// Insert a new player into currently selected list of players (`currentSelection`) such that there
// will be no duplicates.
/// Returns players parsed by the last `ParseWith()` or `Parse()` call.
///
/// If neither were yet called - returns an empty array.
public final function array<EPlayer> GetPlayers() {
local int i;
local array<EPlayer> result;
for (i = 0; i < currentSelection.length; i += 1) {
if (currentSelection[i].IsExistent()) {
result[result.length] = EPlayer(currentSelection[i].Copy());
}
}
return result;
}
/// Parses players from `parser` according to the currently present players.
///
/// Array of parsed players can be retrieved by `self.GetPlayers()` method.
///
/// Returns `true` if parsing was successful and `false` otherwise.
public final function bool ParseWith(Parser parser) {
local Parser.ParserState confirmedState;
if (parser == none) return false;
if (!parser.Ok()) return false;
if (parser.HasFinished()) return false;
Reset();
confirmedState = parser.Skip().GetCurrentState();
if (!parser.Match(T(TOPEN_BRACKET)).Ok()) {
ParseSelector(parser.RestoreState(confirmedState));
if (parser.Ok()) {
return true;
}
return false;
}
while (parser.Ok() && !parser.HasFinished()) {
confirmedState = parser.Skip().GetCurrentState();
if (parser.Match(T(TCLOSE_BRACKET)).Ok()) {
return true;
}
parser.RestoreState(confirmedState);
if (parsedFirstSelector) {
parser.Match(T(TCOMMA)).Skip();
}
ParseSelector(parser);
parser.Skip();
}
parser.Fail();
return false;
}
/// Parses players from according to the currently present players.
///
/// Array of parsed players can be retrieved by `self.GetPlayers()` method.
/// Returns `true` if parsing was successful and `false` otherwise.
public final function bool Parse(BaseText toParse) {
local bool wasSuccessful;
local Parser parser;
if (toParse == none) {
return false;
}
parser = _.text.Parse(toParse);
wasSuccessful = ParseWith(parser);
parser.FreeSelf();
return wasSuccessful;
}
// Insert a new player into currently selected list of players
// (`currentSelection`) such that there will be no duplicates.
//
// `none` values are auto-discarded.
private final function InsertPlayer(EPlayer toInsert) {
@ -261,7 +334,8 @@ private final function RemoveByMacro(BaseText macroText) {
}
}
// Parses one selector from `parser`, while accordingly modifying current player selection list.
// Parses one selector from `parser`, while accordingly modifying current player
// selection list.
private final function ParseSelector(Parser parser) {
local bool additiveSelector;
local Parser.ParserState confirmedState;
@ -299,8 +373,8 @@ private final function ParseSelector(Parser parser) {
ParseNameSelector(parser, additiveSelector);
}
// Parse key selector (assuming "#" is already consumed), while accordingly modifying current player
// selection list.
// Parse key selector (assuming "#" is already consumed), while accordingly
// modifying current player selection list.
private final function ParseKeySelector(Parser parser, bool additiveSelector) {
local int key;
@ -315,8 +389,8 @@ private final function ParseKeySelector(Parser parser, bool additiveSelector) {
}
}
// Parse macro selector (assuming "@" is already consumed), while accordingly modifying current
// player selection list.
// Parse macro selector (assuming "@" is already consumed), while accordingly
// modifying current player selection list.
private final function ParseMacroSelector(Parser parser, bool additiveSelector) {
local MutableText macroName;
local Parser.ParserState confirmedState;
@ -339,7 +413,8 @@ private final function ParseMacroSelector(Parser parser, bool additiveSelector)
_.memory.Free(macroName);
}
// Parse name selector, while accordingly modifying current player selection list.
// Parse name selector, while accordingly modifying current player selection
// list.
private final function ParseNameSelector(Parser parser, bool additiveSelector) {
local MutableText playerName;
local Parser.ParserState confirmedState;
@ -362,10 +437,11 @@ private final function ParseNameSelector(Parser parser, bool additiveSelector) {
_.memory.Free(playerName);
}
// Reads a string that can either be a body of name selector (some player's name prefix) or
// of a macro selector (what comes after "@").
// Reads a string that can either be a body of name selector (some player's
// name prefix) or of a macro selector (what comes after "@").
//
// This is different from `parser.MString()` because it also uses "," as a separator.
// This is different from `parser.MString()` because it also uses "," as
// a separator.
private final function MutableText ParseLiteral(Parser parser) {
local MutableText literal;
local Parser.ParserState confirmedState;
@ -381,58 +457,6 @@ private final function MutableText ParseLiteral(Parser parser) {
return literal;
}
/// Returns players parsed by the last `ParseWith()` or `Parse()` call.
///
/// If neither were yet called - returns an empty array.
public final function array<EPlayer> GetPlayers() {
local int i;
local array<EPlayer> result;
for (i = 0; i < currentSelection.length; i += 1) {
if (currentSelection[i].IsExistent()) {
result[result.length] = EPlayer(currentSelection[i].Copy());
}
}
return result;
}
/// Parses players from `parser` according to the currently present players.
///
/// Array of parsed players can be retrieved by `self.GetPlayers()` method.
///
/// Returns `true` if parsing was successful and `false` otherwise.
public final function bool ParseWith(Parser parser) {
local Parser.ParserState confirmedState;
if (parser == none) return false;
if (!parser.Ok()) return false;
if (parser.HasFinished()) return false;
Reset();
confirmedState = parser.Skip().GetCurrentState();
if (!parser.Match(T(TOPEN_BRACKET)).Ok()) {
ParseSelector(parser.RestoreState(confirmedState));
if (parser.Ok()) {
return true;
}
return false;
}
while (parser.Ok() && !parser.HasFinished()) {
confirmedState = parser.Skip().GetCurrentState();
if (parser.Match(T(TCLOSE_BRACKET)).Ok()) {
return true;
}
parser.RestoreState(confirmedState);
if (parsedFirstSelector) {
parser.Match(T(TCOMMA)).Skip();
}
ParseSelector(parser);
parser.Skip();
}
parser.Fail();
return false;
}
// Resets this object to initial state before parsing and update
// `playersSnapshot` to contain current players.
private final function Reset() {
@ -446,23 +470,6 @@ private final function Reset() {
selectorDelimiters[1] = T(TCLOSE_BRACKET);
}
/// Parses players from according to the currently present players.
///
/// Array of parsed players can be retrieved by `self.GetPlayers()` method.
/// Returns `true` if parsing was successful and `false` otherwise.
public final function bool Parse(BaseText toParse) {
local bool wasSuccessful;
local Parser parser;
if (toParse == none) {
return false;
}
parser = _.text.Parse(toParse);
wasSuccessful = ParseWith(parser);
parser.FreeSelf();
return wasSuccessful;
}
defaultproperties {
TSELF = 0
stringConstants(0) = "self"

351
sources/BaseAPI/API/Commands/Tests/TEST_Voting.uc

@ -0,0 +1,351 @@
/**
* 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 TEST_Voting extends TestCase
abstract
dependsOn(VotingModel);
enum ExpectedOutcome {
TEST_EO_Continue,
TEST_EO_End,
};
protected static function VotingModel MakeVotingModel(bool drawMeansWin) {
local VotingModel model;
model = VotingModel(__().memory.Allocate(class'VotingModel'));
model.Start(drawMeansWin);
return model;
}
protected static function SetVoters(
VotingModel model,
optional string voterID0,
optional string voterID1,
optional string voterID2,
optional string voterID3,
optional string voterID4,
optional string voterID5,
optional string voterID6,
optional string voterID7,
optional string voterID8,
optional string voterID9
) {
local UserID nextID;
local array<UserID> voterIDs;
if (voterID0 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID0));
voterIDs[voterIDs.length] = nextID;
}
if (voterID1 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID1));
voterIDs[voterIDs.length] = nextID;
}
if (voterID2 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID2));
voterIDs[voterIDs.length] = nextID;
}
if (voterID3 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID3));
voterIDs[voterIDs.length] = nextID;
}
if (voterID4 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID4));
voterIDs[voterIDs.length] = nextID;
}
if (voterID5 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID5));
voterIDs[voterIDs.length] = nextID;
}
if (voterID6 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID6));
voterIDs[voterIDs.length] = nextID;
}
if (voterID7 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID7));
voterIDs[voterIDs.length] = nextID;
}
if (voterID8 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID8));
voterIDs[voterIDs.length] = nextID;
}
if (voterID9 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID9));
voterIDs[voterIDs.length] = nextID;
}
model.UpdatePotentialVoters(voterIDs);
}
protected static function MakeFaultyYesVote(
VotingModel model,
string voterID,
VotingModel.VotingResult expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Illegal vote had unexpected result.");
TEST_ExpectTrue(model.CastVote(id, true) == expected);
}
protected static function MakeFaultyNoVote(
VotingModel model,
string voterID,
VotingModel.VotingResult expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Illegal vote had unexpected result.");
TEST_ExpectTrue(model.CastVote(id, false) == expected);
}
protected static function VoteYes(VotingModel model, string voterID, ExpectedOutcome expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Failed to add legitimate vote.");
TEST_ExpectTrue(model.CastVote(id, true) == VFR_Success);
if (expected == TEST_EO_Continue) {
Issue("Vote, that shouldn't have ended voting, ended it.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
} else if (expected == TEST_EO_End) {
Issue("Vote, that should've ended voting with one side's victory, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
}
}
protected static function VoteNo(VotingModel model, string voterID, ExpectedOutcome expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Failed to add legitimate vote.");
TEST_ExpectTrue(model.CastVote(id, false) == VFR_Success);
if (expected == TEST_EO_Continue) {
Issue("Vote, that shouldn't have ended voting, ended it.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
} else if (expected == TEST_EO_End) {
Issue("Vote, that should've ended voting with one side's victory, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Failure);
}
}
protected static function TESTS() {
Test_VotingModel();
}
protected static function Test_VotingModel() {
SubTest_YesVoting();
SubTest_NoVoting();
SubTest_FaultyVoting();
SubTest_DisconnectVoting_DrawMeansWin();
SubTest_DisconnectVoting_DrawMeansLoss();
SubTest_ReconnectVoting_DrawMeansWin();
SubTest_ReconnectVoting_DrawMeansLoss();
}
protected static function SubTest_YesVoting() {
local VotingModel model;
Context("Testing \"yes\" voting.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
}
protected static function SubTest_NoVoting() {
local VotingModel model;
Context("Testing \"no\" voting.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteNo(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_End);
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteNo(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_End);
}
protected static function SubTest_FaultyVoting() {
local VotingModel model;
Context("Testing \"faulty\" voting.");
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
MakeFaultyYesVote(model, "4", VFR_VotingEnded); // voting already ended, so no more votes here
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
MakeFaultyYesVote(model, "4", VFR_VotingEnded); // voting already ended, so no more votes here
}
protected static function SubTest_DisconnectVoting_DrawMeansWin() {
local VotingModel model;
Context("Testing \"disconnect\" voting when draw means victory.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
SetVoters(model, "2", "4", "5", "6", "8", "9", "10"); // remove 1, 3, 7 - 2 "yes" votes
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "9", TEST_EO_Continue);
// Here we're at 3 "no" votes, 3 "yes" votes out of 7 total;
// disconnect "6" and "9" for "yes" to win
SetVoters(model, "2", "4", "5", "8", "10");
Issue("Unexpected result after voting users disconnected.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
}
protected static function SubTest_DisconnectVoting_DrawMeansLoss() {
local VotingModel model;
Context("Testing \"disconnect\" voting when draw means loss.");
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "7", TEST_EO_Continue);
SetVoters(model, "2", "4", "5", "6", "8", "9", "10"); // remove 1, 3, 7 - 3 "yes" votes
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "9", TEST_EO_Continue);
// Here we're at 3 "no" votes, 3 "yes" votes out of 7 total;
// disconnect "6" and "9" for "yes" to win
SetVoters(model, "2", "4", "5", "8", "10");
Issue("Unexpected result after voting users disconnected.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
}
protected static function SubTest_ReconnectVoting_DrawMeansWin() {
local VotingModel model;
Context("Testing \"reconnect\" voting when draw means victory.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 3 "yes" voters
SetVoters(model, "2", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "7", TEST_EO_Continue);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "9", TEST_EO_Continue);
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "3", VFR_NotAllowed);
// Restore 3 "yes" voter
SetVoters(model, "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteNo(model, "3", TEST_EO_End);
}
protected static function SubTest_ReconnectVoting_DrawMeansLoss() {
local VotingModel model;
Context("Testing \"reconnect\" voting when draw means loss.");
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 3 "yes" voters
SetVoters(model, "2", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "7", TEST_EO_Continue);
VoteYes(model, "9", TEST_EO_Continue);
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "3", VFR_NotAllowed);
// Restore 3 "yes" voter
SetVoters(model, "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteNo(model, "8", TEST_EO_Continue);
VoteNo(model, "3", TEST_EO_End);
}
defaultproperties {
caseGroup = "Commands"
caseName = "Voting model"
}

306
sources/BaseAPI/API/Commands/Tools/CmdItemsTool.uc

@ -0,0 +1,306 @@
/**
* 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 CmdItemsTool extends AcediaObject
dependson(CommandAPI)
abstract;
//! This is a base class for auxiliary objects that will be used for storing
//! named [`Command`] instances and [`Voting`] classes: they both have in common
//! the need to remember who was authorized to use them (i.e. which user group)
//! and with what permissions (i.e. name of the config that contains appropriate
//! permissions).
//!
//! Aside from trivial accessors to its data, it also provides a way to resolve
//! the best permissions available to the user by finding the most priviledged
//! group he belongs to.
//!
//! NOTE: child classes must implement `MakeCard()` method and can override
//! `DiscardCard()` method to catch events of removing items from storage.
/// Allows to specify a base class requirement for this tool - only classes
/// that were derived from it can be stored inside.
var protected const class<AcediaObject> ruleBaseClass;
/// Names of user groups that can decide permissions for items,
/// in order of importance: from most significant to the least significant.
/// This is used for resolving the best permissions for each user.
var private array<Text> permissionGroupOrder;
/// Maps item names to their [`ItemCards`] with information about which groups
/// are authorized to use this particular item.
var private HashTable registeredCards;
var LoggerAPI.Definition errItemInvalidName;
var LoggerAPI.Definition errItemDuplicate;
protected function Constructor() {
registeredCards = _.collections.EmptyHashTable();
}
protected function Finalizer() {
_.memory.Free(registeredCards);
_.memory.FreeMany(permissionGroupOrder);
registeredCards = none;
permissionGroupOrder.length = 0;
}
/// Registers given item class under the specified (case-insensitive) name.
///
/// If name parameter is omitted (specified as `none`) or is an invalid name
/// (according to [`BaseText::IsValidName()`] method), then item class will not
/// be registered.
///
/// Returns `true` if item was successfully registered and `false` otherwise`.
///
/// # Errors
///
/// If provided name that is invalid or already taken by a different item -
/// a warning will be logged and item class won't be registered.
public function bool AddItemClass(class<AcediaObject> itemClass, BaseText itemName) {
local Text itemKey;
local ItemCard newCard, existingCard;
if (itemClass == none) return false;
if (itemName == none) return false;
if (registeredCards == none) return false;
if (ruleBaseClass == none || !ClassIsChildOf(itemClass, ruleBaseClass)) {
return false;
}
// The item name is transformed into lowercase, immutable value.
// This facilitates the use of item names as keys in a [`HashTable`],
// enabling case-insensitive matching.
itemKey = itemName.LowerCopy();
if (itemKey == none || !itemKey.IsValidName()) {
_.logger.Auto(errItemInvalidName).ArgClass(itemClass).Arg(itemKey);
return false;
}
// Guaranteed to only store cards
existingCard = ItemCard(registeredCards.GetItem(itemName));
if (existingCard != none) {
_.logger.Auto(errItemDuplicate)
.ArgClass(existingCard.GetItemClass())
.Arg(itemKey)
.ArgClass(itemClass);
_.memory.Free(existingCard);
return false;
}
newCard = MakeCard(itemClass, itemName);
registeredCards.SetItem(itemKey, newCard);
_.memory.Free2(itemKey, newCard);
return true;
}
/// Removes item of given class from the list of registered items.
///
/// Removing once registered item is not an action that is expected to
/// be performed under normal circumstances and does not have an efficient
/// implementation (it is linear on the current amount of items).
///
/// Returns `true` if successfully removed registered item class and
/// `false` otherwise (either item wasn't registered or caller tool
/// initialized).
public function bool RemoveItemClass(class<AcediaObject> itemClass) {
local int i;
local CollectionIterator iter;
local ItemCard nextCard;
local array<Text> keysToRemove;
if (itemClass == none) return false;
if (registeredCards == none) return false;
// Removing items during iterator breaks an iterator, so first we find
// all the keys to remove
iter = registeredCards.Iterate();
iter.LeaveOnlyNotNone();
while (!iter.HasFinished()) {
// Guaranteed to only be `ItemCard`
nextCard = ItemCard(iter.Get());
if (nextCard.GetItemClass() == itemClass) {
keysToRemove[keysToRemove.length] = Text(iter.GetKey());
DiscardCard(nextCard);
}
_.memory.Free(nextCard);
iter.Next();
}
iter.FreeSelf();
// Actual clean up everything in `keysToRemove`
for (i = 0; i < keysToRemove.length; i += 1) {
registeredCards.RemoveItem(keysToRemove[i]);
}
_.memory.FreeMany(keysToRemove);
return (keysToRemove.length > 0);
}
/// Allows to specify the order of the user group in terms of privilege for
/// accessing stored items. Only specified groups will be used when resolving
/// appropriate permissions config name for a user.
public final function SetPermissionGroupOrder(array<Text> groupOrder) {
local int i;
_.memory.FreeMany(permissionGroupOrder);
permissionGroupOrder.length = 0;
for (i = 0; i < groupOrder.length; i += 1) {
if (groupOrder[i] != none) {
permissionGroupOrder[permissionGroupOrder.length] = groupOrder[i].Copy();
}
}
}
/// Specifies what permissions (given by the config name) given user group has
/// when using an item with a specified name.
///
/// Method must be called after item with a given name is added.
///
/// If this config name is specified as `none`, then "default" will be
/// used instead. For non-`none` values, only an invalid name (according to
/// [`BaseText::IsValidName()`] method) will prevent the group from being
/// registered.
///
/// Method will return `true` if group was successfully authorized and `false`
/// otherwise (either group already authorized or no item with specified name
/// was added in the caller tool so far).
///
/// # Errors
///
/// If specified group was already authorized to use card's item, then it
/// will log a warning message about it.
public function bool AuthorizeUsage(BaseText itemName, BaseText groupName, BaseText configName) {
local bool result;
local ItemCard relevantCard;
if (configName != none && !configName.IsValidName()) {
return false;
}
relevantCard = GetCard(itemName);
if (relevantCard != none) {
result = relevantCard.AuthorizeGroupWithConfig(groupName, configName);
_.memory.Free(relevantCard);
}
return result;
}
/// Returns struct with item class (+ instance, if one was stored) for a given
/// case in-sensitive item name and name of the config with best permissions
/// available to the player with provided ID.
///
/// Function only returns `none` for item class if item with a given name
/// wasn't found.
/// Config name being `none` with non-`none` item class in the result means
/// that user with provided ID doesn't have permissions for using the item at
/// all.
public final function CommandAPI.ItemConfigInfo ResolveItem(BaseText itemName, BaseText textID) {
local int i;
local ItemCard relevantCard;
local CommandAPI.ItemConfigInfo result;
relevantCard = GetCard(itemName);
if (relevantCard == none) {
// At this point contains `none` for all values -> indicates a failure
// to find item in storage
return result;
}
result.instance = relevantCard.GetItem();
result.class = relevantCard.GetItemClass();
if (textID == none) {
return result;
}
// Look through all `permissionGroupOrder` in order to find most priviledged
// group that user with `textID` belongs to
for (i = 0; i < permissionGroupOrder.length && result.configName == none; i += 1) {
if (_.users.IsSteamIDInGroup(textID, permissionGroupOrder[i])) {
result.configName = relevantCard.GetConfigNameForGroup(permissionGroupOrder[i]);
}
}
_.memory.Free(relevantCard);
return result;
}
/// Returns all item classes that are stored inside caller tool.
///
/// Doesn't check for duplicates (although with a normal usage, there shouldn't
/// be any).
public final function array< class<AcediaObject> > GetAllItemClasses() {
local array< class<AcediaObject> > result;
local ItemCard value;
local CollectionIterator iter;
for (iter = registeredCards.Iterate(); !iter.HasFinished(); iter.Next()) {
value = ItemCard(iter.Get());
if (value != none) {
result[result.length] = value.GetItemClass();
}
_.memory.Free(value);
}
iter.FreeSelf();
return result;
}
/// Returns array of names of all available items.
public final function array<Text> GetItemsNames() {
local array<Text> emptyResult;
if (registeredCards != none) {
return registeredCards.GetTextKeys();
}
return emptyResult;
}
/// Called each time a new card is to be created and stored.
///
/// Must be reimplemented by child classes.
protected function ItemCard MakeCard(class<AcediaObject> itemClass, BaseText itemName) {
return none;
}
/// Called each time a certain card is to be removed from storage.
///
/// Must be reimplemented by child classes
/// (reimplementations SHOULD NOT DEALLOCATE `toDiscard`).
protected function DiscardCard(ItemCard toDiscard) {
}
/// Find item card for the item that was stored with a specified
/// case-insensitive name
///
/// Function only returns `none` if item with a given name wasn't found
/// (or `none` was provided as an argument).
protected final function ItemCard GetCard(BaseText itemName) {
local Text itemKey;
local ItemCard relevantCard;
if (itemName == none) return none;
if (registeredCards == none) return none;
/// The item name is transformed into lowercase, immutable value.
/// This facilitates the use of item names as keys in a [`HashTable`],
/// enabling case-insensitive matching.
itemKey = itemName.LowerCopy();
relevantCard = ItemCard(registeredCards.GetItem(itemKey));
_.memory.Free(itemKey);
return relevantCard;
}
defaultproperties {
errItemInvalidName = (l=LOG_Error,m="Attempt at registering item with class `%1` under an invalid name \"%2\" will be ignored.")
errItemDuplicate = (l=LOG_Error,m="Command `%1` is already registered with name '%2'. Attempt at registering command `%3` with the same name will be ignored.")
}

142
sources/BaseAPI/API/Commands/Tools/CommandsTool.uc

@ -0,0 +1,142 @@
/**
* 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 CommandsTool extends CmdItemsTool;
//! This is a base class for auxiliary objects that will be used for storing
//! named [`Command`] instances.
//!
//! This storage class allows for efficient manipulation and retrieval of
//! [`Command`]s, along with information about what use groups were authorized
//! to use them.
//!
//! Additionally, this tool allows for efficient fetching of commands that
//! belong to a particular *command group*.
/// [`HashTable`] that maps a command group name to a set of command names that
/// belong to it.
var private HashTable groupedCommands;
protected function Constructor() {
super.Constructor();
groupedCommands = _.collections.EmptyHashTable();
}
protected function Finalizer() {
super.Finalizer();
_.memory.Free(groupedCommands);
groupedCommands = none;
}
/// Returns all known command groups' names.
public final function array<Text> GetGroupsNames() {
local array<Text> emptyResult;
if (groupedCommands != none) {
return groupedCommands.GetTextKeys();
}
return emptyResult;
}
/// Returns array of names of all available commands belonging to the specified
/// group.
public final function array<Text> GetCommandNamesInGroup(BaseText groupName) {
local int i;
local ArrayList commandNamesArray;
local array<Text> result;
if (groupedCommands == none) return result;
commandNamesArray = groupedCommands.GetArrayList(groupName);
if (commandNamesArray == none) return result;
for (i = 0; i < commandNamesArray.GetLength(); i += 1) {
result[result.length] = commandNamesArray.GetText(i);
}
_.memory.Free(commandNamesArray);
return result;
}
protected function ItemCard MakeCard(class<AcediaObject> commandClass, BaseText itemName) {
local Command newCommandInstance;
local ItemCard newCard;
local Text commandGroup;
if (class<Command>(commandClass) != none) {
newCommandInstance = Command(_.memory.Allocate(commandClass, true));
newCommandInstance.Initialize(itemName);
newCard = ItemCard(_.memory.Allocate(class'ItemCard'));
newCard.InitializeWithInstance(newCommandInstance);
// Guaranteed to be lower case (keys of [`HashTable`])
if (itemName != none) {
itemName = itemName.LowerCopy();
} else {
itemName = newCommandInstance.GetPreferredName();
}
commandGroup = newCommandInstance.GetGroupName();
AssociateGroupAndName(commandGroup, itemName);
_.memory.Free3(newCommandInstance, itemName, commandGroup);
}
return newCard;
}
protected function DiscardCard(ItemCard toDiscard) {
local Text groupKey, commandName;
local Command storedCommand;
local ArrayList listOfCommands;
if (toDiscard == none) return;
// Guaranteed to store a [`Command`]
storedCommand = Command(toDiscard.GetItem());
if (storedCommand == none) return;
// Guaranteed to be stored in a lower case
commandName = storedCommand.GetName();
listOfCommands = groupedCommands.GetArrayList(groupKey);
if (listOfCommands != none && commandName != none) {
listOfCommands.RemoveItem(commandName);
}
_.memory.Free2(commandName, storedCommand);
}
// Expect both arguments to be not `none`.
// Expect both arguments to be lower-case.
private final function AssociateGroupAndName(BaseText groupKey, BaseText commandName) {
local ArrayList listOfCommands;
if (groupedCommands != none) {
listOfCommands = groupedCommands.GetArrayList(groupKey);
if (listOfCommands == none) {
listOfCommands = _.collections.EmptyArrayList();
}
if (listOfCommands.Find(commandName) < 0) {
// `< 0` means not found
listOfCommands.AddItem(commandName);
}
// Set `listOfCommands` in case we've just created that array.
// Won't do anything if it is already recorded there.
groupedCommands.SetItem(groupKey, listOfCommands);
}
}
defaultproperties {
ruleBaseClass = class'Command';
}

177
sources/BaseAPI/API/Commands/Tools/ItemCard.uc

@ -0,0 +1,177 @@
/**
* 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 ItemCard extends AcediaObject;
//! Utility class designed for storing either class of an object
//! (possibly also a specific instance) along with authorization information:
//! which user groups are allowed to use stored entity and with what level of
//! permissions (defined by the name of a config with permissions).
//!
//! [`ItemCard`] has to be initialized with either [`InitializeWithClass()`] or
//! [`InitializeWithInstance()`] before it can be used.
/// Class of object that this card describes.
var private class<AcediaObject> storedClass;
/// Instance of an object (can also *optionally* be stored in this card)
var private AcediaObject storedInstance;
/// This [`HashTable`] maps authorized groups to their respective config names.
///
/// Each key represents an authorized group, and its corresponding value
/// indicates the associated config name. If a key has a value of `none`,
/// the default config (named "default") should be used for that group.
var private HashTable groupToConfig;
var LoggerAPI.Definition errGroupAlreadyHasConfig;
protected function Finalizer() {
_.memory.Free2(storedInstance, groupToConfig);
storedInstance = none;
storedClass = none;
groupToConfig = none;
}
/// Initializes the caller [`ItemCard`] object with class to be stored.
///
/// Initialization can only be done once: once method returned `true`,
/// all future calls will fail.
///
/// Returns `false` if caller was already initialized or `none` is provided as
/// an argument. Otherwise succeeds and returns `true`.
public function bool InitializeWithClass(class<AcediaObject> toStore) {
if (storedClass != none) return false;
if (toStore == none) return false;
storedClass = toStore;
groupToConfig = _.collections.EmptyHashTable();
return true;
}
/// Initializes the caller [`ItemCard`] object with an object to be stored.
///
/// Initialization can only be done once: once method returned `true`,
/// all future calls will fail.
///
/// Returns `false` caller was already initialized or `none` is provided as
/// an argument. Otherwise succeeds and returns `true`.
public function bool InitializeWithInstance(AcediaObject toStore) {
if (storedClass != none) return false;
if (toStore == none) return false;
storedClass = toStore.class;
storedInstance = toStore;
storedInstance.NewRef();
groupToConfig = _.collections.EmptyHashTable();
return true;
}
/// Authorizes a new group to use the this card's item.
///
/// This function allows to specify the config name for a particular user group.
/// If this config name is skipped (specified as `none`), then "default" will be
/// used instead.
///
/// Function will return `true` if group was successfully authorized and
/// `false` otherwise (either group already authorized or caller [`ItemCard`]
/// isn't initialized).
///
/// # Errors
///
/// If specified group was already authorized to use card's item, then it
/// will log an error message about it.
public function bool AuthorizeGroupWithConfig(BaseText groupName, optional BaseText configName) {
local Text itemKey;
local Text storedConfigName;
if (storedClass == none) return false;
if (groupToConfig == none) return false;
if (groupName == none) return false;
if (groupName.IsEmpty()) return false;
/// Make group name immutable and have its characters have a uniform case to
/// be usable as case-insensitive keys for [`HashTable`].
itemKey = groupName.LowerCopy();
storedConfigName = groupToConfig.GetText(itemKey);
if (storedConfigName != none) {
_.logger.Auto(errGroupAlreadyHasConfig)
.ArgClass(storedClass)
.Arg(groupName.Copy())
.Arg(storedConfigName)
.Arg(configName.Copy());
_.memory.Free(itemKey);
return false;
}
// We don't actually record "default" value at this point, instead opting
// to return "default" in getter functions in case stored `configName`
// is `none`.
groupToConfig.SetItem(itemKey, configName);
_.memory.Free(itemKey);
return true;
}
/// Returns item instance for the caller [`ItemCard`].
///
/// Returns `none` iff this card wasn't initialized with an instance.
public function AcediaObject GetItem() {
if (storedInstance != none) {
storedInstance.NewRef();
}
return storedInstance;
}
/// Returns item class for the caller [`ItemCard`].
///
/// Returns `none` iff this card wasn't initialized.
public function class<AcediaObject> GetItemClass() {
return storedClass;
}
/// Returns the name of config that was authorized for the specified group.
///
/// Returns `none` if group wasn't authorized, otherwise guaranteed to
/// return non-`none` and non-empty `Text` value.
public function Text GetConfigNameForGroup(BaseText groupName) {
local Text groupNameAsKey, result;
if (storedClass == none) return none;
if (groupToConfig == none) return none;
if (groupName == none) return none;
/// Make group name immutable and have its characters a uniform case to
/// be usable as case-insensitive keys for [`HashTable`]
groupNameAsKey = groupName.LowerCopy();
if (groupToConfig.HasKey(groupNameAsKey)) {
result = groupToConfig.GetText(groupNameAsKey);
if (result == none) {
// If we do have specified group recorded as a key, then we must
// return non-`none` config name, defaulting to "default" value
// if none was provided
result = P("default").Copy();
}
}
_.memory.Free(groupNameAsKey);
return result;
}
defaultproperties {
errGroupAlreadyHasConfig = (l=LOG_Error,m="Item `%1` is already added to group '%2' with config '%3'. Attempt to add it with config '%4' is ignored.")
}

119
sources/BaseAPI/API/Commands/Tools/VotingsTool.uc

@ -0,0 +1,119 @@
/**
* 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 VotingsTool extends CmdItemsTool
dependson(CommandAPI);
//! This is a base class for auxiliary objects that will be used for storing
//! named [`Voting`] classes.
//!
//! This storage class allows for efficient manipulation and retrieval of
//! [`Voting`] classes, along with information about what use groups were
//! authorized to use them.
//!
//! Additionally this tool is used to keep track of the currently ongoing
//! voting, preventing [`CommandsAPI`] from starting several votings at once.
/// Currently running voting process.
/// This tool doesn't actively track when voting ends, so reference can be
/// non-`none` even if voting has already ended. Instead `DropFinishedVoting()`
/// method is used as needed to figure out whether that voting has ended and
/// should be deallocated.
var private Voting currentVoting;
protected function Finalizer() {
super.Finalizer();
_.memory.Free(currentVoting);
currentVoting = none;
}
/// Starts a voting process with a given name, returning its result.
public final function CommandAPI.StartVotingResult StartVoting(
CommandAPI.VotingConfigInfo votingData,
HashTable arguments
) {
local CommandAPI.StartVotingResult result;
DropFinishedVoting();
if (currentVoting != none) {
return SVR_AlreadyInProgress;
}
if (votingData.votingClass == none) {
return SVR_UnknownVoting;
}
currentVoting = Voting(_.memory.Allocate(votingData.votingClass));
result = currentVoting.Start(votingData.config, arguments);
if (result != SVR_Success) {
_.memory.Free(currentVoting);
currentVoting = none;
}
return result;
}
/// Returns `true` iff some voting is currently active.
public final function bool IsVotingRunning() {
DropFinishedVoting();
return (currentVoting != none);
}
/// Returns instance of the active voting.
///
/// `none` iff no voting is currently active.
public final function Voting GetCurrentVoting() {
DropFinishedVoting();
if (currentVoting != none) {
currentVoting.NewRef();
}
return currentVoting;
}
protected function ItemCard MakeCard(class<AcediaObject> votingClass, BaseText itemName) {
local ItemCard newCard;
if (class<Voting>(votingClass) != none) {
newCard = ItemCard(_.memory.Allocate(class'ItemCard'));
newCard.InitializeWithClass(votingClass);
}
return newCard;
}
private final function class<Voting> GetVoting(BaseText itemName) {
local ItemCard relevantCard;
local class<Voting> result;
relevantCard = GetCard(itemName);
if (relevantCard != none) {
result = class<Voting>(relevantCard.GetItemClass());
}
_.memory.Free(relevantCard);
return result;
}
// Clears `currentVoting` if it has already finished
private final function DropFinishedVoting() {
if (currentVoting != none && currentVoting.HasEnded()) {
_.memory.Free(currentVoting);
currentVoting = none;
}
}
defaultproperties {
ruleBaseClass = class'Voting'
}

563
sources/BaseAPI/API/Commands/Voting/TEST_Voting.uc

@ -1,563 +0,0 @@
/**
* 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 TEST_Voting extends TestCase
abstract
dependsOn(VotingModel);
enum ExpectedOutcome {
TEST_EO_Continue,
TEST_EO_End,
TEST_EO_EndDraw,
};
protected static function VotingModel MakeVotingModel(VotingModel.VotingPolicies policies) {
local VotingModel model;
model = VotingModel(__().memory.Allocate(class'VotingModel'));
model.Initialize(policies);
return model;
}
protected static function SetVoters(
VotingModel model,
optional string voterID0,
optional string voterID1,
optional string voterID2,
optional string voterID3,
optional string voterID4,
optional string voterID5,
optional string voterID6,
optional string voterID7,
optional string voterID8,
optional string voterID9
) {
local UserID nextID;
local array<UserID> voterIDs;
if (voterID0 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID0));
voterIDs[voterIDs.length] = nextID;
}
if (voterID1 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID1));
voterIDs[voterIDs.length] = nextID;
}
if (voterID2 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID2));
voterIDs[voterIDs.length] = nextID;
}
if (voterID3 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID3));
voterIDs[voterIDs.length] = nextID;
}
if (voterID4 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID4));
voterIDs[voterIDs.length] = nextID;
}
if (voterID5 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID5));
voterIDs[voterIDs.length] = nextID;
}
if (voterID6 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID6));
voterIDs[voterIDs.length] = nextID;
}
if (voterID7 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID7));
voterIDs[voterIDs.length] = nextID;
}
if (voterID8 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID8));
voterIDs[voterIDs.length] = nextID;
}
if (voterID9 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID9));
voterIDs[voterIDs.length] = nextID;
}
model.UpdatePotentialVoters(voterIDs);
}
protected static function MakeFaultyYesVote(
VotingModel model,
string voterID,
VotingModel.VotingResult expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Illegal vote had unexpected result.");
TEST_ExpectTrue(model.CastVote(id, true) == expected);
}
protected static function MakeFaultyNoVote(
VotingModel model,
string voterID,
VotingModel.VotingResult expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Illegal vote had unexpected result.");
TEST_ExpectTrue(model.CastVote(id, false) == expected);
}
protected static function VoteYes(VotingModel model, string voterID, ExpectedOutcome expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Failed to add legitimate vote.");
TEST_ExpectTrue(model.CastVote(id, true) == VFR_Success);
if (expected == TEST_EO_Continue) {
Issue("Vote, that shouldn't have ended voting, ended it.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
} else if (expected == TEST_EO_End) {
Issue("Vote, that should've ended voting with one side's victory, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
} else if (expected == TEST_EO_EndDraw) {
Issue("Vote, that should've ended voting with a draw, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Draw);
}
}
protected static function VoteNo(VotingModel model, string voterID, ExpectedOutcome expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Failed to add legitimate vote.");
TEST_ExpectTrue(model.CastVote(id, false) == VFR_Success);
if (expected == TEST_EO_Continue) {
Issue("Vote, that shouldn't have ended voting, ended it.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
} else if (expected == TEST_EO_End) {
Issue("Vote, that should've ended voting with one side's victory, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Failure);
} else if (expected == TEST_EO_EndDraw) {
Issue("Vote, that should've ended voting with a draw, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Draw);
}
}
protected static function TESTS() {
Test_RestrictiveVoting();
Test_CanLeaveVoting();
Test_CanChangeVoting();
Test_All();
}
protected static function Test_RestrictiveVoting() {
SubTest_RestrictiveYesVoting();
SubTest_RestrictiveNoVoting();
SubTest_RestrictiveDrawVoting();
SubTest_RestrictiveFaultyVoting();
SubTest_RestrictiveDisconnectVoting();
SubTest_RestrictiveReconnectVoting();
}
protected static function SubTest_RestrictiveYesVoting() {
local VotingModel model;
Context("Testing restrictive \"yes\" voting.");
model = MakeVotingModel(VP_Restrictive);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_End);
}
protected static function SubTest_RestrictiveNoVoting() {
local VotingModel model;
Context("Testing restrictive \"no\" voting.");
model = MakeVotingModel(VP_Restrictive);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteNo(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_End);
}
protected static function SubTest_RestrictiveDrawVoting() {
local VotingModel model;
Context("Testing restrictive \"draw\" voting.");
model = MakeVotingModel(VP_Restrictive);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_EndDraw);
}
protected static function SubTest_RestrictiveFaultyVoting() {
local VotingModel model;
Context("Testing restrictive \"faulty\" voting.");
model = MakeVotingModel(VP_Restrictive);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
MakeFaultyYesVote(model, "3", VFR_AlreadyVoted);
VoteYes(model, "2", TEST_EO_Continue);
MakeFaultyNoVote(model, "7", VFR_NotAllowed);
VoteNo(model, "5", TEST_EO_Continue);
MakeFaultyNoVote(model, "7", VFR_NotAllowed);
MakeFaultyNoVote(model, "5", VFR_AlreadyVoted);
VoteYes(model, "1", TEST_EO_Continue);
MakeFaultyNoVote(model, "3", VFR_CannotChangeVote);
VoteYes(model, "6", TEST_EO_End);
MakeFaultyYesVote(model, "4", VFR_VotingEnded);
}
protected static function SubTest_RestrictiveDisconnectVoting() {
local VotingModel model;
Context("Testing restrictive \"disconnect\" voting.");
model = MakeVotingModel(VP_Restrictive);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "7", TEST_EO_Continue);
SetVoters(model, "2", "4", "5", "6", "8", "9", "10"); // remove 1, 3, 7 - 3 "yes" votes
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "9", TEST_EO_Continue);
// Here we're at 3 "no" votes, 3 "yes" votes out of 7 total;
// disconnect "2" and "9" for "no" to win
SetVoters(model, "4", "5", "6", "8", "10");
Issue("Unexpected result after voting users disconnected.");
TEST_ExpectTrue(model.GetStatus() == VPM_Failure);
}
protected static function SubTest_RestrictiveReconnectVoting() {
local VotingModel model;
Context("Testing restrictive \"reconnecting\" voting.");
model = MakeVotingModel(VP_Restrictive);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 3 "yes" voters
SetVoters(model, "2", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "7", TEST_EO_Continue);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "9", TEST_EO_Continue);
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "3", VFR_NotAllowed);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteNo(model, "10", TEST_EO_EndDraw);
}
/* Testing restrictive "reconnecting" voting.
Unexpected result after voting users reconnected. [1] */
protected static function Test_CanLeaveVoting() {
SubTest_CanLeaveYesVoting();
SubTest_CanLeaveNoVoting();
SubTest_CanLeaveDrawVoting();
SubTest_CanLeaveFaultyVoting();
SubTest_CanLeaveDisconnectVoting();
SubTest_CanLeaveReconnectVoting();
}
protected static function SubTest_CanLeaveYesVoting() {
local VotingModel model;
Context("Testing \"yes\" voting where users are allowed to leave.");
model = MakeVotingModel(VP_CanLeave);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
SetVoters(model, "1", "5", "6");
Issue("Unexpected result after voting users leaves.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
VoteYes(model, "1", TEST_EO_End);
}
protected static function SubTest_CanLeaveNoVoting() {
local VotingModel model;
Context("Testing \"no\" voting where users are allowed to leave.");
model = MakeVotingModel(VP_CanLeave);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteNo(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
SetVoters(model, "3", "4", "5");
Issue("Unexpected result after voting users leaves.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
VoteNo(model, "4", TEST_EO_End);
}
protected static function SubTest_CanLeaveDrawVoting() {
local VotingModel model;
Context("Testing \"draw\" voting where users are allowed to leave.");
model = MakeVotingModel(VP_CanLeave);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
SetVoters(model, "4");
Issue("Unexpected result after voting users leaves.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
VoteNo(model, "4", TEST_EO_EndDraw);
}
protected static function SubTest_CanLeaveFaultyVoting() {
local VotingModel model;
Context("Testing \"faulty\" voting where users are allowed to leave.");
model = MakeVotingModel(VP_CanLeave);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
MakeFaultyYesVote(model, "3", VFR_AlreadyVoted);
VoteYes(model, "2", TEST_EO_Continue);
MakeFaultyNoVote(model, "7", VFR_NotAllowed);
VoteNo(model, "5", TEST_EO_Continue);
MakeFaultyNoVote(model, "7", VFR_NotAllowed);
MakeFaultyNoVote(model, "5", VFR_AlreadyVoted);
VoteYes(model, "1", TEST_EO_Continue);
MakeFaultyNoVote(model, "3", VFR_CannotChangeVote);
SetVoters(model, "4", "5", "6");
Issue("Unexpected result after voting users leaves.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
VoteYes(model, "6", TEST_EO_End);
MakeFaultyYesVote(model, "4", VFR_VotingEnded);
}
protected static function SubTest_CanLeaveDisconnectVoting() {
local VotingModel model;
Context("Testing \"leave\" voting where users are allowed to leave.");
model = MakeVotingModel(VP_CanLeave);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "7", TEST_EO_Continue);
SetVoters(model, "2", "4", "5", "6", "8", "9", "10");
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "10", TEST_EO_End);
}
protected static function SubTest_CanLeaveReconnectVoting() {
local VotingModel model;
Context("Testing \"reconnecting\" voting where users are allowed to leave.");
model = MakeVotingModel(VP_CanLeave);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 3 "yes" voters
SetVoters(model, "2", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "7", TEST_EO_Continue);
VoteNo(model, "8", TEST_EO_Continue);
VoteNo(model, "9", TEST_EO_Continue);
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "3", VFR_NotAllowed);
VoteYes(model, "10", TEST_EO_EndDraw);
}
protected static function Test_CanChangeVoting() {
SubTest_CanChangeYesVoting();
SubTest_CanChangeNoVoting();
SubTest_CanChangeDrawVoting();
SubTest_CanChangeFaultyVoting();
SubTest_CanChangeDisconnectVoting();
SubTest_CanChangeReconnectVoting();
}
protected static function SubTest_CanChangeYesVoting() {
local VotingModel model;
Context("Testing \"yes\" voting where users are allowed to change their vote.");
model = MakeVotingModel(VP_CanChangeVote);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
}
protected static function SubTest_CanChangeNoVoting() {
local VotingModel model;
Context("Testing \"no\" voting where users are allowed to change their vote.");
model = MakeVotingModel(VP_CanChangeVote);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteNo(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_End);
}
protected static function SubTest_CanChangeDrawVoting() {
local VotingModel model;
Context("Testing \"draw\" voting where users are allowed to change their vote.");
model = MakeVotingModel(VP_CanChangeVote);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "1", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_EndDraw);
}
protected static function SubTest_CanChangeFaultyVoting() {
local VotingModel model;
Context("Testing \"faulty\" voting where users are allowed to change their vote.");
model = MakeVotingModel(VP_CanChangeVote);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
MakeFaultyYesVote(model, "4", VFR_VotingEnded);
}
protected static function SubTest_CanChangeDisconnectVoting() {
local VotingModel model;
Context("Testing \"disconnect\" voting where users are allowed to change their vote.");
model = MakeVotingModel(VP_CanChangeVote);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "7", TEST_EO_Continue);
SetVoters(model, "2", "4", "5", "6", "8", "9", "10"); // remove 1, 3, 7 - 3 "yes" votes
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "9", TEST_EO_Continue);
// Here we're at 3 "no" votes, 3 "yes" votes out of 7 total;
// disconnect "6" and "9" for "yes" to win
SetVoters(model, "2", "4", "5", "8", "10");
Issue("Unexpected result after voting users disconnected.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
}
protected static function SubTest_CanChangeReconnectVoting() {
local VotingModel model;
Context("Testing \"reconnect\" voting where users are allowed to change their vote.");
model = MakeVotingModel(VP_CanChangeVote);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 3 "yes" voters
SetVoters(model, "2", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "7", TEST_EO_Continue);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "9", TEST_EO_Continue);
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "3", VFR_NotAllowed);
// Restore 3 "yes" voter
SetVoters(model, "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteNo(model, "3", TEST_EO_End);
}
protected static function Test_All() {
local VotingModel model;
Context("Testing permissive voting options.");
model = MakeVotingModel(VP_CanLeaveAndChangeVote);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteNo(model, "3", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 and 5 voters
SetVoters(model, "2", "3", "4", "6", "7", "8", "9", "10");
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "5", VFR_NotAllowed);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "7", TEST_EO_Continue);
VoteNo(model, "8", TEST_EO_Continue);
VoteNo(model, "9", TEST_EO_Continue);
// Bring back 1, disconnect 3 and 6
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "8", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
// Disconnect 10, finishing voting (since now only 9 voters are available)
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9");
Issue("Unexpected result after voting users disconnected.");
TEST_ExpectTrue(model.GetStatus() == VPM_Failure);
}
defaultproperties {
caseGroup = "Commands"
caseName = "Voting model"
}

826
sources/BaseAPI/API/Commands/Voting/Voting.uc

File diff suppressed because it is too large Load Diff

233
sources/BaseAPI/API/Commands/Voting/VotingModel.uc

@ -19,50 +19,30 @@
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class VotingModel extends AcediaObject;
class VotingModel extends AcediaObject
dependsOn(MathApi);
//! This class counts votes according to the configured voting policies.
//!
//! Its main purpose is to separate the voting logic from the voting interface, making
//! the implementation simpler and the logic easier to test.
//! Its main purpose is to separate the voting logic from the voting interface,
//! making the implementation simpler and the logic easier to test.
//!
//! # Usage
//!
//! 1. Allocate an instance of the [`VotingModel`] class.
//! 2. Call [`Initialize()`] to set the required policies.
//! 2. Call [`Start()`] to start voting with required policies.
//! 3. Use [`UpdatePotentialVoters()`] to specify which users are allowed to vote.
//! You can change this set at any time. The method used to recount the votes will depend on
//! the policies set during the previous [`Initialize()`] call.
//! You can change this set at any time before the voting has concluded.
//! The method used to recount the votes will depend on the policies set
//! during the previous [`Initialize()`] call.
//! 4. Use [`CastVote()`] to add a vote from a user.
//! 5. After calling either [`UpdatePotentialVoters()`] or [`CastVote()`], check [`GetStatus()`] to
//! see if the voting has concluded.
//! Once voting has concluded, the result cannot be changed, so you can release the reference
//! to the [`VotingModel`] object.
/// Describes how [`VotingModel`] should react when a user performs potentially illegal actions.
///
/// Illegal here means that either corresponding operation won't be permitted or any vote made
/// would be considered invalid.
///
/// Leaving means simply no longer being in a potential pool of voters, which includes actually
/// leaving the game and simply losing rights to vote.
enum VotingPolicies {
/// Anything that can be considered illegal actions is prohibited.
///
/// Leaving (or losing rights to vote) during voting will make a vote invalid.
///
/// Changing vote is forbidden.
VP_Restrictive,
/// Leaving during voting is allowed. Changing a vote is not allowed.
VP_CanLeave,
/// Changing one's vote is allowed. If a user leaves during voting, their vote will be invalid.
VP_CanChangeVote,
/// Leaving during voting and changing a vote is allowed. Leaving means losing rights to vote.
///
/// Currently, this policy allows all available options, but this may change in the future if
/// more options are added.
VP_CanLeaveAndChangeVote
};
//! 5. After calling either [`UpdatePotentialVoters()`] or [`CastVote()`],
//! check [`GetStatus()`] to see if the voting has concluded.
//! Once voting has concluded, the result cannot be changed, so you can
//! release the reference to the [`VotingModel`] object.
//! 6. Alternatively, before voting has concluded naturally, you can use
//! [`ForceEnding()`] method to immediately end voting with result being
//! determined by provided [`ForceEndingType`] argument.
/// Current state of voting for this model.
enum VotingModelStatus {
@ -73,9 +53,7 @@ enum VotingModelStatus {
/// Voting has ended with majority for its success
VPM_Success,
/// Voting has ended with majority for its failure
VPM_Failure,
/// Voting has ended in a draw
VPM_Draw
VPM_Failure
};
/// A result of user trying to make a vote
@ -102,9 +80,20 @@ enum PlayerVoteStatus {
PVS_VoteAgainst
};
/// Types of possible outcomes when forcing a voting to end
enum ForceEndingType {
/// Result will be decided by the votes that already have been cast
FET_CurrentLeader,
/// Voting will end in success
FET_Success,
/// Voting will end in failure
FET_Failure
};
var private VotingModelStatus status;
var private bool policyCanLeave, policyCanChangeVote;
/// Specifies whether draw would count as a victory for corresponding voting.
var private bool policyDrawWinsVoting;
var private array<UserID> votesFor, votesAgainst;
/// Votes of people that voted before, but then were forbidden to vote
@ -115,8 +104,6 @@ var private array<UserID> allowedVoters;
protected function Constructor() {
status = VPM_Uninitialized;
policyCanLeave = false;
policyCanChangeVote = false;
}
protected function Finalizer() {
@ -133,35 +120,42 @@ protected function Finalizer() {
}
/// Initializes voting by providing it with a set of policies to follow.
public final function Initialize(VotingPolicies policies) {
if (status == VPM_Uninitialized) {
policyCanLeave = (policies == VP_CanLeave) || (policies == VP_CanLeaveAndChangeVote);
policyCanChangeVote =
(policies == VP_CanChangeVote) || (policies == VP_CanLeaveAndChangeVote);
///
/// The only available policy is configuring whether draw means victory or loss
/// in voting.
///
/// Can only be called once, after that will do nothing.
public final function Start(bool drawWinsVoting) {
if (status != VPM_Uninitialized) {
return;
}
policyDrawWinsVoting = drawWinsVoting;
status = VPM_InProgress;
}
/// Returns whether voting has already concluded.
///
/// This method should be checked after both [`CastVote()`] and [`UpdatePotentialVoters()`] to check
/// whether either of them was enough to conclude the voting result.
/// This method should be checked after both [`CastVote()`] and
/// [`UpdatePotentialVoters()`] to check whether either of them was enough to
/// conclude the voting result.
public final function bool HasEnded() {
return (status != VPM_Uninitialized && status != VPM_InProgress);
}
/// Returns current status of voting.
///
/// This method should be checked after both [`CastVote()`] and [`UpdatePotentialVoters()`] to check
/// whether either of them was enough to conclude the voting result.
/// This method should be checked after both [`CastVote()`] and
/// [`UpdatePotentialVoters()`] to check whether either of them was enough to
/// conclude the voting result.
public final function VotingModelStatus GetStatus() {
return status;
}
/// Changes set of [`User`]s that are allowed to vote.
///
/// Generally you want to provide this method with a list of current players, optionally filtered
/// from spectators, users not in priviledged group or any other relevant criteria.
/// Generally you want to provide this method with a list of current players,
/// optionally filtered from spectators, users not in priviledged group or any
/// other relevant criteria.
public final function UpdatePotentialVoters(array<UserID> potentialVoters) {
local int i;
@ -178,8 +172,7 @@ public final function UpdatePotentialVoters(array<UserID> potentialVoters) {
/// Attempts to add a vote from specified user.
///
/// Adding a vote can fail if [`voter`] isn't allowed to vote or has already voted and policies
/// forbid changing that vote.
/// Adding a vote can fail if [`voter`] isn't allowed to vote.
public final function VotingResult CastVote(UserID voter, bool voteForSuccess) {
local bool votesSameWay;
local PlayerVoteStatus currentVote;
@ -190,15 +183,12 @@ public final function VotingResult CastVote(UserID voter, bool voteForSuccess) {
if (!IsVotingAllowedFor(voter)) {
return VFR_NotAllowed;
}
currentVote = HasVoted(voter);
currentVote = GetVote(voter);
votesSameWay = (voteForSuccess && currentVote == PVS_VoteFor)
|| (!voteForSuccess && currentVote == PVS_VoteAgainst);
if (votesSameWay) {
return VFR_AlreadyVoted;
}
if (!policyCanChangeVote && currentVote != PVS_NoVote) {
return VFR_CannotChangeVote;
}
EraseVote(voter);
voter.NewRef();
if (voteForSuccess) {
@ -210,12 +200,11 @@ public final function VotingResult CastVote(UserID voter, bool voteForSuccess) {
return VFR_Success;
}
/// Checks if the provided user is allowed to vote based on the current list of potential voters.
/// Checks if the provided user is allowed to vote based on the current list of
/// potential voters.
///
/// The right to vote is decided solely by the list of potential voters set using
/// [`UpdatePotentialVoters()`].
/// However, even if a user is on the list of potential voters, they may not be allowed to vote if
/// they have already cast a vote and the voting policies do not allow vote changes.
/// The right to vote is decided solely by the list of potential voters set
/// using [`UpdatePotentialVoters()`].
///
/// Returns true if the user is allowed to vote, false otherwise.
public final function bool IsVotingAllowedFor(UserID voter) {
@ -234,9 +223,8 @@ public final function bool IsVotingAllowedFor(UserID voter) {
/// Returns the current vote status for the given voter.
///
/// If the voter was previously allowed to vote, voted, and had their right to vote revoked, their
/// vote will only count if policies allow voters to leave mid-vote.
/// Otherwise, the method will return [`PVS_NoVote`].
/// If the voter was previously allowed to vote, voted, and had their right to
/// vote revoked, their vote won't count.
public final function PlayerVoteStatus GetVote(UserID voter) {
local int i;
@ -253,94 +241,99 @@ public final function PlayerVoteStatus GetVote(UserID voter) {
return PVS_VoteAgainst;
}
}
if (policyCanLeave) {
for (i = 0; i < storedVotesFor.length; i += 1) {
if (voter.IsEqual(storedVotesFor[i])) {
return PVS_VoteFor;
}
}
for (i = 0; i < storedVotesAgainst.length; i += 1) {
if (voter.IsEqual(storedVotesAgainst[i])) {
return PVS_VoteAgainst;
}
}
}
return PVS_NoVote;
}
/// Returns amount of current valid votes for the success of this voting.
public final function int GetVotesFor() {
if (policyCanLeave) {
return votesFor.length + storedVotesFor.length;
} else {
return votesFor.length;
}
}
/// Returns amount of current valid votes against the success of this voting.
public final function int GetVotesAgainst() {
if (policyCanLeave) {
return votesAgainst.length + storedVotesAgainst.length;
} else {
return votesAgainst.length;
}
}
/// Returns amount of users that are currently allowed to vote in this voting.
public final function int GetTotalPossibleVotes() {
if (policyCanLeave) {
return allowedVoters.length + storedVotesFor.length + storedVotesAgainst.length;
} else {
return allowedVoters.length;
}
}
// Checks if provided user has already voted.
// Only checks among users that are currently allowed to vote, even if their past vote still counts.
private final function PlayerVoteStatus HasVoted(UserID voter) {
local int i;
/// Checks whether, if stopped now, voting will win.
public final function bool IsVotingWinning() {
if (status == VPM_Success) return true;
if (status == VPM_Failure) return false;
if (GetVotesFor() > GetVotesAgainst()) return true;
if (GetVotesFor() < GetVotesAgainst()) return false;
if (voter == none) {
return PVS_NoVote;
}
for (i = 0; i < votesFor.length; i += 1) {
if (voter.IsEqual(votesFor[i])) {
return PVS_VoteFor;
return policyDrawWinsVoting;
}
/// Forcibly ends the voting, deciding winner depending on the argument.
///
/// Only does anything if voting is currently in progress
/// (in `VPM_InProgress` state).
///
/// By default decides result by the votes that already have been cast.
///
/// Returns `true` only if voting was actually ended with this call.
public final function bool ForceEnding(optional ForceEndingType type) {
if (status != VPM_InProgress) {
return false;
}
for (i = 0; i < votesAgainst.length; i += 1) {
if (voter.IsEqual(votesAgainst[i])) {
return PVS_VoteAgainst;
switch (type) {
case FET_CurrentLeader:
if (IsVotingWinning()) {
status = VPM_Success;
} else {
status = VPM_Failure;
}
break;
case FET_Success:
status = VPM_Success;
break;
case FET_Failure:
default:
status = VPM_Failure;
break;
}
return PVS_NoVote;
return true;
}
private final function RecountVotes() {
local bool canOverturn, everyoneVoted;
local MathApi.IntegerDivisionResult divisionResult;
local int winningScore, losingScore;
local int totalPossibleVotes;
local int totalVotesFor, totalVotesAgainst;
local int lowerVoteCount, upperVoteCount, undecidedVoteCount;
if (status != VPM_InProgress) {
return;
}
totalVotesFor = GetVotesFor();
totalVotesAgainst = GetVotesAgainst();
totalPossibleVotes = GetTotalPossibleVotes();
lowerVoteCount = Min(totalVotesFor, totalVotesAgainst);
upperVoteCount = Max(totalVotesFor, totalVotesAgainst);
undecidedVoteCount = totalPossibleVotes - (lowerVoteCount + upperVoteCount);
everyoneVoted = (undecidedVoteCount <= 0);
canOverturn = lowerVoteCount + undecidedVoteCount >= upperVoteCount;
if (everyoneVoted || !canOverturn) {
if (totalVotesFor > totalVotesAgainst) {
divisionResult = _.math.IntegerDivision(totalPossibleVotes, 2);
if (divisionResult.remainder == 1) {
// For odd amount of voters winning is simply majority
winningScore = divisionResult.quotient + 1;
} else {
if (policyDrawWinsVoting) {
// For even amount of voters, exactly half is enough if draw means victory
winningScore = divisionResult.quotient;
} else {
// Otherwise - majority
winningScore = divisionResult.quotient + 1;
}
}
// The `winningScore` represents the number of votes required for a mean victory.
// If the number of votes against the mean is less than or equal to
// `totalPossibleVotes - winningScore`, then victory is still possible.
// However, if there is even one additional vote against, then victory is no longer achievable
// and a loss is inevitable.
losingScore = (totalPossibleVotes - winningScore) + 1;
// `totalPossibleVotes < losingScore + winningScore`, so only one of these inequalities
// can be satisfied at a time
if (GetVotesFor() >= winningScore) {
status = VPM_Success;
} else if (totalVotesFor < totalVotesAgainst) {
} else if (GetVotesAgainst() >= losingScore) {
status = VPM_Failure;
} else {
status = VPM_Draw;
}
}
}

132
sources/BaseAPI/API/Commands/Voting/VotingPermissions.uc

@ -0,0 +1,132 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2021-2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class VotingPermissions extends AcediaConfig
perobjectconfig
config(AcediaCommands);
/// Determines the duration of the voting period, specified in seconds.
/// Zero or negative values mean unlimited voting period.
var public config float votingTime;
/// Determines whether spectators are allowed to vote.
var public config bool allowSpectatorVoting;
/// Determines how draw will be interpreted.
/// `true` means draw counts as a vote's success, `false` means draw counts as a vote's failure.
var public config bool drawEqualsSuccess;
/// Specifies which group(s) of players are allowed to see who makes what vote.
var public config array<string> allowedToVoteGroup;
/// Specifies which group(s) of players are allowed to see who makes what vote.
var public config array<string> allowedToSeeVotesGroup;
/// Specifies which group(s) of players are allowed to forcibly end voting.
var public config array<string> allowedToForceGroup;
protected function HashTable ToData() {
local int i;
local HashTable data;
local ArrayList arrayOfTexts;
data = __().collections.EmptyHashTable();
data.SetFloat(P("votingTime"), votingTime);
data.SetBool(P("allowSpectatorVoting"), allowSpectatorVoting);
data.SetBool(P("drawEqualsSuccess"), drawEqualsSuccess);
arrayOfTexts = _.collections.EmptyArrayList();
for (i = 0; i < allowedToVoteGroup.length; i += 1) {
arrayOfTexts.AddString(allowedToVoteGroup[i]);
}
data.SetItem(P("allowedToVoteGroup"), arrayOfTexts);
_.memory.Free(arrayOfTexts);
arrayOfTexts = _.collections.EmptyArrayList();
for (i = 0; i < allowedToSeeVotesGroup.length; i += 1) {
arrayOfTexts.AddString(allowedToSeeVotesGroup[i]);
}
data.SetItem(P("allowedToSeeVotesGroup"), arrayOfTexts);
_.memory.Free(arrayOfTexts);
arrayOfTexts = _.collections.EmptyArrayList();
for (i = 0; i < allowedToForceGroup.length; i += 1) {
arrayOfTexts.AddString(allowedToForceGroup[i]);
}
data.SetItem(P("allowedToForceGroup"), arrayOfTexts);
_.memory.Free(arrayOfTexts);
return data;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList arrayOfTexts;
if (source == none) {
return;
}
votingTime = source.GetFloat(P("votingTime"), 30.0);
allowSpectatorVoting = source.GetBool(P("allowSpectatorVoting"), false);
drawEqualsSuccess = source.GetBool(P("drawEqualsSuccess"), false);
allowedToVoteGroup.length = 0;
arrayOfTexts = source.GetArrayList(P("allowedToVoteGroup"));
for (i = 0; i < arrayOfTexts.GetLength(); i += 1) {
allowedToVoteGroup[allowedToVoteGroup.length] = arrayOfTexts.GetString(i);
}
allowedToSeeVotesGroup.length = 0;
arrayOfTexts = source.GetArrayList(P("allowedToSeeVotesGroup"));
for (i = 0; i < arrayOfTexts.GetLength(); i += 1) {
allowedToSeeVotesGroup[allowedToSeeVotesGroup.length] = arrayOfTexts.GetString(i);
}
_.memory.Free(arrayOfTexts);
allowedToForceGroup.length = 0;
arrayOfTexts = source.GetArrayList(P("allowedToForceGroup"));
for (i = 0; i < arrayOfTexts.GetLength(); i += 1) {
allowedToForceGroup[allowedToForceGroup.length] = arrayOfTexts.GetString(i);
}
_.memory.Free(arrayOfTexts);
}
protected function DefaultIt() {
votingTime = 30.0;
drawEqualsSuccess = false;
allowSpectatorVoting = false;
allowedToVoteGroup.length = 0;
allowedToSeeVotesGroup.length = 0;
allowedToForceGroup.length = 0;
allowedToVoteGroup[0] = "all";
allowedToSeeVotesGroup[0] = "all";
allowedToForceGroup[0] = "admin";
allowedToForceGroup[1] = "moderator";
}
defaultproperties {
configName = "AcediaCommands"
supportsDataConversion = true
votingTime = 30.0
drawEqualsSuccess = false
allowSpectatorVoting = false
allowedToVoteGroup(0) = "all"
allowedToSeeVotesGroup(0) = "all"
allowedToForceGroup(0) = "admin"
allowedToForceGroup(1) = "moderator"
}

75
sources/BaseAPI/API/Commands/Voting/VotingSettings.uc

@ -1,75 +0,0 @@
class VotingSettings extends FeatureConfig
perobjectconfig
config(AcediaVoting);
/// Determines the duration of the voting period, specified in seconds.
var public config float votingTime;
/// Determines whether spectators are allowed to vote.
var public config bool allowSpectatorVoting;
/// Specifies which group(s) of players are allowed to see who makes what vote.
var public config array<string> allowedToSeeVotesGroup;
/// Specifies which group(s) of players are allowed to vote.
var public config array<string> allowedToVoteGroup;
protected function HashTable ToData() {
local int i;
local HashTable data;
local ArrayList arrayOfTexts;
data = __().collections.EmptyHashTable();
data.SetFloat(P("votingTime"), votingTime);
data.SetBool(P("allowSpectatorVoting"), allowSpectatorVoting);
arrayOfTexts = _.collections.EmptyArrayList();
for (i = 0; i < allowedToSeeVotesGroup.length; i += 1) {
arrayOfTexts.AddString(allowedToSeeVotesGroup[i]);
}
data.SetItem(P("allowedToSeeVotesGroup"), arrayOfTexts);
_.memory.Free(arrayOfTexts);
arrayOfTexts = _.collections.EmptyArrayList();
for (i = 0; i < allowedToVoteGroup.length; i += 1) {
arrayOfTexts.AddString(allowedToVoteGroup[i]);
}
data.SetItem(P("allowedToVoteGroup"), arrayOfTexts);
_.memory.Free(arrayOfTexts);
return data;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList arrayOfTexts;
if (source == none) {
return;
}
votingTime = source.GetFloat(P("votingTime"), 30.0);
allowSpectatorVoting = source.GetBool(P("allowSpectatorVoting"), false);
allowedToSeeVotesGroup.length = 0;
arrayOfTexts = source.GetArrayList(P("allowedToSeeVotesGroup"));
for (i = 0; i < arrayOfTexts.GetLength(); i += 1) {
allowedToSeeVotesGroup[allowedToSeeVotesGroup.length] = arrayOfTexts.GetString(i);
}
_.memory.Free(arrayOfTexts);
allowedToVoteGroup.length = 0;
arrayOfTexts = source.GetArrayList(P("allowedToVoteGroup"));
for (i = 0; i < arrayOfTexts.GetLength(); i += 1) {
allowedToVoteGroup[allowedToVoteGroup.length] = arrayOfTexts.GetString(i);
}
_.memory.Free(arrayOfTexts);
}
protected function DefaultIt() {
votingTime = 30.0;
allowSpectatorVoting = false;
allowedToSeeVotesGroup.length = 0;
allowedToVoteGroup.length = 0;
allowedToVoteGroup[0] = "everybody";
}
defaultproperties {
configName = "AcediaVoting"
}

4
sources/BaseAPI/API/Unflect/UnflectApi.uc

@ -58,7 +58,7 @@ protected function Finalizer() {
functionCaster = none;
}
public final function bool _drop() {
public final function _drop() {
local UFunction nextFunctionInstance;
local Text nextFunctionName;
local HashTableIterator iter;
@ -67,6 +67,7 @@ public final function bool _drop() {
// Drop is called when Acedia is shutting down, so releasing references isn't necessary
iter = HashTableIterator(completedReplacements.Iterate());
while (!iter.HasFinished()) {
nextFunctionInstance = none;
nextFunctionName = Text(iter.GetKey());
nextSources = ByteArrayBox(originalScriptCodes.GetItem(nextFunctionName));
if (nextSources != none ) {
@ -75,6 +76,7 @@ public final function bool _drop() {
if (nextFunctionInstance != none) {
nextFunctionInstance.script = nextSources.Get();
}
iter.Next();
}
}

5
sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc

@ -19,7 +19,8 @@
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class AcediaEnvironment extends AcediaObject;
class AcediaEnvironment extends AcediaObject
config(AcediaSystem);
//! API for management of running `Feature`s and loaded packages.
//!
@ -401,7 +402,7 @@ public final function DisableAllFeatures() {
defaultproperties
{
manifestSuffix = ".Manifest"
debugMode = true
debugMode = false
infoRegisteringPackage = (l=LOG_Info,m="Registering package \"%1\".")
infoAlreadyRegistered = (l=LOG_Info,m="Package \"%1\" is already registered.")
errNotRegistered = (l=LOG_Error,m="Package \"%1\" has failed to be registered.")

2
sources/Chat/ChatAPI.uc

@ -210,7 +210,7 @@ public /*signal*/ function ChatAPI_OnMessageFor_Slot OnMessageFor(AcediaObject r
///
/// # Slot description
///
/// bool <slot>(EPlayer sender, ChatApi.BuiltInVoiceMessage message)
/// void <slot>(EPlayer sender, ChatApi.BuiltInVoiceMessage message)
///
/// ## Parameters
///

25
sources/Data/Database/DBAPI.uc

@ -250,48 +250,49 @@ public final function LocalDatabaseInstance LoadLocal(BaseText databaseName)
local DBRecord rootRecord;
local Text rootRecordName;
local LocalDatabase newConfig;
local LocalDatabaseInstance newLocalDBInstance;
local LocalDatabaseInstance newLocalDBInstance, result;
local Text dbKey;
if (databaseName == none) {
return none;
}
CreateLocalDBMapIfMissing();
if (loadedLocalDatabases.HasKey(databaseName))
dbKey = databaseName.Copy();
if (loadedLocalDatabases.HasKey(dbKey))
{
return LocalDatabaseInstance(loadedLocalDatabases
.GetItem(databaseName));
result = LocalDatabaseInstance(loadedLocalDatabases.GetItem(dbKey));
_.memory.Free(dbKey);
return result;
}
// No need to check `databaseName` for being valid,
// No need to check `dbKey` for being valid,
// since `Load()` will just return `none` if it is not.
newConfig = class'LocalDatabase'.static.Load(databaseName);
newConfig = class'LocalDatabase'.static.Load(dbKey);
if (newConfig == none) {
_.memory.Free(dbKey);
return none;
}
if (!newConfig.HasDefinedRoot() && !newConfig.ShouldCreateIfMissing()) {
_.memory.Free(dbKey);
return none;
}
newLocalDBInstance = LocalDatabaseInstance(_.memory.Allocate(localDBClass));
dbKey = databaseName.Copy();
loadedLocalDatabases.SetItem(dbKey, newLocalDBInstance);
dbKey.FreeSelf();
if (newConfig.HasDefinedRoot())
{
rootRecordName = newConfig.GetRootName();
rootRecord = class'DBRecord'.static
.LoadRecord(rootRecordName, databaseName);
rootRecord = class'DBRecord'.static.LoadRecord(rootRecordName, dbKey);
}
else
{
rootRecord = class'DBRecord'.static.NewRecord(databaseName);
rootRecord = class'DBRecord'.static.NewRecord(dbKey);
rootRecordName = _.text.FromString(string(rootRecord.name));
newConfig.SetRootName(rootRecordName);
newConfig.Save();
}
newLocalDBInstance.Initialize(newConfig, rootRecord);
_.logger.Auto(infoLocalDatabaseLoaded).Arg(databaseName.Copy());
_.logger.Auto(infoLocalDatabaseLoaded).Arg(dbKey);
_.memory.Free(rootRecordName);
_.memory.Free(newLocalDBInstance);
return newLocalDBInstance;
}

1
sources/Features/FeatureConfig.uc

@ -136,5 +136,6 @@ defaultproperties
{
usesObjectPool = false
autoEnable = false
supportsDataConversion = true
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.")
}

33
sources/InfoQueryHandler/InfoQueryHandler.uc

@ -34,7 +34,7 @@ var private const int TACEDIA_HELP_COMMANDS_CHAT, TACEDIA_HELP_COMMANDS_CONSOLE;
var private const int TACEDIA_HELP_COMMANDS_CHAT_AND_CONSOLE;
var private const int TACEDIA_HELP_COMMANDS_NO, TACEDIA_HELP_COMMANDS_USELESS;
var private const int TACEDIA_RUNNING, TACEDIA_VERSION, TACEDIA_CREDITS;
var private const int TACEDIA_ACKNOWLEDGMENT, TPREFIX, TSEPARATOR;
var private const int TACEDIA_ACKNOWLEDGMENT, TPREFIX, THELP, TSEPARATOR;
public static function StaticConstructor()
{
@ -228,7 +228,7 @@ private final static function StopOutput()
private final static function OutAcediaHelp()
{
local MutableText prefix, builder;
local MutableText prefix, helpName, builder;
default.currentOutput
.Flush()
@ -242,12 +242,17 @@ private final static function OutAcediaHelp()
.GetChatPrefix()
.IntoMutableText()
.ChangeDefaultColor(__().color.TextEmphasis);
helpName = class'Commands_Feature'.static
.GetHelpCommandName()
.IntoMutableText()
.ChangeDefaultColor(__().color.TextEmphasis);
if ( class'Commands_Feature'.static.IsUsingChatInput()
&& class'Commands_Feature'.static.IsUsingMutateInput())
{
builder =
T(default.TACEDIA_HELP_COMMANDS_CHAT_AND_CONSOLE).MutableCopy();
builder.Replace(T(default.TPREFIX), prefix);
builder.Replace(T(default.THELP), helpName);
default.currentOutput.WriteLine(builder);
__().memory.Free(builder);
}
@ -256,20 +261,24 @@ private final static function OutAcediaHelp()
builder =
T(default.TACEDIA_HELP_COMMANDS_CHAT).MutableCopy();
builder.Replace(T(default.TPREFIX), prefix);
builder.Replace(T(default.THELP), helpName);
default.currentOutput.WriteLine(builder);
__().memory.Free(builder);
}
else if (class'Commands_Feature'.static.IsUsingMutateInput())
{
default.currentOutput
.WriteLine(T(default.TACEDIA_HELP_COMMANDS_CONSOLE));
builder =
T(default.TACEDIA_HELP_COMMANDS_CONSOLE).MutableCopy();
builder.Replace(T(default.THELP), helpName);
default.currentOutput.WriteLine(builder);
__().memory.Free(builder);
}
else
{
default.currentOutput
.WriteLine(T(default.TACEDIA_HELP_COMMANDS_USELESS));
}
__().memory.Free(prefix);
__().memory.Free2(prefix, helpName);
}
private final static function OutAcediaStatus()
@ -297,11 +306,11 @@ defaultproperties
TACEDIA_HELP = 2
stringConstants(2) = "Acedia always supports four commands: {$TextEmphasis help}, {$TextEmphasis status}, {$TextEmphasis version} and {$TextEmphasis credits}"
TACEDIA_HELP_COMMANDS_CHAT = 3
stringConstants(3) = "To get detailed information about available to you commands, please type {$TextEmphasis %PREFIX%help} in chat"
stringConstants(3) = "To get detailed information about available to you commands, please type {$TextEmphasis %PREFIX%%HELP%} in chat"
TACEDIA_HELP_COMMANDS_CONSOLE = 4
stringConstants(4) = "To get detailed information about available to you commands, please type {$TextEmphasis mutate help -l} in console"
stringConstants(4) = "To get detailed information about available to you commands, please type {$TextEmphasis mutate %HELP% -l} in console"
TACEDIA_HELP_COMMANDS_CHAT_AND_CONSOLE = 5
stringConstants(5) = "To get detailed information about available to you commands, please type {$TextEmphasis %PREFIX%help} in chat or {$TextEmphasis mutate help -l} in console"
stringConstants(5) = "To get detailed information about available to you commands, please type {$TextEmphasis %PREFIX%%HELP%} in chat or {$TextEmphasis mutate %HELP% -l} in console"
TACEDIA_HELP_COMMANDS_NO = 6
stringConstants(6) = "Unfortunately other commands aren't available right now. To enable them please type {$TextEmphasis mutate acediacommands} in console if you have enough rights to reenable them."
TACEDIA_HELP_COMMANDS_USELESS = 7
@ -311,11 +320,13 @@ defaultproperties
TACEDIA_VERSION = 9
stringConstants(9) = "AcediaCore version 0.1.dev8 - this is a development version, bugs and issues are expected"
TACEDIA_CREDITS = 10
stringConstants(10) = "AcediaCore was developed by dkanus, 2019 - 2022"
stringConstants(10) = "AcediaCore was developed by dkanus, 2019 - 2023"
TACEDIA_ACKNOWLEDGMENT = 11
stringConstants(11) = "Special thanks for NikC- and Chaos for suggestions, testing and discussion"
TPREFIX = 12
stringConstants(12) = "%PREFIX%"
TSEPARATOR = 13
stringConstants(13) = "============================="
THELP = 13
stringConstants(13) = "%HELP%"
TSEPARATOR = 14
stringConstants(14) = "============================="
}

13
sources/Players/EPlayer.uc

@ -422,6 +422,19 @@ public final function bool IsAdmin()
return (GetAdminStatus() != AS_None);
}
/// Checks if player is a spectator, i.e. observes game without actively
/// participating.
public final function bool IsSpectator()
{
local PlayerReplicationInfo myReplicationInfo;
myReplicationInfo = GetRI();
if (myReplicationInfo == none) {
return true;
}
return myReplicationInfo.bOnlySpectator;
}
/**
* Changes admin status of the caller `EPlayer`.
* Can only fail if caller `EPlayer` has already disconnected.

87
sources/Text/TextAPI.uc

@ -317,10 +317,39 @@ public final function bool IsEmpty(BaseText text)
public final function string IntoString(/*take*/ BaseText toConvert)
{
local string result;
if (toConvert != none) {
result = toConvert.ToString();
}
_.memory.Free(toConvert);
}
return result;
}
/**
* Converts given array of `BaseText`s into an array of plain `string`s,
* returning their values and deallocating passed `BaseText`.
*
* Method introduced to simplify a common use-case of converting returned
* copies of `BaseText`s into a `string`s, which required additional variable
* to store and later deallocate `BaseText` references.
*
* @param toConvert Array of `BaseText`s to convert.
* @return Array of `string`s, representing passed `BaseText`s as
* a plain string.
* Empty `string`, if `toConvert == none`.
*/
public final function array<string> IntoStrings(/*take*/ array<BaseText> toConvert) {
local int i;
local array<string> result;
for (i = 0; i < toConvert.length; i += 1) {
if (toConvert[i] != none) {
result[result.length] = toConvert[i].ToString();
_.memory.Free(toConvert[i]);
} else {
result[result.length] = "";
}
}
return result;
}
@ -346,6 +375,34 @@ public final function string IntoColoredString(/*take*/ BaseText toConvert)
return result;
}
/**
* Converts given array of `BaseText`s into an array of colored `string`s,
* returning their values and deallocating passed `BaseText`.
*
* Method introduced to simplify a common use-case of converting returned
* copies of `BaseText`s into a `string`s, which required additional variable
* to store and later deallocate `BaseText` references.
*
* @param toConvert Array of `BaseText`s to convert.
* @return Array of `string`s, representing passed `BaseText`s as
* a colored string.
* Empty `string`, if `toConvert == none`.
*/
public final function array<string> IntoColoredStrings(/*take*/ array<BaseText> toConvert) {
local int i;
local array<string> result;
for (i = 0; i < toConvert.length; i += 1) {
if (toConvert[i] != none) {
result[result.length] = toConvert[i].ToColoredString();
_.memory.Free(toConvert[i]);
} else {
result[result.length] = "";
}
}
return result;
}
/**
* Converts given `BaseText` into a formatted `string`, returns it's value and
* deallocates passed `BaseText`.
@ -368,6 +425,34 @@ public final function string IntoFormattedString(/*take*/ BaseText toConvert)
return result;
}
/**
* Converts given array of `BaseText`s into an array of formatted `string`s,
* returning their values and deallocating passed `BaseText`.
*
* Method introduced to simplify a common use-case of converting returned
* copies of `BaseText`s into a `string`s, which required additional variable
* to store and later deallocate `BaseText` references.
*
* @param toConvert Array of `BaseText`s to convert.
* @return Array of `string`s, representing passed `BaseText`s as
* a formatted string.
* Empty `string`, if `toConvert == none`.
*/
public final function array<string> IntoFormattedStrings(/*take*/ array<BaseText> toConvert) {
local int i;
local array<string> result;
for (i = 0; i < toConvert.length; i += 1) {
if (toConvert[i] != none) {
result[result.length] = toConvert[i].ToFormattedString();
_.memory.Free(toConvert[i]);
} else {
result[result.length] = "";
}
}
return result;
}
/**
* Creates a `string` that consists only of a given character.
*

7
sources/Users/ACommandUserGroups.uc

@ -22,7 +22,6 @@ class ACommandUserGroups extends Command
protected function BuildData(CommandDataBuilder builder)
{
builder.Name(P("usergroups"));
builder.Group(P("admin"));
builder.Summary(P("User groups management."));
builder.Describe(P("Allows to add/remove user groups and users to these: groups. Changes made"
@ -77,7 +76,7 @@ protected function BuildData(CommandDataBuilder builder)
builder.Describe(P("Allows to force usage of invalid user IDs."));
}
protected function Executed(CallData arguments, EPlayer instigator)
protected function Executed(CallData arguments, EPlayer instigator, CommandPermissions permissions)
{
local bool forceOption;
local Text groupName, userID, userName, annotation;
@ -653,6 +652,6 @@ private function DisplayUsersFor(Text groupName)
}
}
defaultproperties
{
defaultproperties {
preferredName = "usergroups"
}

35
sources/Users/Users_Feature.uc

@ -70,16 +70,7 @@ var private LoggerAPI.Definition errDBContainsNonLowerRegister;
protected function OnEnabled()
{
local Commands_Feature feature;
_.users._reloadFeature();
feature =
Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
if (feature != none)
{
feature.RegisterCommand(class'ACommandUserGroups');
feature.FreeSelf();
}
if (_server.IsAvailable()) {
LoadUserData();
SetupPersistentData(usePersistentData);
@ -96,9 +87,7 @@ protected function OnDisabled()
_.users._reloadFeature();
feature =
Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
if (feature != none)
{
feature.RemoveCommand(class'ACommandUserGroups');
if (feature != none) {
feature.FreeSelf();
}
ResetUploadedUserGroups();
@ -590,9 +579,9 @@ public final function bool AddGroup(BaseText groupName)
local Text lowerCaseGroupName;
local HashTable emptyHashTable;
if (groupName == none) {
return false;
}
if (groupName == none) return false;
if (groupName.Compare(P("all"), SCASE_INSENSITIVE)) return false;
lowerCaseGroupName = groupName.LowerCopy();
if (loadedGroupToUsersMap.HasKey(lowerCaseGroupName))
{
@ -676,9 +665,9 @@ public final function bool RemoveGroup(BaseText groupName)
local bool groupExists;
local Text lowerCaseGroupName;
if (groupName == none) {
return false;
}
if (groupName == none) return false;
if (groupName.Compare(P("all"), SCASE_INSENSITIVE)) return false;
lowerCaseGroupName = groupName.LowerCopy();
groupExists = loadedGroupToUsersMap.HasKey(lowerCaseGroupName);
if (!groupExists)
@ -754,9 +743,9 @@ public final function bool IsGroupExisting(BaseText groupName)
local bool result;
local Text lowerCaseGroupName;
if (groupName == none) {
return false;
}
if (groupName == none) return false;
if (groupName.Compare(P("all"), SCASE_INSENSITIVE)) return true;
lowerCaseGroupName = groupName.LowerCopy();
result = loadedGroupToUsersMap.HasKey(lowerCaseGroupName);
lowerCaseGroupName.FreeSelf();
@ -811,6 +800,7 @@ public final function bool AddSteamIDToGroup(
if (steamID == none) return false;
if (loadedGroupToUsersMap == none) return false;
if (groupName == none) return false;
if (groupName.Compare(P("all"), SCASE_INSENSITIVE)) return true;
lowercaseGroupName = groupName.LowerCopy();
groupUsers = loadedGroupToUsersMap.GetHashTable(lowercaseGroupName);
@ -1000,6 +990,7 @@ public final function bool RemoveSteamIDFromGroup(
if (steamID == none) return false;
if (groupName == none) return false;
if (groupName.Compare(P("all"), SCASE_INSENSITIVE)) return false;
if (loadedGroupToUsersMap == none) return false;
lowercaseGroupName = groupName.LowerCopy();
@ -1195,6 +1186,7 @@ public final function array<Text> GetGroupsForSteamID(BaseText steamID)
if (loadedGroupToUsersMap == none) return result;
if (steamID == none) return result;
result[0] = P("all").Copy();
immutableSteamID = steamID.LowerCopy();
iter = HashTableIterator(loadedGroupToUsersMap.Iterate());
while (!iter.HasFinished())
@ -1962,6 +1954,7 @@ public final function bool IsSteamIDInGroup(
if (loadedGroupToUsersMap == none) return false;
if (groupName == none) return false;
if (groupName.Compare(P("all"), SCASE_INSENSITIVE)) return true;
if (steamID == none) return false;
lowerGroupName = groupName.LowerCopy();

Loading…
Cancel
Save