diff --git a/config/AcediaAliases_Colors.ini b/config/AcediaAliases_Colors.ini
index 913130d..ed48d09 100644
--- a/config/AcediaAliases_Colors.ini
+++ b/config/AcediaAliases_Colors.ini
@@ -8,7 +8,7 @@ record=(alias="TextSubHeader",value="rgb(147,112,219)")
record=(alias="TextPositive",value="rgb(60,220,20)")
record=(alias="TextNeutral",value="rgb(255,255,0)")
record=(alias="TextNegative",value="rgb(220,20,60)")
-record=(alias="TextSubtle",value="rgb(128,128,128)")
+record=(alias="TextSubtle",value="rgb(211,211,211)")
record=(alias="TextEmphasis",value="rgb(0,128,255)")
record=(alias="TextOk",value="rgb(0,255,0)")
record=(alias="TextWarning",value="rgb(255,128,0)")
diff --git a/config/AcediaAliases_Commands.ini b/config/AcediaAliases_Commands.ini
index e69de29..012eb51 100644
--- a/config/AcediaAliases_Commands.ini
+++ b/config/AcediaAliases_Commands.ini
@@ -0,0 +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")
+
+[help CommandAliases]
+Alias="hlp"
\ No newline at end of file
diff --git a/config/AcediaSystem.ini b/config/AcediaSystem.ini
index 5bfd2d6..a42cf4e 100644
--- a/config/AcediaSystem.ini
+++ b/config/AcediaSystem.ini
@@ -1,5 +1,8 @@
; Every single option in this config should be considered [ADVANCED].
; DO NOT CHANGE THEM unless you are sure you know what you're doing.
+[AcediaCore.AcediaEnvironment]
+debugMode=false
+
[AcediaCore.SideEffects]
; Acedia requires adding its own `GameRules` to listen to many different
; game events.
@@ -150,7 +153,7 @@ maximumNotifyTime=20
TextDefault=(R=255,G=255,B=255,A=255)
TextHeader=(R=128,G=0,B=128,A=255)
TextSubHeader=(R=147,G=112,B=219,A=255)
-TextSubtle=(R=128,G=128,B=128,A=255)
+TextSubtle=(R=211,G=211,B=211,A=255)
TextEmphasis=(R=0,G=128,B=255,A=255)
TextPositive=(R=0,G=128,B=0,A=255)
TextNeutral=(R=255,G=255,B=0,A=255)
diff --git a/config/AcediaVoting.ini b/config/AcediaVoting.ini
new file mode 100644
index 0000000..60731ba
--- /dev/null
+++ b/config/AcediaVoting.ini
@@ -0,0 +1,22 @@
+[default VotingSettings]
+;= Determines the duration of the voting period, specified in seconds.
+votingTime=30
+;= Determines whether spectators are allowed to vote.
+allowSpectatorVoting=false
+;= Specifies which group(s) of players are allowed to see who makes what vote.
+allowedToSeeVotesGroup="admin"
+allowedToSeeVotesGroup="moderator"
+;= Specifies which group(s) of players are allowed to vote.
+allowedToVoteGroup="everybody"
+
+[moderator VotingSettings]
+votingTime=30
+allowSpectatorVoting=true
+allowedToSeeVotesGroup="admin"
+allowedToVoteGroup="moderator"
+allowedToVoteGroup="admin"
+
+[admin VotingSettings]
+votingTime=30
+allowSpectatorVoting=true
+allowedToVoteGroup="admin"
\ No newline at end of file
diff --git a/sources/Aliases/Aliases_Feature.uc b/sources/Aliases/Aliases_Feature.uc
index 002f628..887d0ed 100644
--- a/sources/Aliases/Aliases_Feature.uc
+++ b/sources/Aliases/Aliases_Feature.uc
@@ -112,15 +112,12 @@ protected function SwapConfig(FeatureConfig config)
if (newConfig == none) {
return;
}
- _.memory.Free(weaponAliasSource);
- DropSources();
weaponAliasSource = GetSource(newConfig.weaponAliasSource);
colorAliasSource = GetSource(newConfig.colorAliasSource);
featureAliasSource = GetSource(newConfig.featureAliasSource);
entityAliasSource = GetSource(newConfig.entityAliasSource);
commandAliasSource = GetSource(newConfig.commandAliasSource);
LoadCustomSources(newConfig.customSource);
- _.alias._reloadSources();
}
private function LoadCustomSources(
diff --git a/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc
new file mode 100644
index 0000000..9e146bd
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc
@@ -0,0 +1,166 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class ACommandFakers extends Command
+ dependsOn(VotingModel);
+
+var private array fakers;
+
+protected 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."));
+
+ builder.SubCommand(P("amount"));
+ builder.Describe(P("Specify amount of faker that are allowed to vote."));
+ builder.ParamInteger(P("fakers_amount"));
+
+ builder.SubCommand(P("vote"));
+ builder.Describe(P("Make a vote as a faker."));
+ builder.ParamInteger(P("faker_number"));
+ builder.ParamBoolean(P("vote_for"));
+}
+
+protected function Executed(CallData arguments, EPlayer instigator) {
+ if (arguments.subCommandName.IsEmpty()) {
+ DisplayCurrentFakers();
+ } else if (arguments.subCommandName.Compare(P("amount"), SCASE_INSENSITIVE)) {
+ ChangeAmount(arguments.parameters.GetInt(P("fakers_amount")));
+ } else if (arguments.subCommandName.Compare(P("vote"), SCASE_INSENSITIVE)) {
+ CastVote(
+ arguments.parameters.GetInt(P("faker_number")),
+ arguments.parameters.GetBool(P("vote_for")));
+ }
+}
+
+public final function UpdateFakersForVoting() {
+ local Voting currentVoting;
+
+ currentVoting = GetCurrentVoting();
+ if (currentVoting != none) {
+ currentVoting.SetDebugVoters(fakers);
+ }
+ _.memory.Free(currentVoting);
+}
+
+private final function CastVote(int fakerID, bool voteFor) {
+ local Voting currentVoting;
+
+ if (fakerID < 0 || fakerID >= fakers.length) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("Faker number is out of bounds."));
+ return;
+ }
+ currentVoting = GetCurrentVoting();
+ if (currentVoting == none) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("There is no voting active right now."));
+ return;
+ }
+ currentVoting.CastVoteByID(fakers[fakerID], voteFor);
+ _.memory.Free(currentVoting);
+}
+
+private final function ChangeAmount(int newAmount) {
+ local int i;
+ local Text nextIDName;
+ local UserID nextID;
+
+ if (newAmount < 0) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("Cannot specify negative amount."));
+ }
+ if (newAmount == fakers.length) {
+ callerConsole
+ .UseColor(_.color.TextNeutral)
+ .WriteLine(P("Specified same amount of fakers."));
+ } else if (newAmount > fakers.length) {
+ for (i = fakers.length; i < newAmount; i += 1) {
+ nextIDName = _.text.FromString("DEBUG:FAKER:" $ i);
+ nextID = UserID(__().memory.Allocate(class'UserID'));
+ nextID.Initialize(nextIDName);
+ _.memory.Free(nextIDName);
+ fakers[fakers.length] = nextID;
+ }
+ } else {
+ for (i = fakers.length - 1; i >= newAmount; i -= 1) {
+ _.memory.Free(fakers[i]);
+ }
+ fakers.length = newAmount;
+ }
+ 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();
+ }
+ return result;
+}
+
+private function DisplayCurrentFakers() {
+ local int i;
+ local VotingModel.PlayerVoteStatus nextVoteStatus;
+ local MutableText nextNumber;
+ local Voting currentVoting;
+
+ if (fakers.length <= 0) {
+ callerConsole.WriteLine(P("No fakers!"));
+ return;
+ }
+ currentVoting = GetCurrentVoting();
+ for (i = 0; i < fakers.length; i += 1) {
+ nextNumber = _.text.FromIntM(i);
+ callerConsole
+ .Write(P("Faker #"))
+ .Write(nextNumber)
+ .Write(P(": "));
+ if (currentVoting != none) {
+ nextVoteStatus = currentVoting.GetVote(fakers[i]);
+ }
+ switch (nextVoteStatus) {
+ case PVS_NoVote:
+ callerConsole.WriteLine(P("no vote"));
+ break;
+ case PVS_VoteFor:
+ callerConsole.UseColorOnce(_.color.TextPositive).WriteLine(P("vote for"));
+ break;
+ case PVS_VoteAgainst:
+ callerConsole.UseColorOnce(_.color.TextNegative).WriteLine(P("vote against"));
+ break;
+ default:
+ callerConsole.UseColorOnce(_.color.TextFailure).WriteLine(P("vote !ERROR!"));
+ }
+ _.memory.Free(nextNumber);
+ }
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandHelp.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc
similarity index 96%
rename from sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandHelp.uc
rename to sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc
index a4ec0f9..0540cc7 100644
--- a/sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandHelp.uc
+++ b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc
@@ -102,21 +102,22 @@ protected function Finalizer()
protected function BuildData(CommandDataBuilder builder)
{
- builder.Name(P("help")).Group(P("core"))
- .Summary(P("Displays detailed information about available commands."));
- builder.OptionalParams()
- .ParamTextList(P("commands"))
- .Describe(P("Displays information about all specified commands."));
- builder.Option(P("aliases"))
- .Describe(P("When displaying available commands, specifying this flag"
- @ "will additionally make command to display all of their available"
- @ "aliases."))
- .Option(P("list"))
- .Describe(P("Display available commands. Optionally command groups can"
- @ "be specified and then only commands from such groups will be"
- @ "listed. Otherwise all commands will be displayed."))
- .OptionalParams()
- .ParamTextList(P("groups"));
+ builder.Name(P("help"));
+ builder.Group(P("core"));
+ builder.Summary(P("Displays detailed information about available commands."));
+ builder.OptionalParams();
+ builder.ParamTextList(P("commands"));
+
+ builder.Option(P("aliases"));
+ builder.Describe(P("When displaying available commands, specifying this flag will additionally"
+ @ "make command to display all of their available aliases."));
+
+ builder.Option(P("list"));
+ builder.Describe(P("Display available commands. Optionally command groups can be specified and"
+ @ "then only commands from such groups will be listed. Otherwise all commands will"
+ @ "be displayed."));
+ builder.OptionalParams();
+ builder.ParamTextList(P("groups"));
}
protected function Executed(Command.CallData callData, EPlayer callerPlayer)
diff --git a/sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandNotify.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
similarity index 100%
rename from sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandNotify.uc
rename to sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
diff --git a/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc
new file mode 100644
index 0000000..d11f7e5
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc
@@ -0,0 +1,159 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class ACommandVote extends Command;
+
+var private CommandDataBuilder dataBuilder;
+
+protected function Constructor() {
+ ResetVotingInfo();
+}
+
+protected function Finalizer() {
+ super.Finalizer();
+ _.memory.Free(dataBuilder);
+ dataBuilder = none;
+}
+
+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."));
+ 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."));
+}
+
+protected function Executed(CallData arguments, EPlayer instigator) {
+ 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();
+ }
+ if (arguments.subCommandName.IsEmpty()) {
+ DisplayInfoAboutVoting(instigator, currentVoting);
+ } else if (arguments.subCommandName.Compare(P("yes"), SCASE_INSENSITIVE)) {
+ CastVote(currentVoting, instigator, true);
+ } else if (arguments.subCommandName.Compare(P("no"), SCASE_INSENSITIVE)) {
+ CastVote(currentVoting, instigator, false);
+ } else {
+ StartVoting(arguments.subCommandName, feature, currentVoting, instigator);
+ }
+ _.memory.Free(feature);
+ _.memory.Free(currentVoting);
+}
+
+/// Adds sub-command information about given voting with a given name.
+public final function AddVotingInfo(BaseText processName, class processClass) {
+ if (processName == none) return;
+ if (processClass == none) return;
+ if (dataBuilder == none) return;
+
+ dataBuilder.SubCommand(processName);
+ processClass.static.AddInfo(dataBuilder);
+ commandData = dataBuilder.BorrowData();
+}
+
+/// Clears all sub-command information added from [`Voting`]s.
+public final function ResetVotingInfo() {
+ _.memory.Free(dataBuilder);
+ dataBuilder = CommandDataBuilder(_.memory.Allocate(class'CommandDataBuilder'));
+ BuildData(dataBuilder);
+ commandData = dataBuilder.BorrowData();
+}
+
+private final function DisplayInfoAboutVoting(EPlayer instigator, Voting currentVoting) {
+ if (currentVoting == none) {
+ callerConsole.WriteLine(P("No voting is active right now."));
+ } else {
+ currentVoting.PrintVotingInfoFor(instigator);
+ }
+}
+
+private final function CastVote(Voting currentVoting, EPlayer voter, bool voteForSuccess) {
+ if (currentVoting != none) {
+ currentVoting.CastVote(voter, voteForSuccess);
+ } else {
+ callerConsole.UseColor(_.color.TextWarning).WriteLine(P("No voting is active right now."));
+ }
+}
+
+// Assumes all arguments aren't `none`.
+private final function StartVoting(
+ BaseText votingName,
+ Commands_Feature feature,
+ Voting currentVoting,
+ EPlayer instigator
+) {
+ local Command fakersCommand;
+ local Voting newVoting;
+ local Commands_Feature.StartVotingResult result;
+
+ result = feature.StartVoting(votingName);
+ // Handle errors
+ if (result == SVR_UnknownVoting) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .Write(P("Unknown voting option \""))
+ .Write(votingName)
+ .WriteLine(P("\""));
+ return;
+ } else 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();
+ }
+ _.memory.Free(fakersCommand);
+ }
+ // Cast a vote from instigator
+ newVoting = feature.GetCurrentVoting();
+ if (newVoting != none) {
+ newVoting.CastVote(instigator, true);
+ } else {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("Voting should be available, but it isn't."
+ @ "This is unexpected, something broke terribly."));
+ }
+ _.memory.Free(newVoting);
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Command.uc b/sources/BaseAPI/API/Commands/Command.uc
new file mode 100644
index 0000000..c46c831
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Command.uc
@@ -0,0 +1,585 @@
+/**
+ * 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 .
+ */
+class Command extends AcediaObject
+ dependson(BaseText);
+
+//! This class is meant to represent a command type.
+//!
+//! Command class provides an automated way to add a command to a server through
+//! AcediaCore's features. It takes care of:
+//!
+//! 1. Verifying that player has passed correct (expected parameters);
+//! 2. Parsing these parameters into usable values (both standard, built-in
+//! types like `bool`, `int`, `float`, etc. and more advanced types such
+//! as players lists and JSON values);
+//! 3. Allowing you to easily specify a set of players you are targeting by
+//! supporting several ways to refer to them, such as *by name*, *by id*
+//! and *by selector* (@ and @self refer to caller player, @all refers
+//! to all players).
+//! 4. It can be registered inside AcediaCore's commands feature and be
+//! automatically called through the unified system that supports *chat*
+//! and *mutate* inputs (as well as allowing you to hook in any other
+//! input source);
+//! 5. Will also automatically provide a help page through built-in "help"
+//! command;
+//! 6. Subcommand support - when one command can have several distinct
+//! functions, depending on how its called (e.g. "inventory add" vs
+//! "inventory remove"). These subcommands have a special treatment in
+//! help pages, which makes them more preferable, compared to simply
+//! matching first `Text` argument;
+//! 7. Add support for "options" - additional flags that can modify commands
+//! behavior and behave like usual command options "--force"/"-f".
+//! Their short versions can even be combined:
+//! "give@ $ebr --ammo --force" can be rewritten as "give@ $ebr -af".
+//! And they can have their own parameters: "give@all --list sharp".
+//!
+//! # 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).
+//!
+//! 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`).
+
+/// Possible errors that can arise when parsing command parameters from user input
+enum ErrorType {
+ // No error
+ CET_None,
+ // 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)
+ CET_NoSubCommands,
+ // 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
+ CET_NoRequiredParam,
+ CET_NoRequiredParamForOption,
+ // Unknown option key was specified
+ CET_UnknownOption,
+ CET_UnknownShortOption,
+ // 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
+ CET_UnusedCommandParameters,
+ // 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)
+ CET_IncorrectTargetList,
+ CET_EmptyTargetList
+};
+
+/// Structure that contains all the information about how `Command` was called.
+struct CallData {
+ // Targeted players (if applicable)
+ var public array targetPlayers;
+ // Specified sub-command and parameters/options
+ var public Text subCommandName;
+ // 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.
+ var public ErrorType parsingError;
+ var public Text errorCause;
+};
+
+/// Possible types of parameters.
+enum ParameterType {
+ // Parses into `BoolBox`
+ CPT_Boolean,
+ // Parses into `IntBox`
+ CPT_Integer,
+ // Parses into `FloatBox`
+ CPT_Number,
+ // Parses into `Text`
+ CPT_Text,
+ // Special parameter that consumes the rest of the input into `Text`
+ CPT_Remainder,
+ // Parses into `HashTable`
+ CPT_Object,
+ // Parses into `ArrayList`
+ CPT_Array,
+ // Parses into any JSON value
+ CPT_JSON,
+ // 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.
+enum PreferredBooleanFormat {
+ PBF_TrueFalse,
+ PBF_EnableDisable,
+ PBF_OnOff,
+ PBF_YesNo
+};
+
+// Defines a singular command parameter
+struct Parameter {
+ // Display name (for the needs of help page displaying)
+ var Text displayName;
+ // 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
+ var bool allowsList;
+ // 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
+ 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.
+ var Text aliasSourceName;
+};
+
+// Defines a sub-command of a this command (specified as " ").
+//
+// 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`
+ var Text name;
+ // Can be `none`
+ var Text description;
+ var array required;
+ var array optional;
+};
+
+// Defines command's option (options are specified by "--long" or "-l").
+// Options are independent from sub-commands.
+struct Option {
+ var BaseText.Character shortName;
+ var Text longName;
+ var Text description;
+ // Option can also have their own parameters
+ var array required;
+ var array optional;
+};
+
+// 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
+ var protected Text group;
+ // Short summary of what command does (recommended to
+ // keep it to 80 characters)
+ var protected Text summary;
+ var protected array subCommands;
+ var protected array options;
+ var protected bool requiresTarget;
+};
+var protected Data commandData;
+
+// 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".
+var protected ConsoleWriter publicConsole, othersConsole;
+var protected ConsoleWriter callerConsole, targetConsole;
+
+protected function Constructor() {
+ local CommandDataBuilder dataBuilder;
+ dataBuilder = CommandDataBuilder(_.memory.Allocate(class'CommandDataBuilder'));
+ BuildData(dataBuilder);
+ commandData = dataBuilder.BorrowData();
+ dataBuilder.FreeSelf();
+ dataBuilder = none;
+}
+
+protected function Finalizer() {
+ local int i;
+ local array subCommands;
+ local array options;
+
+ DeallocateConsoles();
+ _.memory.Free(commandData.name);
+ _.memory.Free(commandData.summary);
+ subCommands = commandData.subCommands;
+ for (i = 0; i < options.length; i += 1) {
+ _.memory.Free(subCommands[i].name);
+ _.memory.Free(subCommands[i].description);
+ CleanParameters(subCommands[i].required);
+ CleanParameters(subCommands[i].optional);
+ subCommands[i].required.length = 0;
+ subCommands[i].optional.length = 0;
+ }
+ commandData.subCommands.length = 0;
+ options = commandData.options;
+ for (i = 0; i < options.length; i += 1) {
+ _.memory.Free(options[i].longName);
+ _.memory.Free(options[i].description);
+ CleanParameters(options[i].required);
+ CleanParameters(options[i].optional);
+ options[i].required.length = 0;
+ options[i].optional.length = 0;
+ }
+ commandData.options.length = 0;
+}
+
+private final function CleanParameters(array 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);
+ }
+}
+
+/// 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.
+///
+/// [`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.
+///
+/// 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.
+///
+/// [`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.
+public final static function Command GetInstance() {
+ if (default.mainInstance == none) {
+ default.mainInstance = Command(__().memory.Allocate(default.class));
+ }
+ return default.mainInstance;
+}
+
+/// 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.
+///
+/// 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,
+ optional BaseText subCommandName
+) {
+ local array targetPlayers;
+ local CommandParser commandParser;
+ local CallData callData;
+
+ if (parser == none || !parser.Ok()) {
+ callData.parsingError = CET_BadParser;
+ return callData;
+ }
+ // Parse targets and handle errors that can arise here
+ if (commandData.requiresTarget) {
+ targetPlayers = ParseTargets(parser, callerPlayer);
+ if (!parser.Ok()) {
+ callData.parsingError = CET_IncorrectTargetList;
+ return callData;
+ }
+ if (targetPlayers.length <= 0) {
+ callData.parsingError = CET_EmptyTargetList;
+ return callData;
+ }
+ }
+ // Parse parameters themselves
+ commandParser = CommandParser(_.memory.Allocate(class'CommandParser'));
+ callData = commandParser.ParseWith(
+ parser,
+ commandData,
+ callerPlayer,
+ subCommandName);
+ callData.targetPlayers = targetPlayers;
+ commandParser.FreeSelf();
+ 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.
+///
+/// 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) {
+ local int i;
+ local array targetPlayers;
+
+ if (callerPlayer == none) return false;
+ if (!callerPlayer.IsExistent()) return false;
+
+ // Report or execute
+ if (callData.parsingError != CET_None) {
+ ReportError(callData, callerPlayer);
+ return false;
+ }
+ targetPlayers = callData.targetPlayers;
+ publicConsole = _.console.ForAll();
+ callerConsole = _.console.For(callerPlayer);
+ callerConsole
+ .Write(P("Executing command `"))
+ .Write(commandData.name)
+ .Say(P("`"));
+ // `othersConsole` should also exist in time for `Executed()` call
+ othersConsole = _.console.ForAll().ButPlayer(callerPlayer);
+ Executed(callData, callerPlayer);
+ _.memory.Free(othersConsole);
+ if (commandData.requiresTarget) {
+ for (i = 0; i < targetPlayers.length; i += 1) {
+ targetConsole = _.console.For(targetPlayers[i]);
+ othersConsole = _.console
+ .ForAll()
+ .ButPlayer(callerPlayer)
+ .ButPlayer(targetPlayers[i]);
+ ExecutedFor(targetPlayers[i], callData, callerPlayer);
+ _.memory.Free(othersConsole);
+ _.memory.Free(targetConsole);
+ }
+ }
+ othersConsole = none;
+ targetConsole = none;
+ DeallocateConsoles();
+ return true;
+}
+
+private final function DeallocateConsoles() {
+ if (publicConsole != none && publicConsole.IsAllocated()) {
+ _.memory.Free(publicConsole);
+ }
+ if (callerConsole != none && callerConsole.IsAllocated()) {
+ _.memory.Free(callerConsole);
+ }
+ if (targetConsole != none && targetConsole.IsAllocated()) {
+ _.memory.Free(targetConsole);
+ }
+ if (othersConsole != none && othersConsole.IsAllocated()) {
+ _.memory.Free(othersConsole);
+ }
+ publicConsole = none;
+ callerConsole = none;
+ targetConsole = none;
+ 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
+private final function ReportError(CallData callData, EPlayer callerPlayer) {
+ local Text errorMessage;
+ local ConsoleWriter console;
+
+ if (callerPlayer == none) return;
+ if (!callerPlayer.IsExistent()) return;
+
+ // Setup console color
+ console = callerPlayer.BorrowConsole();
+ if (callData.parsingError == CET_EmptyTargetList) {
+ console.UseColor(_.color.textWarning);
+ } else {
+ console.UseColor(_.color.textFailure);
+ }
+ // Send message
+ errorMessage = PrintErrorMessage(callData);
+ console.Say(errorMessage);
+ errorMessage.FreeSelf();
+ // Restore console color
+ console.ResetColor().Flush();
+}
+
+private final function Text PrintErrorMessage(CallData callData) {
+ local Text result;
+ local MutableText builder;
+
+ builder = _.text.Empty();
+ switch (callData.parsingError) {
+ case CET_BadParser:
+ builder.Append(P("Internal error occurred: invalid parser"));
+ break;
+ case CET_NoSubCommands:
+ builder.Append(P("Ill defined command: no subcommands"));
+ break;
+ case CET_BadSubCommand:
+ builder
+ .Append(P("Ill defined sub-command: "))
+ .Append(callData.errorCause);
+ break;
+ case CET_NoRequiredParam:
+ builder
+ .Append(P("Missing required parameter: "))
+ .Append(callData.errorCause);
+ break;
+ case CET_NoRequiredParamForOption:
+ builder
+ .Append(P("Missing required parameter for option: "))
+ .Append(callData.errorCause);
+ break;
+ case CET_UnknownOption:
+ builder
+ .Append(P("Invalid option specified: "))
+ .Append(callData.errorCause);
+ break;
+ case CET_UnknownShortOption:
+ builder.Append(P("Invalid short option specified"));
+ break;
+ case CET_RepeatedOption:
+ builder
+ .Append(P("Option specified several times: "))
+ .Append(callData.errorCause);
+ break;
+ case CET_UnusedCommandParameters:
+ builder.Append(P("Part of command could not be parsed: "))
+ .Append(callData.errorCause);
+ break;
+ case CET_MultipleOptionsWithParams:
+ builder
+ .Append(P("Multiple short options in one declarations require parameters: "))
+ .Append(callData.errorCause);
+ break;
+ case CET_IncorrectTargetList:
+ builder
+ .Append(P("Target players are incorrectly specified."))
+ .Append(callData.errorCause);
+ break;
+ case CET_EmptyTargetList:
+ builder
+ .Append(P("List of target players is empty"))
+ .Append(callData.errorCause);
+ break;
+ default:
+ }
+ result = builder.Copy();
+ builder.FreeSelf();
+ return result;
+}
+
+// Auxiliary method for parsing list of targeted players.
+// Assumes given parser is not `none` and not in a failed state.
+// If parsing failed, guaranteed to return an empty array.
+private final function array ParseTargets(Parser parser, EPlayer callerPlayer) {
+ local array targetPlayers;
+ local PlayersParser targetsParser;
+
+ targetsParser = PlayersParser(_.memory.Allocate(class'PlayersParser'));
+ targetsParser.SetSelf(callerPlayer);
+ targetsParser.ParseWith(parser);
+ if (parser.Ok()) {
+ targetPlayers = targetsParser.GetPlayers();
+ }
+ targetsParser.FreeSelf();
+ 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 {
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/CommandAPI.uc b/sources/BaseAPI/API/Commands/CommandAPI.uc
new file mode 100644
index 0000000..52c6112
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/CommandAPI.uc
@@ -0,0 +1,228 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class CommandAPI extends AcediaObject;
+
+// Classes registered to be registered in async way
+var private array< class > pendingClasses;
+// Job that is supposed to register pending commands
+var private CommandRegistrationJob registeringJob;
+
+// Saves `HashTable` with command locks.
+// Locks are simply boolean switches that mark for commands whether they can be executed.
+//
+// Lock is considered "unlocked" if this `HashTable` stores `true` at the key with its name
+// and `false` otherwise.
+var private HashTable commandLocks;
+
+var private Commands_Feature commandsFeature;
+
+// DO NOT CALL MANUALLY
+public final /*internal*/ function class _popPending() {
+ local class result;
+
+ if (pendingClasses.length == 0) {
+ return none;
+ }
+ result = pendingClasses[0];
+ pendingClasses.Remove(0, 1);
+ return result;
+}
+
+// DO NOT CALL MANUALLY
+public final /*internal*/ function _reloadFeature() {
+ if (commandsFeature != none) {
+ commandsFeature.FreeSelf();
+ commandsFeature = none;
+ }
+ commandsFeature = Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
+}
+
+/// Checks if `Commands_Feature` is enabled, which is required for this API to be functional.
+public final function bool AreCommandsEnabled() {
+ // `Commands_Feature` is responsible for updating us with an actually enabled instance
+ return (commandsFeature != none);
+}
+
+/// Registers given command class, making it available via `Execute()`.
+///
+/// Unless you need command right now, it is recommended to use `RegisterCommandAsync()` instead.
+///
+/// Returns `true` if command was successfully registered and `false` otherwise`.
+///
+/// # Errors
+///
+/// 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 commandClass) {
+ if (commandsFeature != none) {
+ return commandsFeature.RegisterCommand(commandClass);
+ }
+ return false;
+}
+
+/// Registers given command class asynchronously, making it available via `Execute()`.
+///
+/// Doesn't register commands immediately, instead scheduling it to be done at a later moment in
+/// time, allowing.
+/// This can help to reduce amount of work we do every tick during server startup, therefore
+/// avoiding crashed due to the faulty infinite loop detection.
+///
+/// # Errors
+///
+/// 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 RegisterCommandAsync(class commandClass) {
+ if (commandsFeature == none) {
+ return;
+ }
+ pendingClasses[pendingClasses.length] = commandClass;
+ if (registeringJob == none || registeringJob.IsCompleted()) {
+ _.memory.Free(registeringJob);
+ registeringJob = CommandRegistrationJob(_.memory.Allocate(class'CommandRegistrationJob'));
+ _.scheduler.AddJob(registeringJob);
+ }
+}
+
+/// 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 commandClass) {
+ if (commandsFeature != none) {
+ commandsFeature.RemoveCommand(commandClass);
+ }
+}
+
+/// Executes command based on the input.
+///
+/// Takes [`commandLine`] as input with command's call, finds appropriate registered command
+/// instance and executes it with parameters specified in the [`commandLine`].
+///
+/// [`callerPlayer`] has to be specified and represents instigator of this command that will receive
+/// appropriate result/error messages.
+///
+/// Returns `true` iff command was successfully executed.
+///
+/// # Errors
+///
+/// Doesn't log any errors, but can complain about errors in name or parameters to
+/// the [`callerPlayer`]
+public final function Execute(BaseText commandLine, EPlayer callerPlayer) {
+ if (commandsFeature != none) {
+ commandsFeature.HandleInput(commandLine, 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`].
+///
+/// [`callerPlayer`] has to be specified and represents instigator of this command that will receive
+/// appropriate result/error messages.
+///
+/// Returns `true` iff command was successfully executed.
+///
+/// # Errors
+///
+/// Doesn't log any errors, but can complain about errors in name or parameters to
+/// the [`callerPlayer`]
+public final function Execute_S(string commandLine, EPlayer callerPlayer) {
+ local MutableText wrapper;
+
+ if (commandsFeature != none) {
+ wrapper = _.text.FromStringM(commandLine);
+ commandsFeature.HandleInput(wrapper, callerPlayer);
+ _.memory.Free(wrapper);
+ }
+}
+
+/// Closes a command lock with a given case-insensitive name.
+///
+/// Command locks are basically just boolean values that commands can use to check for whether they
+/// are allowed to perform certain actions (e.g. cheats).
+public final function bool Lock(BaseText lockName) {
+ local Text lowerCaseName;
+
+ if (lockName == none) return false;
+ if (commandsFeature == none) return false;
+
+ if (commandLocks == none) {
+ commandLocks = _.collections.EmptyHashTable();
+ }
+ lowerCaseName = lockName.LowerCopy();
+ commandLocks.SetBool(lowerCaseName, true);
+ lowerCaseName.FreeSelf();
+ return true;
+}
+
+/// Opens a command lock with a given case-insensitive name.
+///
+/// Command locks are basically just boolean values that commands can use to check for whether they
+/// are allowed to perform certain actions (e.g. cheats).
+public final function bool Unlock(BaseText lockName) {
+ local Text lowerCaseName;
+
+ if (lockName == none) return false;
+ if (commandsFeature == none) return false;
+
+ if (commandLocks == none) {
+ commandLocks = _.collections.EmptyHashTable();
+ }
+ lowerCaseName = lockName.LowerCopy();
+ commandLocks.SetBool(lowerCaseName, false);
+ lowerCaseName.FreeSelf();
+ return true;
+}
+
+/// Checks if a command lock with a given case-insensitive name is closed.
+///
+/// Command locks are basically just boolean values that commands can use to check for whether they
+/// are allowed to perform certain actions (e.g. cheats).
+public final function bool IsLocked(BaseText lockName) {
+ local bool result;
+ local Text lowerCaseName;
+
+ if (lockName == none) return true;
+ if (commandsFeature == none) return true;
+ if (commandLocks == none) return true;
+
+ lowerCaseName = lockName.LowerCopy();
+ result = commandLocks.GetBool(lowerCaseName);
+ lowerCaseName.FreeSelf();
+ return result;
+}
+
+/// Closes all command locks.
+///
+/// Command locks are basically just boolean values that commands can use to check for whether they
+/// are allowed to perform certain actions (e.g. cheats).
+public final function CloseAllLocks() {
+ _.memory.Free(commandLocks);
+ commandLocks = none;
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/CommandDataBuilder.uc b/sources/BaseAPI/API/Commands/CommandDataBuilder.uc
new file mode 100644
index 0000000..b06c8c0
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/CommandDataBuilder.uc
@@ -0,0 +1,910 @@
+/**
+ * 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 .
+ */
+class CommandDataBuilder extends AcediaObject
+ dependson(Command);
+
+//! This is an auxiliary class for convenient creation of [`Command::Data`] using a builder pattern.
+//!
+//! ## Implementation
+//!
+//! We will store all defined data in two ways:
+//!
+//! 1. Selected data: data about parameters for subcommand/option that is currently being filled;
+//! 2. Prepared data: data that was already filled as "selected data" then stored in these records.
+//! Whenever we want to switch to filling another subcommand/option or return already prepared
+//! data we must dump "selected data" into "prepared data" first and then return the latter.
+//!
+//! Builder object is automatically created when new `Command` instance is allocated and doesn't
+//! normally need to be allocated by hand.
+
+// "Prepared data"
+var private Text commandName, commandGroup;
+var private Text commandSummary;
+var private array subcommands;
+var private array options;
+var private bool requiresTarget;
+
+// Auxiliary arrays signifying that we've started adding optional parameters into appropriate
+// `subcommands` and `options`.
+//
+// All optional parameters must follow strictly after required parameters and so, after user have
+// started adding optional parameters to subcommand/option, we prevent them from adding required
+// ones (to that particular command/option).
+var private array subcommandsIsOptional;
+var private array optionsIsOptional;
+
+// "Selected data"
+// `false` means we have selected sub-command, `true` - option
+var private bool selectedItemIsOption;
+// `name` for sub-commands, `longName` for options
+var private Text selectedItemName;
+// Description of selected sub-command/option
+var private Text selectedDescription;
+// Are we filling optional parameters (`true`)? Or required ones (`false`)?
+var private bool selectionIsOptional;
+// Array of parameters we are currently filling (either required or optional)
+var private array selectedParameterArray;
+
+var private LoggerAPI.Definition errLongNameTooShort, errShortNameTooLong;
+var private LoggerAPI.Definition warnSameLongName, warnSameShortName;
+
+protected function Constructor() {
+ // Fill empty subcommand (no special key word) by default
+ SubCommand(P(""));
+}
+
+protected function Finalizer() {
+ subcommands.length = 0;
+ subcommandsIsOptional.length = 0;
+ options.length = 0;
+ optionsIsOptional.length = 0;
+ selectedParameterArray.length = 0;
+ commandName = none;
+ commandGroup = none;
+ commandSummary = none;
+ selectedItemName = none;
+ selectedDescription = none;
+ requiresTarget = false;
+ selectedItemIsOption = false;
+ selectionIsOptional = false;
+}
+
+// Find index of sub-command with a given name `name` in `subcommands`.
+// `-1` if there's not sub-command with such name.
+// Case-sensitive.
+private final function int FindSubCommandIndex(BaseText name) {
+ local int i;
+
+ if (name == none) {
+ return -1;
+ }
+ for (i = 0; i < subcommands.length; i += 1) {
+ if (name.Compare(subcommands[i].name)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+// Find index of option with a given name `name` in `options`.
+// `-1` if there's not sub-command with such name.
+// Case-sensitive.
+private final function int FindOptionIndex(BaseText longName) {
+ local int i;
+
+ if (longName == none) {
+ return -1;
+ }
+ for (i = 0; i < options.length; i += 1) {
+ if (longName.Compare(options[i].longName)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+// Creates an empty selection record for subcommand or option with name (long name) `name`.
+// Doe not check whether subcommand/option with that name already exists.
+// Copies passed `name`, assumes that it is not `none`.
+private final function MakeEmptySelection(BaseText name, bool selectedOption) {
+ selectedItemIsOption = selectedOption;
+ selectedItemName = name.Copy();
+ selectedDescription = none;
+ selectedParameterArray.length = 0;
+ selectionIsOptional = false;
+}
+
+// Select option with a given long name `longName` from `options`.
+// If there is no option with specified `longName` in prepared data - creates new record in
+// selection, otherwise copies previously saved data.
+// Automatically saves previously selected data into prepared data.
+// Copies `name` if it has to create new record.
+private final function SelectOption(BaseText longName) {
+ local int optionIndex;
+
+ if (longName == none) {
+ return;
+ }
+ if (selectedItemIsOption && selectedItemName != none && selectedItemName.Compare(longName)) {
+ return;
+ }
+ RecordSelection();
+ optionIndex = FindOptionIndex(longName);
+ if (optionIndex < 0) {
+ MakeEmptySelection(longName, true);
+ return;
+ }
+ // Load appropriate prepared data, if it exists for
+ // option with long name `longName`
+ selectedItemIsOption = true;
+ selectedItemName = options[optionIndex].longName;
+ selectedDescription = options[optionIndex].description;
+ selectionIsOptional = optionsIsOptional[optionIndex] > 0;
+ if (selectionIsOptional) {
+ selectedParameterArray = options[optionIndex].optional;
+ } else {
+ selectedParameterArray = options[optionIndex].required;
+ }
+}
+
+// Saves currently selected data into prepared data.
+private final function RecordSelection() {
+ if (selectedItemName == none) {
+ return;
+ }
+ if (selectedItemIsOption) {
+ RecordSelectedOption();
+ } else {
+ RecordSelectedSubCommand();
+ }
+}
+
+// Saves selected sub-command into prepared records.
+// Assumes that command and not an option is selected.
+private final function RecordSelectedSubCommand() {
+ local int selectedSubCommandIndex;
+ local Command.SubCommand newSubcommand;
+
+ if (selectedItemName == none) {
+ return;
+ }
+ selectedSubCommandIndex = FindSubCommandIndex(selectedItemName);
+ if (selectedSubCommandIndex < 0) {
+ selectedSubCommandIndex = subcommands.length;
+ subcommands[selectedSubCommandIndex] = newSubcommand;
+ }
+ subcommands[selectedSubCommandIndex].name = selectedItemName;
+ subcommands[selectedSubCommandIndex].description = selectedDescription;
+ if (selectionIsOptional) {
+ subcommands[selectedSubCommandIndex].optional = selectedParameterArray;
+ subcommandsIsOptional[selectedSubCommandIndex] = 1;
+ } else {
+ subcommands[selectedSubCommandIndex].required = selectedParameterArray;
+ subcommandsIsOptional[selectedSubCommandIndex] = 0;
+ }
+}
+
+// Saves currently selected option into prepared records.
+// Assumes that option and not an command is selected.
+private final function RecordSelectedOption() {
+ local int selectedOptionIndex;
+ local Command.Option newOption;
+
+ if (selectedItemName == none) {
+ return;
+ }
+ selectedOptionIndex = FindOptionIndex(selectedItemName);
+ if (selectedOptionIndex < 0) {
+ selectedOptionIndex = options.length;
+ options[selectedOptionIndex] = newOption;
+ }
+ options[selectedOptionIndex].longName = selectedItemName;
+ options[selectedOptionIndex].description = selectedDescription;
+ if (selectionIsOptional) {
+ options[selectedOptionIndex].optional = selectedParameterArray;
+ optionsIsOptional[selectedOptionIndex] = 1;
+ } else {
+ options[selectedOptionIndex].required = selectedParameterArray;
+ optionsIsOptional[selectedOptionIndex] = 0;
+ }
+}
+
+// Validates names (printing errors in case of failure) for the option.
+// Long name must be at least 2 characters long.
+// Short name must be either:
+// 1. exactly one character long;
+// 2. `none`, which leads to deriving `shortName` from `longName`
+// as a first character.
+// Anything else will result in logging a failure and rejection of
+// the option altogether.
+// Returns `none` if validation failed and chosen short name otherwise
+// (if `shortName` was used for it - it's value will be copied).
+private final function BaseText.Character GetValidShortName(
+ BaseText longName,
+ BaseText shortName
+) {
+ // Validate `longName`
+ if (longName == none) {
+ return _.text.GetInvalidCharacter();
+ }
+ if (longName.GetLength() < 2) {
+ _.logger.Auto(errLongNameTooShort).ArgClass(class).Arg(longName.Copy());
+ return _.text.GetInvalidCharacter();
+ }
+ // Validate `shortName`,
+ // deriving if from `longName` if necessary & possible
+ if (shortName == none) {
+ return longName.GetCharacter(0);
+ }
+ if (shortName.IsEmpty() || shortName.GetLength() > 1) {
+ _.logger.Auto(errShortNameTooLong).ArgClass(class).Arg(longName.Copy());
+ return _.text.GetInvalidCharacter();
+ }
+ return shortName.GetCharacter(0);
+}
+
+// Checks that if any option record has a long/short name from a given pair of
+// names (`longName`, `shortName`), then it also has another one.
+//
+// i.e. we cannot have several options with identical names:
+// (--silent, -s) and (--sick, -s).
+private final function bool VerifyNoOptionNamingConflict(
+ BaseText longName,
+ BaseText.Character shortName
+) {
+ local int i;
+ local bool sameShortNames, sameLongNames;
+
+ // To make sure we will search through the up-to-date `options`,
+ // record selection into prepared records.
+ RecordSelection();
+ for (i = 0; i < options.length; i += 1) {
+ sameShortNames = _.text.AreEqual(shortName, options[i].shortName);
+ sameLongNames = longName.Compare(options[i].longName);
+ if (sameLongNames && !sameShortNames) {
+ _.logger.Auto(warnSameLongName).ArgClass(class).Arg(longName.Copy());
+ return true;
+ }
+ if (!sameLongNames && sameShortNames) {
+ _.logger.Auto(warnSameLongName).ArgClass(class).Arg(_.text.FromCharacter(shortName));
+ return true;
+ }
+ }
+ return false;
+}
+
+/// Method that starts defining a new sub-command.
+///
+/// Creates new sub-command with a given name (if it's missing) and then selects sub-command with
+/// a given name to add parameters to.
+///
+/// [`name`] defines name of the sub-command user wants, case-sensitive.
+/// If `none` is passed, this method will do nothing.
+public final function SubCommand(BaseText name) {
+ local int subcommandIndex;
+
+ if (name == none) {
+ return;
+ }
+ if (!selectedItemIsOption && selectedItemName != none && selectedItemName.Compare(name)) {
+ return;
+ }
+ RecordSelection();
+ subcommandIndex = FindSubCommandIndex(name);
+ if (subcommandIndex < 0) {
+ MakeEmptySelection(name, false);
+ return;
+ }
+ // Load appropriate prepared data, if it exists for
+ // sub-command with name `name`
+ selectedItemIsOption = false;
+ selectedItemName = subcommands[subcommandIndex].name;
+ selectedDescription = subcommands[subcommandIndex].description;
+ selectionIsOptional = subcommandsIsOptional[subcommandIndex] > 0;
+ if (selectionIsOptional) {
+ selectedParameterArray = subcommands[subcommandIndex].optional;
+ } else {
+ selectedParameterArray = subcommands[subcommandIndex].required;
+ }
+}
+
+/// Method that starts defining a new option.
+///
+/// This method checks if some of the recorded options are in conflict with given `longName` and
+/// `shortName` (already using one and only one of them).
+/// In case there is no conflict, it creates new option with specified long and short names
+/// (if such option is missing) and selects option with a long name `longName` to add parameters to.
+///
+/// [`longName`] defines long name of the option, case-sensitive (for using an option in form
+/// "--..."). Must be at least two characters long.
+/// [`shortName`] defines short name of the option, case-sensitive (for using an option in form
+/// "-..."). Must be exactly one character.
+///
+/// # Errors
+///
+/// Errors will be logged in case either of arguments are `none`, have inappropriate length or are
+/// in conflict with each other.
+public final function Option(BaseText longName, optional BaseText shortName) {
+ local int optionIndex;
+ local BaseText.Character shortNameAsCharacter;
+
+ // Unlike for `SubCommand()`, we need to ensure that option naming is
+ // correct and does not conflict with existing options
+ // (user might attempt to add two options with same long names and
+ // different short ones).
+ shortNameAsCharacter = GetValidShortName(longName, shortName);
+ if ( !_.text.IsValidCharacter(shortNameAsCharacter)
+ || VerifyNoOptionNamingConflict(longName, shortNameAsCharacter)) {
+ // ^ `GetValidShortName()` and `VerifyNoOptionNamingConflict()`
+ // are responsible for logging warnings/errors
+ return;
+ }
+ SelectOption(longName);
+ // Set short name for new options
+ optionIndex = FindOptionIndex(longName);
+ if (optionIndex < 0) {
+ // We can only be here if option was created for the first time
+ RecordSelection();
+ // So now it cannot fail
+ optionIndex = FindOptionIndex(longName);
+ options[optionIndex].shortName = shortNameAsCharacter;
+ }
+}
+
+/// Adds description to the selected sub-command / option.
+///
+/// Highlights parts of the description in-between "`" characters.
+///
+/// Does nothing if nothing is yet selected.
+public final function Describe(BaseText description) {
+ local int fromIndex, toIndex;
+ local BaseText.Formatting keyWordFormatting;
+ local bool lookingForEnd;
+ local MutableText coloredDescription;
+
+ if (description == none) {
+ return;
+ }
+ keyWordFormatting = _.text.FormattingFromColor(_.color.TextEmphasis);
+ coloredDescription = description.MutableCopy();
+ while (true) {
+ if (lookingForEnd) {
+ toIndex = coloredDescription.IndexOf(P("`"), fromIndex + 1);
+ } else {
+ fromIndex = coloredDescription.IndexOf(P("`"), toIndex + 1);
+ }
+ if (toIndex < 0 || fromIndex < 0) {
+ break;
+ }
+ if (lookingForEnd) {
+ coloredDescription.ChangeFormatting(
+ keyWordFormatting,
+ fromIndex,
+ toIndex - fromIndex + 1);
+ lookingForEnd = false;
+ } else {
+ lookingForEnd = true;
+ }
+ }
+ coloredDescription.Replace(P("`"), P(""));
+ if (lookingForEnd) {
+ coloredDescription.ChangeFormatting(keyWordFormatting, fromIndex);
+ }
+ _.memory.Free(selectedDescription);
+ selectedDescription = coloredDescription.IntoText();
+}
+
+/// Sets new name of the command that caller [`CommandDataBuilder`] constructs.
+public final function Name(BaseText newName) {
+ if (newName != none && newName == commandName) {
+ return;
+ }
+ _.memory.Free(commandName);
+ if (newName != none) {
+ commandName = newName.Copy();
+ } else {
+ commandName = none;
+ }
+}
+
+/// Sets new group of `Command.Data` under construction.
+///
+/// Group name is meant to be shared among several commands, allowing user to filter or
+/// fetch commands of a certain group.
+public final function Group(BaseText newName) {
+ if (newName != none && newName == commandGroup) {
+ return;
+ }
+ _.memory.Free(commandGroup);
+ if (newName != none) {
+ commandGroup = newName.Copy();
+ } else {
+ commandGroup = none;
+ }
+}
+
+/// Sets new summary of `Command.Data` under construction.
+///
+/// Summary gives a short description of the command on the whole that will displayed when "help"
+/// command is listing available command
+public final function Summary(BaseText newSummary) {
+ if (newSummary != none && newSummary == commandSummary) {
+ return;
+ }
+ _.memory.Free(commandSummary);
+ if (newSummary != none) {
+ commandSummary = newSummary.Copy();
+ } else {
+ commandSummary = none;
+ }
+}
+
+/// Makes caller builder to mark `Command.Data` under construction to require a player target.
+public final function RequireTarget() {
+ requiresTarget = true;
+}
+
+
+/// Any parameters added to currently selected sub-command / option after calling this method will
+/// be marked as optional.
+///
+/// Further calls when the same sub-command / option is selected will do nothing.
+public final function OptionalParams()
+{
+ if (selectionIsOptional) {
+ return;
+ }
+ // Record all required parameters first, otherwise there would be no way
+ // to distinguish between them and optional parameters
+ RecordSelection();
+ selectionIsOptional = true;
+ selectedParameterArray.length = 0;
+}
+
+/// Returns data that has been constructed so far by the caller [`CommandDataBuilder`].
+///
+/// Does not reset progress.
+public final function Command.Data BorrowData() {
+ local Command.Data newData;
+
+ RecordSelection();
+ newData.name = commandName;
+ newData.group = commandGroup;
+ newData.summary = commandSummary;
+ newData.subcommands = subcommands;
+ newData.options = options;
+ newData.requiresTarget = requiresTarget;
+ return newData;
+}
+
+// Adds new parameter to selected sub-command / option
+private final function PushParameter(Command.Parameter newParameter) {
+ selectedParameterArray[selectedParameterArray.length] = newParameter;
+}
+
+// Fills `Command.ParameterType` struct with given values (except boolean format).
+// Assumes `displayName != none`.
+private final function Command.Parameter NewParameter(
+ BaseText displayName,
+ Command.ParameterType parameterType,
+ bool isListParameter,
+ optional BaseText variableName
+) {
+ local Command.Parameter newParameter;
+
+ newParameter.displayName = displayName.Copy();
+ newParameter.type = parameterType;
+ newParameter.allowsList = isListParameter;
+ if (variableName != none) {
+ newParameter.variableName = variableName.Copy();
+ } else {
+ newParameter.variableName = displayName.Copy();
+ }
+ return newParameter;
+}
+
+/// Adds new boolean parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`format`] defines preferred format of boolean values.
+/// Command parser will still accept boolean values in any form, this setting only affects how
+/// parameter will be displayed in generated help.
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamBoolean(
+ BaseText name,
+ optional Command.PreferredBooleanFormat format,
+ optional BaseText variableName
+) {
+ local Command.Parameter newParam;
+
+ if (name != none) {
+ newParam = NewParameter(name, CPT_Boolean, false, variableName);
+ newParam.booleanFormat = format;
+ PushParameter(newParam);
+ }
+}
+
+/// Adds new integer list parameter (required or optional depends on whether `OptionalParams()`
+/// call happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`format`] defines preferred format of boolean values.
+/// Command parser will still accept boolean values in any form, this setting only affects how
+/// parameter will be displayed in generated help.
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamBooleanList(
+ BaseText name,
+ optional Command.PreferredBooleanFormat format,
+ optional BaseText variableName
+) {
+ local Command.Parameter newParam;
+
+ if (name != none) {
+ newParam = NewParameter(name, CPT_Boolean, true, variableName);
+ newParam.booleanFormat = format;
+ PushParameter(newParam);
+ }
+}
+
+/// Adds new integer parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamInteger(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Integer, false, variableName));
+ }
+}
+
+/// Adds new integer list parameter (required or optional depends on whether `OptionalParams()`
+/// call happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamIntegerList(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Integer, true, variableName));
+ }
+}
+
+/// Adds new numeric parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamNumber(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Number, false, variableName));
+ }
+}
+
+/// Adds new numeric list parameter (required or optional depends on whether `OptionalParams()`
+/// call happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamNumberList(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Number, true, variableName));
+ }
+}
+
+/// Adds new text parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+///
+/// [`aliasSourceName`] defines name of the alias source that must be used to auto-resolve this
+/// parameter's value. `none` means that parameter will be recorded as-is, any other value
+/// (either "weapon", "color", "feature", "entity" or some kind of custom alias source name) will
+/// make values prefixed with "$" to be resolved as custom aliases.
+/// In case auto-resolving is used, value will be recorded as a `HasTable` with two fields:
+/// "alias" - value provided by user and (in case "$" prefix was used) "value" - actual resolved
+/// value of an alias.
+/// If alias has failed to be resolved, `none` will be stored as a value.
+public final function ParamText(
+ BaseText name,
+ optional BaseText variableName,
+ optional BaseText aliasSourceName
+) {
+ local Command.Parameter newParameterValue;
+
+ if (name == none) {
+ return;
+ }
+ newParameterValue = NewParameter(name, CPT_Text, false, variableName);
+ if (aliasSourceName != none) {
+ newParameterValue.aliasSourceName = aliasSourceName.Copy();
+ }
+ PushParameter(newParameterValue);
+}
+
+/// Adds new text list parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+///
+/// [`aliasSourceName`] defines name of the alias source that must be used to auto-resolve this
+/// parameter's value. `none` means that parameter will be recorded as-is, any other value
+/// (either "weapon", "color", "feature", "entity" or some kind of custom alias source name) will
+/// make values prefixed with "$" to be resolved as custom aliases.
+/// In case auto-resolving is used, value will be recorded as a `HasTable` with two fields:
+/// "alias" - value provided by user and (in case "$" prefix was used) "value" - actual resolved
+/// value of an alias.
+/// If alias has failed to be resolved, `none` will be stored as a value.
+public final function ParamTextList(
+ BaseText name,
+ optional BaseText variableName,
+ optional BaseText aliasSourceName
+) {
+ local Command.Parameter newParameterValue;
+
+ if (name == none) {
+ return;
+ }
+ newParameterValue = NewParameter(name, CPT_Text, true, variableName);
+ if (aliasSourceName != none) {
+ newParameterValue.aliasSourceName = aliasSourceName.Copy();
+ }
+ PushParameter(newParameterValue);
+}
+
+/// Adds new remainder parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// Remainder parameter is a special parameter that will simply consume all remaining command's
+/// input as-is.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamRemainder(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Remainder, false, variableName));
+ }
+}
+
+/// Adds new JSON object parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamObject(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Object, false, variableName));
+ }
+}
+
+/// Adds new JSON object list parameter (required or optional depends on whether `OptionalParams()`
+/// call happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamObjectList(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Object, true, variableName));
+ }
+}
+
+/// Adds new JSON array parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamArray(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Array, false, variableName));
+ }
+}
+
+/// Adds new JSON array list parameter (required or optional depends on whether `OptionalParams()`
+/// call happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamArrayList(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_Array, true, variableName));
+ }
+}
+
+/// Adds new JSON value parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamJSON(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_JSON, false, variableName));
+ }
+}
+
+/// Adds new JSON value list parameter (required or optional depends on whether `OptionalParams()`
+/// call happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamJSONList(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_JSON, true, variableName));
+ }
+}
+
+
+/// Adds new integer parameter (required or optional depends on whether `OptionalParams()` call
+/// happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// Players parameter is a parameter that allows one to specify a list of players through
+/// special selectors, the same way one does for targeted commands.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamPlayers(BaseText name, optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_PLAYERS, false, variableName));
+ }
+}
+
+/// Adds new integer list parameter (required or optional depends on whether `OptionalParams()`
+/// call happened) to the currently selected sub-command / option.
+///
+/// Only fails if provided `name` is `none`.
+///
+/// Players parameter is a parameter that allows one to specify a list of players through
+/// special selectors, the same way one does for targeted commands.
+///
+/// List parameters expect user to enter one or more value of the same type as command's arguments.
+///
+/// [`name`] will become the name of the parameter
+/// (it would appear in the generated "help" command info).
+///
+/// [`variableName`] will become key for this parameter's value in `HashTable` after user's command
+/// input is parsed.
+/// If left `none`, - will coincide with `name` parameter.
+public final function ParamPlayersList(BaseText name,optional BaseText variableName) {
+ if (name != none) {
+ PushParameter(NewParameter(name, CPT_PLAYERS, true, variableName));
+ }
+}
+
+defaultproperties
+{
+ errLongNameTooShort = (l=LOG_Error,m="Command `%1` is trying to register an option with a name that is way too short (<2 characters). Option will be discarded: %2")
+ errShortNameTooLong = (l=LOG_Error,m="Command `%1` is trying to register an option with a short name that doesn't consist of just one character. Option will be discarded: %2")
+ warnSameLongName = (l=LOG_Error,m="Command `%1` is trying to register several options with the same long name \"%2\", but different short names. This should not happen, do not expect correct behavior.")
+ warnSameShortName = (l=LOG_Error,m="Command `%1` is trying to register several options with the same short name \"%2\", but different long names. This should not have happened, do not expect correct behavior.")
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/CommandParser.uc b/sources/BaseAPI/API/Commands/CommandParser.uc
new file mode 100644
index 0000000..57d58c1
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/CommandParser.uc
@@ -0,0 +1,958 @@
+/**
+ * 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 .
+ */
+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.
+//!
+//! `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.
+//! 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.
+//!
+//! # Usage
+//!
+//! 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.
+//!
+//! # 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.
+//!
+//! 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.
+//!
+//! 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
+//! parameters that can contain several values.
+//!
+//! So main parsing method looks something like:
+//!
+//! ```
+//! ParseParameterArrays() {
+//! loop ParseParameter() {
+//! loop ParseSingleValue()
+//! }
+//! }
+//! ```
+//!
+//! `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()` 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.
+//! Rest of the methods perform simple auxiliary functions.
+
+// Describes which parameters we are currently parsing, classifying them
+// as either "necessary" or "extra".
+//
+// E.g. if last require parameter is a list of integers,
+// then after parsing first integer we are:
+//
+// * 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\
+// result in failed parsing and "extra" otherwise.
+enum ParsingTarget {
+ // We are in the process of parsing required parameters, that must all
+ // be present.
+ // This case does not include parsing last required parameter: it needs
+ // to be treated differently to track when we change from "necessary" to
+ // "extra" parameters.
+ CPT_NecessaryParameter,
+ // We are parsing last necessary parameter.
+ CPT_LastNecessaryParameter,
+ // We are not parsing extra parameters that can be safely omitted.
+ CPT_ExtraParameter,
+};
+
+// Parser filled with user input.
+var private Parser commandParser;
+// Data for sub-command specified by both command we are parsing
+// and user's input; determined early during parsing.
+var private Command.SubCommand pickedSubCommand;
+// Options available for the command we are parsing.
+var private array availableOptions;
+// Result variable we are filling during the parsing process,
+// 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
+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
+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`.
+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.
+var private array usedOptions;
+
+// Literals that can be used as boolean values
+var private array booleanTrueEquivalents;
+var private array booleanFalseEquivalents;
+
+var LoggerAPI.Definition errNoSubCommands;
+
+protected function Finalizer() {
+ Reset();
+}
+
+// Zero important variables
+private final function Reset() {
+ local Command.CallData blankCallData;
+
+ _.memory.Free(currentPlayersParser);
+ currentPlayersParser = none;
+ // We didn't create this one and are not meant to free it either
+ commandParser = none;
+ nextResult = blankCallData;
+ currentTarget = CPT_NecessaryParameter;
+ currentTargetIsOption = false;
+ usedOptions.length = 0;
+}
+
+// Auxiliary method for recording errors
+private final function DeclareError(Command.ErrorType type, optional BaseText cause) {
+ nextResult.parsingError = type;
+ if (cause != none) {
+ nextResult.errorCause = cause.Copy();
+ }
+ if (commandParser != none) {
+ commandParser.Fail();
+ }
+}
+
+// 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).
+//
+// 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;
+ local Command.SubCommand emptySubCommand;
+ local array allSubCommands;
+
+ allSubCommands = commandData.subCommands;
+ if (allSubcommands.length == 0) {
+ _.logger.Auto(errNoSubCommands).ArgClass(class);
+ pickedSubCommand = emptySubCommand;
+ return;
+ }
+ // Get candidate name
+ confirmedState = commandParser.GetCurrentState();
+ if (specifiedSubCommand != none) {
+ candidateSubCommandName = specifiedSubCommand.MutableCopy();
+ } else {
+ commandParser.Skip().MUntil(candidateSubCommandName,, true);
+ }
+ // Try matching it to sub commands
+ pickedSubCommand = allSubcommands[0];
+ if (candidateSubCommandName.IsEmpty()) {
+ candidateSubCommandName.FreeSelf();
+ return;
+ }
+ for (i = 0; i < allSubcommands.length; i += 1) {
+ if (candidateSubCommandName.Compare(allSubcommands[i].name)) {
+ candidateSubCommandName.FreeSelf();
+ pickedSubCommand = allSubcommands[i];
+ return;
+ }
+ }
+ // We will only reach here if we did not match any sub commands,
+ // 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() {
+ local Text remainder;
+
+ if (!commandParser.Ok()) return;
+ if (commandParser.Skip().GetRemainingLength() <= 0) return;
+
+ remainder = commandParser.GetRemainder();
+ DeclareError(CET_UnusedCommandParameters, remainder);
+ remainder.FreeSelf();
+}
+
+// Assumes `commandParser` is not `none`.
+// 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(
+ array requiredParameters,
+ array optionalParameters
+) {
+ local HashTable parsedParameters;
+
+ if (!commandParser.Ok()) {
+ return none;
+ }
+ parsedParameters = _.collections.EmptyHashTable();
+ // Parse parameters
+ ParseRequiredParameterArray(parsedParameters, requiredParameters);
+ ParseOptionalParameterArray(parsedParameters, optionalParameters);
+ // Parse trailing options
+ while (TryParsingOptions());
+ return parsedParameters;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+//
+// Parses given required parameters along with any possible option declarations into given
+// `parsedParameters` `HashTable`.
+private final function ParseRequiredParameterArray(
+ HashTable parsedParameters,
+ array requiredParameters
+) {
+ local int i;
+
+ if (!commandParser.Ok()) {
+ return;
+ }
+ currentTarget = CPT_NecessaryParameter;
+ while (i < requiredParameters.length) {
+ if (i == requiredParameters.length - 1) {
+ currentTarget = CPT_LastNecessaryParameter;
+ }
+ // Parse parameters one-by-one, reporting appropriate errors
+ if (!ParseParameter(parsedParameters, requiredParameters[i])) {
+ // Any failure to parse required parameter leads to error
+ if (currentTargetIsOption) {
+ DeclareError( CET_NoRequiredParamForOption,
+ targetOption.longName);
+ } else {
+ DeclareError( CET_NoRequiredParam,
+ requiredParameters[i].displayName);
+ }
+ return;
+ }
+ i += 1;
+ }
+ currentTarget = CPT_ExtraParameter;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+//
+// Parses given optional parameters along with any possible option declarations into given
+// `parsedParameters` hash table.
+private final function ParseOptionalParameterArray(
+ HashTable parsedParameters,
+ array optionalParameters
+) {
+ local int i;
+
+ if (!commandParser.Ok()) {
+ return;
+ }
+ while (i < optionalParameters.length) {
+ confirmedState = commandParser.GetCurrentState();
+ // Parse parameters one-by-one, reporting appropriate errors
+ if (!ParseParameter(parsedParameters, optionalParameters[i])) {
+ // Propagate errors
+ if (nextResult.parsingError != CET_None) {
+ return;
+ }
+ // Failure to parse optional parameter is fine if
+ // it is caused by that parameters simply missing
+ commandParser.RestoreState(confirmedState);
+ break;
+ }
+ i += 1;
+ }
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+//
+// 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.
+private final function bool ParseParameter(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local bool parsedEnough;
+
+ confirmedState = commandParser.GetCurrentState();
+ while (ParseSingleValue(parsedParameters, expectedParameter)) {
+ if (currentTarget == CPT_LastNecessaryParameter) {
+ currentTarget = CPT_ExtraParameter;
+ }
+ parsedEnough = true;
+ // We are done if there is either no more input or we only needed
+ // to parse a single value
+ if (!expectedParameter.allowsList) {
+ return true;
+ }
+ if (commandParser.Skip().HasFinished()) {
+ return true;
+ }
+ confirmedState = commandParser.GetCurrentState();
+ }
+ // We only succeeded in parsing if we've parsed enough for
+ // a given parameter and did not encounter any errors
+ if (parsedEnough && nextResult.parsingError == CET_None) {
+ commandParser.RestoreState(confirmedState);
+ return true;
+ }
+ // Clean up any values `ParseSingleValue` might have recorded
+ parsedParameters.RemoveItem(expectedParameter.variableName);
+ return false;
+}
+
+// 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`.
+//
+// Returns `true` if we've successfully parsed a single value without any errors.
+private final function bool ParseSingleValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ // Before parsing any other value we need to check if user has specified any options instead.
+ //
+ // However this might lead to errors if we are already parsing necessary parameters of another
+ // option: we must handle such situation and report an error.
+ if (currentTargetIsOption) {
+ // There is no problem is option's parameter is remainder
+ if (expectedParameter.type == CPT_Remainder) {
+ return ParseRemainderValue(parsedParameters, expectedParameter);
+ }
+ if (currentTarget != CPT_ExtraParameter && TryParsingOptions()) {
+ DeclareError(CET_NoRequiredParamForOption, targetOption.longName);
+ return false;
+ }
+ }
+ while (TryParsingOptions());
+ // First we try `CPT_Remainder` parameter, since it is a special case that
+ // consumes all further input
+ if (expectedParameter.type == CPT_Remainder) {
+ return ParseRemainderValue(parsedParameters, expectedParameter);
+ }
+ // Propagate errors after parsing options
+ if (nextResult.parsingError != CET_None) {
+ return false;
+ }
+ // Try parsing one of the variable types
+ if (expectedParameter.type == CPT_Boolean) {
+ return ParseBooleanValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_Integer) {
+ return ParseIntegerValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_Number) {
+ return ParseNumberValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_Text) {
+ return ParseTextValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_Remainder) {
+ return ParseRemainderValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_Object) {
+ return ParseObjectValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_Array) {
+ return ParseArrayValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_JSON) {
+ return ParseJSONValue(parsedParameters, expectedParameter);
+ } else if (expectedParameter.type == CPT_Players) {
+ return ParsePlayersValue(parsedParameters, expectedParameter);
+ }
+ return false;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+//
+// Parses a single boolean value into given `parsedParameters` hash table.
+private final function bool ParseBooleanValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local int i;
+ local bool isValidBooleanLiteral;
+ local bool booleanValue;
+ local MutableText parsedLiteral;
+
+ commandParser.Skip().MUntil(parsedLiteral,, true);
+ if (!commandParser.Ok()) {
+ _.memory.Free(parsedLiteral);
+ return false;
+ }
+ // Try to match parsed literal to any recognizable boolean literals
+ for (i = 0; i < booleanTrueEquivalents.length; i += 1) {
+ if (parsedLiteral.CompareToString(booleanTrueEquivalents[i], SCASE_INSENSITIVE)) {
+ isValidBooleanLiteral = true;
+ booleanValue = true;
+ break;
+ }
+ }
+ for (i = 0; i < booleanFalseEquivalents.length; i += 1) {
+ if (isValidBooleanLiteral) {
+ break;
+ }
+ if (parsedLiteral.CompareToString(booleanFalseEquivalents[i], SCASE_INSENSITIVE)) {
+ isValidBooleanLiteral = true;
+ booleanValue = false;
+ }
+ }
+ parsedLiteral.FreeSelf();
+ if (!isValidBooleanLiteral) {
+ return false;
+ }
+ RecordParameter(parsedParameters, expectedParameter, _.box.bool(booleanValue));
+ return true;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+// Parses a single integer value into given `parsedParameters` hash table.
+private final function bool ParseIntegerValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local int integerValue;
+
+ commandParser.Skip().MInteger(integerValue);
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ RecordParameter(parsedParameters, expectedParameter, _.box.int(integerValue));
+ return true;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+// Parses a single number (float) value into given `parsedParameters` hash table.
+private final function bool ParseNumberValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local float numberValue;
+
+ commandParser.Skip().MNumber(numberValue);
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ RecordParameter(parsedParameters, expectedParameter, _.box.float(numberValue));
+ return true;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+// Parses a single `Text` value into given `parsedParameters`
+// hash table.
+private final function bool ParseTextValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local bool failedParsing;
+ local MutableText textValue;
+ local Parser.ParserState initialState;
+ local HashTable resolvedPair;
+
+ // (needs some work for reading formatting `string`s from `Text` objects)
+ initialState = commandParser.Skip().GetCurrentState();
+ // Try manually parsing as a string literal first, since then we will
+ // allow empty `textValue` as a result
+ commandParser.MStringLiteral(textValue);
+ failedParsing = !commandParser.Ok();
+ // Otherwise - empty values are not allowed
+ if (failedParsing) {
+ _.memory.Free(textValue);
+ commandParser.RestoreState(initialState).MString(textValue);
+ failedParsing = (!commandParser.Ok() || textValue.IsEmpty());
+ }
+ if (failedParsing) {
+ _.memory.Free(textValue);
+ commandParser.Fail();
+ return false;
+ }
+ resolvedPair = AutoResolveAlias(textValue, expectedParameter.aliasSourceName);
+ if (resolvedPair != none) {
+ RecordParameter(parsedParameters, expectedParameter, resolvedPair);
+ _.memory.Free(textValue);
+ } else {
+ RecordParameter(parsedParameters, expectedParameter, textValue.IntoText());
+ }
+ return true;
+}
+
+// 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;
+ local Text resolvedValue, immutableValue;
+
+ if (textValue == none) return none;
+ if (aliasSourceName == none) return none;
+
+ // Always create `HashTable` with at least "alias" key
+ result = _.collections.EmptyHashTable();
+ immutableValue = textValue.Copy();
+ result.SetItem(P("alias"), immutableValue);
+ _.memory.Free(immutableValue);
+ // Add "value" key only after we've checked for "$" prefix
+ if (!textValue.StartsWithS("$")) {
+ return result;
+ }
+ if (aliasSourceName.Compare(P("weapon"))) {
+ resolvedValue = _.alias.ResolveWeapon(textValue, true);
+ } else if (aliasSourceName.Compare(P("color"))) {
+ resolvedValue = _.alias.ResolveColor(textValue, true);
+ } else if (aliasSourceName.Compare(P("feature"))) {
+ resolvedValue = _.alias.ResolveFeature(textValue, true);
+ } else if (aliasSourceName.Compare(P("entity"))) {
+ resolvedValue = _.alias.ResolveEntity(textValue, true);
+ } else {
+ resolvedValue = _.alias.ResolveCustom(aliasSourceName, textValue, true);
+ }
+ result.SetItem(P("value"), resolvedValue);
+ _.memory.Free2(resolvedValue, immutableValue);
+ return result;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+//
+// Parses a single `Text` value into given `parsedParameters` hash table, consuming all remaining
+// contents.
+private final function bool ParseRemainderValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local MutableText value;
+
+ commandParser.Skip().MUntil(value);
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ RecordParameter(parsedParameters, expectedParameter, value.IntoText());
+ return true;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+//
+// Parses a single JSON object into given `parsedParameters` hash table.
+private final function bool ParseObjectValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local HashTable objectValue;
+
+ objectValue = _.json.ParseHashTableWith(commandParser);
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ RecordParameter(parsedParameters, expectedParameter, objectValue);
+ return true;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+// Parses a single JSON array into given `parsedParameters` hash table.
+private final function bool ParseArrayValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local ArrayList arrayValue;
+
+ arrayValue = _.json.ParseArrayListWith(commandParser);
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ RecordParameter(parsedParameters, expectedParameter, arrayValue);
+ return true;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+// Parses a single JSON value into given `parsedParameters`
+// hash table.
+private final function bool ParseJSONValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter
+) {
+ local AcediaObject jsonValue;
+
+ jsonValue = _.json.ParseWith(commandParser);
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ RecordParameter(parsedParameters, expectedParameter, jsonValue);
+ return true;
+}
+
+// Assumes `commandParser` and `parsedParameters` are not `none`.
+// Parses a single JSON value into given `parsedParameters` hash table.
+private final function bool ParsePlayersValue(
+ HashTable parsedParameters,
+ Command.Parameter expectedParameter)
+{
+ local ArrayList resultPlayerList;
+ local array targetPlayers;
+
+ currentPlayersParser.ParseWith(commandParser);
+ if (commandParser.Ok()) {
+ targetPlayers = currentPlayersParser.GetPlayers();
+ } else {
+ return false;
+ }
+ resultPlayerList = _.collections.NewArrayList(targetPlayers);
+ _.memory.FreeMany(targetPlayers);
+ RecordParameter(parsedParameters, expectedParameter, resultPlayerList);
+ return true;
+}
+
+// Assumes `parsedParameters` is not `none`.
+//
+// Records `value` for a given `parameter` into a given `parametersArray`.
+// If parameter is not a list type - simply records `value` as value under
+// `parameter.variableName` key.
+// If parameter is a list type - pushed value at the end of an array, recorded at
+// `parameter.variableName` key (creating it if missing).
+//
+// All recorded values are managed by `parametersArray`.
+private final function RecordParameter(
+ HashTable parametersArray,
+ Command.Parameter parameter,
+ /*take*/ AcediaObject value
+) {
+ local ArrayList parameterVariable;
+
+ if (!parameter.allowsList) {
+ parametersArray.SetItem(parameter.variableName, value);
+ _.memory.Free(value);
+ return;
+ }
+ parameterVariable = ArrayList(parametersArray.GetItem(parameter.variableName));
+ if (parameterVariable == none) {
+ parameterVariable = _.collections.EmptyArrayList();
+ }
+ parameterVariable.AddItem(value);
+ _.memory.Free(value);
+ parametersArray.SetItem(parameter.variableName, parameterVariable);
+ _.memory.Free(parameterVariable);
+}
+
+// Assumes `commandParser` is not `none`.
+//
+// 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.
+private final function bool TryParsingOptions() {
+ local int temporaryInt;
+
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ confirmedState = commandParser.GetCurrentState();
+ // Long options
+ commandParser.Skip().Match(P("--"));
+ if (commandParser.Ok()) {
+ return ParseLongOption();
+ }
+ // Filter out negative numbers that start similarly to short options:
+ // -3, -5.7, -.9
+ commandParser
+ .RestoreState(confirmedState)
+ .Skip()
+ .Match(P("-"))
+ .MUnsignedInteger(temporaryInt, 10, 1);
+ if (commandParser.Ok()) {
+ commandParser.RestoreState(confirmedState);
+ return false;
+ }
+ commandParser.RestoreState(confirmedState).Skip().Match(P("-."));
+ if (commandParser.Ok()) {
+ commandParser.RestoreState(confirmedState);
+ return false;
+ }
+ // Short options
+ commandParser.RestoreState(confirmedState).Skip().Match(P("-"));
+ if (commandParser.Ok()) {
+ return ParseShortOption();
+ }
+ commandParser.RestoreState(confirmedState);
+ return false;
+}
+
+// Assumes `commandParser` is not `none`.
+//
+// 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`).
+private final function bool ParseLongOption() {
+ local int i, optionIndex;
+ local MutableText optionName;
+
+ commandParser.MUntil(optionName,, true);
+ if (!commandParser.Ok()) {
+ return false;
+ }
+ while (optionIndex < availableOptions.length) {
+ if (optionName.Compare(availableOptions[optionIndex].longName)) break;
+ optionIndex += 1;
+ }
+ if (optionIndex >= availableOptions.length) {
+ DeclareError(CET_UnknownOption, optionName);
+ optionName.FreeSelf();
+ return false;
+ }
+ for (i = 0; i < usedOptions.length; i += 1) {
+ if (optionName.Compare(usedOptions[i].longName)) {
+ DeclareError(CET_RepeatedOption, optionName);
+ optionName.FreeSelf();
+ return false;
+ }
+ }
+ //usedOptions[usedOptions.length] = availableOptions[optionIndex];
+ optionName.FreeSelf();
+ return ParseOptionParameters(availableOptions[optionIndex]);
+}
+
+// Assumes `commandParser` and `nextResult` are not `none`.
+//
+// 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()
+{
+ local int i;
+ local bool pickedOptionWithParameters;
+ local MutableText optionsList;
+
+ commandParser.MUntil(optionsList,, true);
+ if (!commandParser.Ok()) {
+ optionsList.FreeSelf();
+ return false;
+ }
+ for (i = 0; i < optionsList.GetLength(); i += 1) {
+ if (nextResult.parsingError != CET_None) break;
+ pickedOptionWithParameters =
+ AddOptionByCharacter(
+ optionsList.GetCharacter(i),
+ optionsList,
+ pickedOptionWithParameters)
+ || pickedOptionWithParameters;
+ }
+ optionsList.FreeSelf();
+ return (nextResult.parsingError == CET_None);
+}
+
+// Assumes `commandParser` and `nextResult` are not `none`.
+//
+// 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.
+//
+// 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.
+private final function bool AddOptionByCharacter(
+ BaseText.Character optionCharacter,
+ BaseText optionSourceList,
+ bool forbidOptionWithParameters
+) {
+ local int i;
+ local bool optionHasParameters;
+
+ // Prevent same option appearing twice
+ for (i = 0; i < usedOptions.length; i += 1) {
+ if (_.text.AreEqual(optionCharacter, usedOptions[i].shortName)) {
+ DeclareError(CET_RepeatedOption, usedOptions[i].longName);
+ return false;
+ }
+ }
+ // If it's a new option - look it up in all available options
+ for (i = 0; i < availableOptions.length; i += 1) {
+ if (!_.text.AreEqual(optionCharacter, availableOptions[i].shortName)) {
+ continue;
+ }
+ usedOptions[usedOptions.length] = availableOptions[i];
+ optionHasParameters = (availableOptions[i].required.length > 0
+ || availableOptions[i].optional.length > 0);
+ // Enforce `forbidOptionWithParameters` flag restriction
+ if (optionHasParameters && forbidOptionWithParameters) {
+ DeclareError(CET_MultipleOptionsWithParams, optionSourceList);
+ return optionHasParameters;
+ }
+ // Parse parameters (even if they are empty) and bail
+ commandParser.Skip();
+ ParseOptionParameters(availableOptions[i]);
+ break;
+ }
+ if (i >= availableOptions.length) {
+ DeclareError(CET_UnknownShortOption);
+ }
+ return optionHasParameters;
+}
+
+// Auxiliary method for parsing option's parameters (including empty ones).
+// 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;
+
+ // If we are already parsing other option's parameters and did not finish
+ // parsing all required ones - we cannot start another option
+ if (currentTargetIsOption && currentTarget != CPT_ExtraParameter) {
+ DeclareError(CET_NoRequiredParamForOption, targetOption.longName);
+ return false;
+ }
+ if (pickedOption.required.length == 0 && pickedOption.optional.length == 0) {
+ nextResult.options.SetItem(pickedOption.longName, none);
+ return true;
+ }
+ currentTargetIsOption = true;
+ targetOption = pickedOption;
+ optionParameters = ParseParameterArrays(
+ pickedOption.required,
+ pickedOption.optional);
+ currentTargetIsOption = false;
+ if (commandParser.Ok()) {
+ nextResult.options.SetItem(pickedOption.longName, optionParameters);
+ _.memory.Free(optionParameters);
+ return true;
+ }
+ _.memory.Free(optionParameters);
+ return false;
+}
+
+defaultproperties {
+ booleanTrueEquivalents(0) = "true"
+ booleanTrueEquivalents(1) = "enable"
+ booleanTrueEquivalents(2) = "on"
+ booleanTrueEquivalents(3) = "yes"
+ booleanFalseEquivalents(0) = "false"
+ booleanFalseEquivalents(1) = "disable"
+ booleanFalseEquivalents(2) = "off"
+ booleanFalseEquivalents(3) = "no"
+ errNoSubCommands = (l=LOG_Error,m="`GetSubCommand()` method was called on a command `%1` with zero defined sub-commands.")
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/CommandRegistrationJob.uc b/sources/BaseAPI/API/Commands/CommandRegistrationJob.uc
new file mode 100644
index 0000000..5bc783c
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/CommandRegistrationJob.uc
@@ -0,0 +1,54 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class CommandRegistrationJob extends SchedulerJob;
+
+var private class nextCommand;
+
+protected function Constructor() {
+ nextCommand = _.commands._popPending();
+}
+
+protected function Finalizer() {
+ nextCommand = none;
+}
+
+public function bool IsCompleted() {
+ return (nextCommand == 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;
+ }
+ }
+}
+
+defaultproperties
+{
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Commands.uc b/sources/BaseAPI/API/Commands/Commands.uc
new file mode 100644
index 0000000..82f5ba7
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Commands.uc
@@ -0,0 +1,60 @@
+/**
+ * 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 .
+ */
+class Commands extends FeatureConfig
+ perobjectconfig
+ config(AcediaSystem);
+
+var public config bool useChatInput;
+var public config bool useMutateInput;
+var public config string chatCommandPrefix;
+
+protected function HashTable ToData() {
+ local HashTable data;
+
+ data = __().collections.EmptyHashTable();
+ data.SetBool(P("useChatInput"), useChatInput, true);
+ data.SetBool(P("useMutateInput"), useMutateInput, true);
+ data.SetString(P("chatCommandPrefix"), chatCommandPrefix);
+ return data;
+}
+
+protected function FromData(HashTable source) {
+ if (source == none) {
+ return;
+ }
+ useChatInput = source.GetBool(P("useChatInput"));
+ useMutateInput = source.GetBool(P("useMutateInput"));
+ chatCommandPrefix = source.GetString(P("chatCommandPrefix"), "!");
+}
+
+protected function DefaultIt() {
+ useChatInput = true;
+ useMutateInput = true;
+ chatCommandPrefix = "!";
+}
+
+defaultproperties {
+ configName = "AcediaSystem"
+ useChatInput = true
+ useMutateInput = true
+ chatCommandPrefix = "!"
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Commands_Feature.uc b/sources/BaseAPI/API/Commands/Commands_Feature.uc
new file mode 100644
index 0000000..7480bcd
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Commands_Feature.uc
@@ -0,0 +1,719 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class Commands_Feature extends Feature;
+
+//! This feature manages commands that automatically parse their arguments into standard Acedia
+//! collections.
+//!
+//! # Implementation
+//!
+//! Implementation is simple: calling a method `RegisterCommand()` adds
+//! command into two caches `registeredCommands` for obtaining registered
+//! commands by name and `groupedCommands` for obtaining arrays of commands by
+//! their group name. These arrays are used for providing methods for fetching
+//! arrays of commands and obtaining pre-allocated `Command` instances by their
+//! name.
+//! Depending on settings, this feature also connects to corresponding
+//! signals for catching "mutate"/chat input, then it checks user-specified name
+//! for being an alias and picks correct command from `registeredCommands`.
+//! Emergency enabling this feature sets `emergencyEnabledMutate` flag that
+//! enforces connecting to the "mutate" input.
+
+/// Pairs [`Voting`] class with a name its registered under in lower case for quick search.
+struct NamedVoting {
+ var public class 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
+/// sub-command name.
+///
+/// Normally sub-command name is parsed by the command itself, however command aliases can try to
+/// enforce one.
+struct CommandCallPair {
+ var MutableText commandName;
+ /// 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
+};
+
+/// Delimiters that always separate command name from it's parameters
+var private array commandDelimiters;
+/// Registered commands, recorded as (, ) pairs.
+/// Keys should be deallocated when their entry is removed.
+var private HashTable registeredCommands;
+/// [`HashTable`] of "" <-> [`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 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 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
+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;
+
+protected function OnEnabled() {
+ registeredCommands = _.collections.EmptyHashTable();
+ groupedCommands = _.collections.EmptyHashTable();
+ RegisterCommand(class'ACommandHelp');
+ RegisterCommand(class'ACommandNotify');
+ RegisterCommand(class'ACommandVote');
+ if (_.environment.IsDebugging()) {
+ RegisterCommand(class'ACommandFakers');
+ }
+ RegisterVotingClass(class'Voting');
+ // Macro selector
+ commandDelimiters[0] = _.text.FromString("@");
+ // Key selector
+ commandDelimiters[1] = _.text.FromString("#");
+ // Player array (possibly JSON array)
+ commandDelimiters[2] = _.text.FromString("[");
+ // Negation of the selector
+ // NOT the same thing as default command prefix in chat
+ commandDelimiters[3] = _.text.FromString("!");
+ if (useChatInput) {
+ _.chat.OnMessage(self).connect = HandleCommands;
+ }
+ else {
+ _.chat.OnMessage(self).Disconnect();
+ }
+ if (useMutateInput || emergencyEnabledMutate) {
+ if (__server() != none) {
+ __server().unreal.mutator.OnMutate(self).connect = HandleMutate;
+ } else {
+ _.logger.Auto(errServerAPIUnavailable);
+ }
+ }
+}
+
+protected function OnDisabled() {
+ if (useChatInput) {
+ _.chat.OnMessage(self).Disconnect();
+ }
+ if (useMutateInput && __server() != none) {
+ __server().unreal.mutator.OnMutate(self).Disconnect();
+ }
+ useChatInput = false;
+ useMutateInput = false;
+ _.memory.Free3(registeredCommands, groupedCommands, chatCommandPrefix);
+ registeredCommands = none;
+ groupedCommands = none;
+ chatCommandPrefix = none;
+ commandDelimiters.length = 0;
+ ReleaseNameVotingsArray(/*out*/ registeredVotings);
+}
+
+protected function SwapConfig(FeatureConfig config)
+{
+ local Commands newConfig;
+
+ newConfig = Commands(config);
+ if (newConfig == none) {
+ return;
+ }
+ _.memory.Free(chatCommandPrefix);
+ chatCommandPrefix = _.text.FromString(newConfig.chatCommandPrefix);
+ useChatInput = newConfig.useChatInput;
+ useMutateInput = newConfig.useMutateInput;
+}
+
+/// This method allows to forcefully enable `Command_Feature` along with "mutate" input in case
+/// something goes wrong.
+///
+/// `Command_Feature` is a critical command to have running on your server and,
+/// if disabled by accident, there will be no way of starting it again without
+/// restarting the level or even editing configs.
+public final static function EmergencyEnable() {
+ local bool noWayToInputCommands;
+ local Text autoConfig;
+ local Commands_Feature feature;
+
+ if (!IsEnabled()) {
+ autoConfig = GetAutoEnabledConfig();
+ EnableMe(autoConfig);
+ __().memory.Free(autoConfig);
+ }
+ feature = Commands_Feature(GetEnabledInstance());
+ noWayToInputCommands = !feature.emergencyEnabledMutate
+ &&!feature.IsUsingMutateInput()
+ && !feature.IsUsingChatInput();
+ if (noWayToInputCommands) {
+ default.emergencyEnabledMutate = true;
+ feature.emergencyEnabledMutate = true;
+ if (__server() != none) {
+ __server().unreal.mutator.OnMutate(feature).connect = HandleMutate;
+ } else {
+ __().logger.Auto(default.errServerAPIUnavailable);
+ }
+ }
+}
+
+/// Checks if `Commands_Feature` currently uses chat as input.
+///
+/// If `Commands_Feature` is not enabled, then it does not use anything
+/// as input.
+public final static function bool IsUsingChatInput() {
+ local Commands_Feature instance;
+
+ instance = Commands_Feature(GetEnabledInstance());
+ if (instance != none) {
+ return instance.useChatInput;
+ }
+ return false;
+}
+
+/// Checks if `Commands_Feature` currently uses mutate command as input.
+///
+/// If `Commands_Feature` is not enabled, then it does not use anything
+/// as input.
+public final static function bool IsUsingMutateInput() {
+ local Commands_Feature instance;
+
+ instance = Commands_Feature(GetEnabledInstance());
+ if (instance != none) {
+ return instance.useMutateInput;
+ }
+ return false;
+}
+
+/// Returns prefix that will indicate that chat message is intended to be a command. By default "!".
+///
+/// If `Commands_Feature` is disabled, always returns `none`.
+public final static function Text GetChatPrefix() {
+ local Commands_Feature instance;
+
+ instance = Commands_Feature(GetEnabledInstance());
+ if (instance != none && instance.chatCommandPrefix != none) {
+ return instance.chatCommandPrefix.Copy();
+ }
+ return none;
+}
+
+/// Returns `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 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 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 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 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 commandClass) {
+ local int i;
+ local CollectionIterator iter;
+ local Command nextCommand;
+ local Text nextCommandName;
+ local array commandGroup;
+ local array 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.
+///
+/// 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 GetCommandNames() {
+ local array 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 GetCommandNamesInGroup(BaseText groupName) {
+ local int i;
+ local ArrayList groupArray;
+ local Command nextCommand;
+ local array 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 GetGroupsNames() {
+ local array emptyResult;
+
+ if (groupedCommands != none) {
+ return groupedCommands.GetTextKeys();
+ }
+ return emptyResult;
+}
+
+/// Executes command based on the input.
+///
+/// Takes [`commandLine`] as input with command's call, finds appropriate registered command
+/// instance and executes it with parameters specified in the [`commandLine`].
+///
+/// [`callerPlayer`] has to be specified and represents instigator of this command that will receive
+/// appropriate result/error messages.
+///
+/// Returns `true` iff command was successfully executed.
+///
+/// # Errors
+///
+/// Doesn't log any errors, but can complain about errors in name or parameters to
+/// the [`callerPlayer`]
+public final function bool HandleInput(BaseText input, EPlayer callerPlayer) {
+ local bool result;
+ local Parser wrapper;
+
+ if (input == none) {
+ return false;
+ }
+ wrapper = input.Parse();
+ result = HandleInputWith(wrapper, callerPlayer);
+ wrapper.FreeSelf();
+ return result;
+}
+
+/// Executes command based on the input.
+///
+/// Takes [`commandLine`] as input with command's call, finds appropriate registered command
+/// instance and executes it with parameters specified in the [`commandLine`].
+///
+/// [`callerPlayer`] has to be specified and represents instigator of this command that will receive
+/// appropriate result/error messages.
+///
+/// Returns `true` iff command was successfully executed.
+///
+/// # Errors
+///
+/// Doesn't log any errors, but can complain about errors in name or parameters to
+/// the [`callerPlayer`]
+public final function bool HandleInputWith(Parser parser, EPlayer callerPlayer) {
+ local bool errorOccured;
+ local Command commandInstance;
+ local Command.CallData callData;
+ local CommandCallPair callPair;
+
+ if (parser == none) return false;
+ if (callerPlayer == none) return false;
+ if (!parser.Ok()) return false;
+
+ callPair = ParseCommandCallPairWith(parser);
+ commandInstance = GetCommand(callPair.commandName);
+ if (commandInstance == none && 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);
+ }
+ return none;
+}
+
+// Parses command's name into `CommandCallPair` - sub-command is filled in case
+// specified name is an alias with specified sub-command name.
+private final function CommandCallPair ParseCommandCallPairWith(Parser parser) {
+ local Text resolvedValue;
+ local MutableText userSpecifiedName;
+ local CommandCallPair result;
+ local Text.Character dotCharacter;
+
+ if (parser == none) return result;
+ if (!parser.Ok()) return result;
+
+ parser.MUntilMany(userSpecifiedName, commandDelimiters, true, true);
+ resolvedValue = _.alias.ResolveCommand(userSpecifiedName);
+ // This isn't an alias
+ if (resolvedValue == none) {
+ result.commandName = userSpecifiedName;
+ return result;
+ }
+ // It is an alias - parse it
+ dotCharacter = _.text.GetCharacter(".");
+ resolvedValue.Parse()
+ .MUntil(result.commandName, dotCharacter)
+ .MatchS(".")
+ .MUntil(result.subCommandName, dotCharacter)
+ .FreeSelf();
+ if (result.subCommandName.IsEmpty()) {
+ result.subCommandName.FreeSelf();
+ result.subCommandName = none;
+ }
+ resolvedValue.FreeSelf();
+ return result;
+}
+
+private function bool HandleCommands(EPlayer sender, MutableText message, bool teamMessage) {
+ local Parser parser;
+
+ // We are only interested in messages that start with `chatCommandPrefix`
+ parser = _.text.Parse(message);
+ if (!parser.Match(chatCommandPrefix).Ok()) {
+ parser.FreeSelf();
+ return true;
+ }
+ // Pass input to command feature
+ HandleInputWith(parser, sender);
+ parser.FreeSelf();
+ return false;
+}
+
+private function HandleMutate(string command, PlayerController sendingPlayer) {
+ local Parser parser;
+ local EPlayer sender;
+
+ // A lot of other mutators use these commands
+ if (command ~= "help") return;
+ if (command ~= "version") return;
+ if (command ~= "status") return;
+ if (command ~= "credits") return;
+
+ parser = _.text.ParseString(command);
+ sender = _.players.FromController(sendingPlayer);
+ HandleInputWith(parser, sender);
+ sender.FreeSelf();
+ parser.FreeSelf();
+}
+
+private final function RemoveClassFromGroup(class commandClass, BaseText commandGroup) {
+ local int i;
+ local ArrayList groupArray;
+ local Command nextCommand;
+
+ groupArray = groupedCommands.GetArrayList(commandGroup);
+ if (groupArray == none) {
+ return;
+ }
+ while (i < groupArray.GetLength()) {
+ nextCommand = Command(groupArray.GetItem(i));
+ if (nextCommand != none && nextCommand.class == commandClass) {
+ groupArray.RemoveIndex(i);
+ } else {
+ i += 1;
+ }
+ _.memory.Free(nextCommand);
+ }
+ if (groupArray.GetLength() == 0) {
+ groupedCommands.RemoveItem(commandGroup);
+ }
+ _.memory.Free(groupArray);
+}
+
+private final function ReleaseNameVotingsArray(out array toRelease) {
+ local int i;
+
+ for (i = 0; i < toRelease.length; i += 1) {
+ _.memory.Free(toRelease[i].processName);
+ toRelease[i].processName = none;
+ }
+ toRelease.length = 0;
+}
+
+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.")
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/PlayersParser.uc b/sources/BaseAPI/API/Commands/PlayersParser.uc
new file mode 100644
index 0000000..4c8f014
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/PlayersParser.uc
@@ -0,0 +1,487 @@
+/**
+ * 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 .
+ */
+class PlayersParser extends AcediaObject
+ dependson(Parser);
+
+//! This parser is supposed to parse player set definitions as they
+//! are used in commands.
+//!
+//! Basic use is to specify one of the selectors:
+//! 1. Key selector: "#" (examples: "#1", "#5").
+//! 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.
+//! "@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 "@".
+//! These specify name prefixes: any player with specified prefix will be considered to match
+//! such selector.
+//!
+//! Negated selectors: "!". Specifying "!" in front of selector will select all players
+//! that do not match it instead.
+//!
+//! Grouped selectors: "['', '', ... '']".
+//! 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:
+//!
+//! * "[@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;
+//! 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
+//! (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.
+
+// 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`.
+var private array playersSnapshot;
+// Players, selected according to selectors we have parsed so far
+var private array 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).
+var private bool parsedFirstSelector;
+// Will be equal to a single-element array [","], used for parsing
+var private array selectorDelimiters;
+
+var const int TSELF, TME, TADMIN, TALL, TNOT, TKEY, TMACRO, TCOMMA;
+var const int TOPEN_BRACKET, TCLOSE_BRACKET;
+
+protected function Finalizer() {
+ // No need to deallocate `currentSelection`,
+ // since it has `EPlayer`s from `playersSnapshot` or `selfPlayer`
+ _.memory.Free(selfPlayer);
+ _.memory.FreeMany(playersSnapshot);
+ selfPlayer = none;
+ parsedFirstSelector = false;
+ playersSnapshot.length = 0;
+ currentSelection.length = 0;
+}
+
+/// Set a player who will be referred to by "@", "@me" and "@self" macros.
+///
+/// Passing `none` will make it so no one is referred by these macros.
+public final function SetSelf(EPlayer newSelfPlayer) {
+ _.memory.Free(selfPlayer);
+ selfPlayer = none;
+ if (newSelfPlayer != none) {
+ selfPlayer = EPlayer(newSelfPlayer.Copy());
+ }
+}
+
+// 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) {
+ local int i;
+
+ if (toInsert == none) {
+ return;
+ }
+ for (i = 0; i < currentSelection.length; i += 1) {
+ if (currentSelection[i] == toInsert) {
+ return;
+ }
+ }
+ currentSelection[currentSelection.length] = toInsert;
+}
+
+// Adds all the players with specified key (`key`) to the current selection.
+private final function AddByKey(int key) {
+ local int i;
+
+ for (i = 0; i < playersSnapshot.length; i += 1) {
+ if (playersSnapshot[i].GetIdentity().GetKey() == key) {
+ InsertPlayer(playersSnapshot[i]);
+ }
+ }
+}
+
+// Removes all the players with specified key (`key`) from
+// the current selection.
+private final function RemoveByKey(int key) {
+ local int i;
+
+ while (i < currentSelection.length) {
+ if (currentSelection[i].GetIdentity().GetKey() == key) {
+ currentSelection.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+// Adds all the players with specified name (`name`) to the current selection.
+private final function AddByName(BaseText name) {
+ local int i;
+ local Text nextPlayerName;
+
+ if (name == none) {
+ return;
+ }
+ for (i = 0; i < playersSnapshot.length; i += 1) {
+ nextPlayerName = playersSnapshot[i].GetName();
+ if (nextPlayerName.StartsWith(name, SCASE_INSENSITIVE)) {
+ InsertPlayer(playersSnapshot[i]);
+ }
+ nextPlayerName.FreeSelf();
+ }
+}
+
+// Removes all the players with specified name (`name`) from
+// the current selection.
+private final function RemoveByName(BaseText name) {
+ local int i;
+ local Text nextPlayerName;
+
+ while (i < currentSelection.length) {
+ nextPlayerName = currentSelection[i].GetName();
+ if (nextPlayerName.StartsWith(name, SCASE_INSENSITIVE)) {
+ currentSelection.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ nextPlayerName.FreeSelf();
+ }
+}
+
+// Adds all the admins to the current selection.
+private final function AddAdmins() {
+ local int i;
+
+ for (i = 0; i < playersSnapshot.length; i += 1) {
+ if (playersSnapshot[i].IsAdmin()) {
+ InsertPlayer(playersSnapshot[i]);
+ }
+ }
+}
+
+// Removes all the admins from the current selection.
+private final function RemoveAdmins() {
+ local int i;
+
+ while (i < currentSelection.length) {
+ if (currentSelection[i].IsAdmin()) {
+ currentSelection.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+// Add all the players specified by `macroText` (from macro "@").
+// Does nothing if there is no such macro.
+private final function AddByMacro(BaseText macroText) {
+ if (macroText.Compare(T(TADMIN), SCASE_INSENSITIVE)) {
+ AddAdmins();
+ return;
+ }
+ if (macroText.Compare(T(TALL), SCASE_INSENSITIVE)) {
+ currentSelection = playersSnapshot;
+ return;
+ }
+ if ( macroText.IsEmpty()
+ || macroText.Compare(T(TSELF), SCASE_INSENSITIVE)
+ || macroText.Compare(T(TME), SCASE_INSENSITIVE)) {
+ InsertPlayer(selfPlayer);
+ }
+}
+
+// Removes all the players specified by `macroText` (from macro "@").
+// Does nothing if there is no such macro.
+private final function RemoveByMacro(BaseText macroText) {
+ local int i;
+
+ if (macroText.Compare(T(TADMIN), SCASE_INSENSITIVE)) {
+ RemoveAdmins();
+ return;
+ }
+ if (macroText.Compare(T(TALL), SCASE_INSENSITIVE)) {
+ currentSelection.length = 0;
+ return;
+ }
+ if (macroText.IsEmpty() || macroText.Compare(T(TSELF), SCASE_INSENSITIVE)) {
+ while (i < currentSelection.length) {
+ if (currentSelection[i] == selfPlayer) {
+ currentSelection.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+ }
+}
+
+// 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;
+
+ if (parser == none) return;
+ if (!parser.Ok()) return;
+
+ confirmedState = parser.GetCurrentState();
+ if (!parser.Match(T(TNOT)).Ok()) {
+ additiveSelector = true;
+ parser.RestoreState(confirmedState);
+ }
+ // Determine whether we stars with empty or full player list
+ if (!parsedFirstSelector) {
+ parsedFirstSelector = true;
+ if (additiveSelector) {
+ currentSelection.length = 0;
+ }
+ else {
+ currentSelection = playersSnapshot;
+ }
+ }
+ // Try all selector types
+ confirmedState = parser.GetCurrentState();
+ if (parser.Match(T(TKEY)).Ok()) {
+ ParseKeySelector(parser, additiveSelector);
+ return;
+ }
+ parser.RestoreState(confirmedState);
+ if (parser.Match(T(TMACRO)).Ok()) {
+ ParseMacroSelector(parser, additiveSelector);
+ return;
+ }
+ parser.RestoreState(confirmedState);
+ ParseNameSelector(parser, additiveSelector);
+}
+
+// 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;
+
+ if (parser == none) return;
+ if (!parser.Ok()) return;
+ if (!parser.MInteger(key).Ok()) return;
+
+ if (additiveSelector) {
+ AddByKey(key);
+ } else {
+ RemoveByKey(key);
+ }
+}
+
+// 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;
+
+ if (parser == none) return;
+ if (!parser.Ok()) return;
+
+ confirmedState = parser.GetCurrentState();
+ macroName = ParseLiteral(parser);
+ if (!parser.Ok()) {
+ _.memory.Free(macroName);
+ return;
+ }
+ if (additiveSelector) {
+ AddByMacro(macroName);
+ }
+ else {
+ RemoveByMacro(macroName);
+ }
+ _.memory.Free(macroName);
+}
+
+// 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;
+
+ if (parser == none) return;
+ if (!parser.Ok()) return;
+
+ confirmedState = parser.GetCurrentState();
+ playerName = ParseLiteral(parser);
+ if (!parser.Ok() || playerName.IsEmpty()) {
+ _.memory.Free(playerName);
+ return;
+ }
+ if (additiveSelector) {
+ AddByName(playerName);
+ }
+ else {
+ RemoveByName(playerName);
+ }
+ _.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 "@").
+//
+// 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;
+
+ if (parser == none) return none;
+ if (!parser.Ok()) return none;
+
+ confirmedState = parser.GetCurrentState();
+ if (!parser.MStringLiteral(literal).Ok()) {
+ parser.RestoreState(confirmedState);
+ parser.MUntilMany(literal, selectorDelimiters, true);
+ }
+ 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 GetPlayers() {
+ local int i;
+ local array 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() {
+ parsedFirstSelector = false;
+ currentSelection.length = 0;
+ _.memory.FreeMany(playersSnapshot);
+ playersSnapshot.length = 0;
+ playersSnapshot = _.players.GetAll();
+ selectorDelimiters.length = 0;
+ selectorDelimiters[0] = T(TCOMMA);
+ 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"
+ TADMIN = 1
+ stringConstants(1) = "admin"
+ TALL = 2
+ stringConstants(2) = "all"
+ TNOT = 3
+ stringConstants(3) = "!"
+ TKEY = 4
+ stringConstants(4) = "#"
+ TMACRO = 5
+ stringConstants(5) = "@"
+ TCOMMA = 6
+ stringConstants(6) = ","
+ TOPEN_BRACKET = 7
+ stringConstants(7) = "["
+ TCLOSE_BRACKET = 8
+ stringConstants(8) = "]"
+ TME = 9
+ stringConstants(9) = "me"
+}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/Tests/MockCommandA.uc b/sources/BaseAPI/API/Commands/Tests/MockCommandA.uc
similarity index 68%
rename from sources/LevelAPI/Features/Commands/Tests/MockCommandA.uc
rename to sources/BaseAPI/API/Commands/Tests/MockCommandA.uc
index 48bfbc6..630b1d6 100644
--- a/sources/LevelAPI/Features/Commands/Tests/MockCommandA.uc
+++ b/sources/BaseAPI/API/Commands/Tests/MockCommandA.uc
@@ -21,16 +21,17 @@ class MockCommandA extends Command;
protected function BuildData(CommandDataBuilder builder)
{
- builder.ParamObject(P("just_obj"))
- .ParamArrayList(P("manyLists"))
- .OptionalParams()
- .ParamObject(P("last_obj"));
- builder.SubCommand(P("simple"))
- .ParamBooleanList(P("isItSimple?"))
- .ParamInteger(P("integer variable"), P("int"))
- .OptionalParams()
- .ParamNumberList(P("numeric list"), P("list"))
- .ParamTextList(P("another list"));
+ builder.ParamObject(P("just_obj"));
+ builder.ParamArrayList(P("manyLists"));
+ builder.OptionalParams();
+ builder.ParamObject(P("last_obj"));
+
+ builder.SubCommand(P("simple"));
+ builder.ParamBooleanList(P("isItSimple?"));
+ builder.ParamInteger(P("integer variable"), P("int"));
+ builder.OptionalParams();
+ builder.ParamNumberList(P("numeric list"), P("list"));
+ builder.ParamTextList(P("another list"));
}
defaultproperties
diff --git a/sources/BaseAPI/API/Commands/Tests/MockCommandB.uc b/sources/BaseAPI/API/Commands/Tests/MockCommandB.uc
new file mode 100644
index 0000000..7f27fdc
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Tests/MockCommandB.uc
@@ -0,0 +1,61 @@
+/**
+ * Mock command class for testing.
+ * Copyright 2021 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class MockCommandB extends Command;
+
+protected function BuildData(CommandDataBuilder builder)
+{
+ builder.ParamArray(P("just_array"));
+ builder.ParamText(P("just_text"));
+
+ builder.Option(P("values"));
+ builder.ParamIntegerList(P("types"));
+
+ builder.Option(P("long"));
+ builder.ParamInteger(P("num"));
+ builder.ParamNumberList(P("text"));
+ builder.ParamBoolean(P("huh"));
+
+ builder.Option(P("type"), P("t"));
+ builder.ParamText(P("type"));
+
+ builder.Option(P("Test"));
+ builder.ParamText(P("to_test"));
+
+ builder.Option(P("silent"));
+ builder.Option(P("forced"));
+ builder.Option(P("verbose"), P("V"));
+ builder.Option(P("actual"));
+
+ builder.SubCommand(P("do"));
+ builder.OptionalParams();
+ builder.ParamNumberList(P("numeric list"), P("list"));
+ builder.ParamBoolean(P("maybe"));
+
+ builder.Option(P("remainder"));
+ builder.ParamRemainder(P("everything"));
+
+ builder.SubCommand(P("json"));
+ builder.ParamJSON(P("first_json"));
+ builder.ParamJSONList(P("other_json"));
+}
+
+defaultproperties
+{
+}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/Tests/TEST_Command.uc b/sources/BaseAPI/API/Commands/Tests/TEST_Command.uc
similarity index 100%
rename from sources/LevelAPI/Features/Commands/Tests/TEST_Command.uc
rename to sources/BaseAPI/API/Commands/Tests/TEST_Command.uc
diff --git a/sources/LevelAPI/Features/Commands/Tests/TEST_CommandDataBuilder.uc b/sources/BaseAPI/API/Commands/Tests/TEST_CommandDataBuilder.uc
similarity index 94%
rename from sources/LevelAPI/Features/Commands/Tests/TEST_CommandDataBuilder.uc
rename to sources/BaseAPI/API/Commands/Tests/TEST_CommandDataBuilder.uc
index a573e52..1494e46 100644
--- a/sources/LevelAPI/Features/Commands/Tests/TEST_CommandDataBuilder.uc
+++ b/sources/BaseAPI/API/Commands/Tests/TEST_CommandDataBuilder.uc
@@ -24,27 +24,33 @@ class TEST_CommandDataBuilder extends TestCase
protected static function CommandDataBuilder PrepareBuilder()
{
local CommandDataBuilder builder;
- builder =
- CommandDataBuilder(__().memory.Allocate(class'CommandDataBuilder'));
- builder.ParamNumber(P("var")).ParamText(P("str_var"), P("otherName"));
+ builder = CommandDataBuilder(__().memory.Allocate(class'CommandDataBuilder'));
+ builder.ParamNumber(P("var"));
+ builder.ParamText(P("str_var"), P("otherName"));
builder.OptionalParams();
builder.Describe(P("Simple command"));
builder.ParamBooleanList(P("list"), PBF_OnOff);
// Subcommands
- builder.SubCommand(P("sub")).ParamArray(P("array_var"));
+ builder.SubCommand(P("sub"));
+ builder.ParamArray(P("array_var"));
builder.Describe(P("Alternative command!"));
builder.ParamIntegerList(P("int"));
builder.SubCommand(P("empty"));
builder.Describe(P("Empty one!"));
- builder.SubCommand(P("huh")).ParamNumber(P("list"));
- builder.SubCommand(P("sub")).ParamObjectList(P("one_more"), P("but"));
+ builder.SubCommand(P("huh"));
+ builder.ParamNumber(P("list"));
+ builder.SubCommand(P("sub"));
+ builder.ParamObjectList(P("one_more"), P("but"));
builder.Describe(P("Alternative command! Updated!"));
// Options
- builder.Option(P("silent")).Describe(P("Just an option, I dunno."));
+ builder.Option(P("silent"));
+ builder.Describe(P("Just an option, I dunno."));
builder.Option(P("Params"), P("d"));
builder.ParamBoolean(P("www"), PBF_YesNo, P("random"));
- builder.OptionalParams().ParamIntegerList(P("www2"));
- return builder.RequireTarget();
+ builder.OptionalParams();
+ builder.ParamIntegerList(P("www2"));
+ builder.RequireTarget();
+ return builder;
}
protected static function Command.SubCommand GetSubCommand(
diff --git a/sources/BaseAPI/API/Commands/Voting/TEST_Voting.uc b/sources/BaseAPI/API/Commands/Voting/TEST_Voting.uc
new file mode 100644
index 0000000..e4df93a
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Voting/TEST_Voting.uc
@@ -0,0 +1,563 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class 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 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"
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Voting/Voting.uc b/sources/BaseAPI/API/Commands/Voting/Voting.uc
new file mode 100644
index 0000000..9c0b6e8
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Voting/Voting.uc
@@ -0,0 +1,437 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class Voting extends AcediaObject
+ dependsOn(VotingModel);
+
+//! Class that describes a single voting option.
+//!
+//! [`Voting`] is created to be heavily integrated with [`Commands_Feature`] and shouldn't be used
+//! separately from it.
+//! You shouldn't allocate its instances directly unless you're working on
+//! the [`Commands_Feature`]'s or related code.
+//!
+//! # Usage
+//!
+//! This class takes care of the voting process by itself, one only needs to call [`Start()`] method.
+//! If you wish to prematurely end voting (e.g. forcing it to end), then call [`Stop()`] method.
+//!
+//! Note that there is no method to handle ending of [`Voting`], since all necessary logic is
+//! expected to be performed internally.
+//! [`Commands_Feature`] does this check lazily (only when user wants to start another voting)
+//! via [`HasEnded()`] method.
+
+/// Records whether end-of-the voting methods were already called.
+var private bool endingHandled;
+/// Records whether voting was forcefully ended
+var private bool forcefullyEnded;
+/// Underlying voting model that does actual vote calculations.
+var private VotingModel model;
+
+/// Fake voters that are only used in debug mode to allow for simpler vote testing.
+var private array debugVoters;
+
+/// Text that serves as a template for announcing current vote counts. Don't change this.
+var private const string votesDistributionLine;
+/// Text that serves as a template for announcing player making a new vote. Don't change this.
+var private const string playerVotedLine;
+// [`TextTemplate`]s made from the above `string` templates.
+var private TextTemplate summaryTemplate, playerVotedTemplate;
+
+// TODO: check these limitations
+/// Name of this voting.
+///
+/// Has to satisfy limitations described in the `BaseText::IsValidName()`
+var private const string preferredName;
+/// Text that should be displayed when voting info is displayed mid-voting.
+///
+/// There isn't any hard limitations, but for the sake of uniformity try to mimic
+/// "Voting to end trader currently active." line, avoid adding formatting and don't
+/// add comma/exclamation mark at the end.
+var private const string infoLine;
+/// Text that should be displayed when voting starts.
+///
+/// There isn't any hard limitations, but for the sake of uniformity try to mimic
+/// "Voting to end trader has started" line, avoid adding formatting and don't
+/// add comma/exclamation mark at the end.
+var private const string votingStartedLine;
+/// Text that should be displayed when voting has ended with a success.
+///
+/// There isn't any hard limitations, but for the sake of uniformity try to mimic
+/// "{$TextPositive Voting to end trader was successful!}" line, coloring it in a positive color and
+/// adding comma/exclamation mark at the end.
+var private const string votingSucceededLine;
+/// Text that should be displayed when voting has ended in a failure.
+///
+/// There isn't any hard limitations, but for the sake of uniformity try to mimic
+/// "{$TextNegative Voting to end trader has failed!}" line, coloring it in a negative color and
+/// adding comma/exclamation mark at the end.
+var private const string votingFailedLine;
+
+protected function Constructor() {
+ summaryTemplate = _.text.MakeTemplate_S(votesDistributionLine);
+ playerVotedTemplate = _.text.MakeTemplate_S(playerVotedLine);
+}
+
+protected function Finalizer() {
+ forcefullyEnded = false;
+ endingHandled = false;
+ _.memory.Free(model);
+ _.memory.Free(summaryTemplate);
+ _.memory.Free(playerVotedTemplate);
+ model = none;
+ summaryTemplate = none;
+ playerVotedTemplate = none;
+}
+
+/// Override this to perform necessary logic after voting has succeeded.
+protected function Execute() {}
+
+/// Override this to specify arguments for your voting command.
+///
+/// This method is for adding arguments only.
+/// DO NOT call [`CommandDataBuilder::SubCommand()`] or [`CommandDataBuilder::Option()`] methods,
+/// otherwise you'll cause unexpected behavior for your mod's users.
+public static function AddInfo(CommandDataBuilder builder) {
+ builder.OptionalParams();
+ builder.ParamText(P("message"));
+}
+
+/// Returns name of this voting in the lower case.
+public final static function Text GetPreferredName() {
+ local Text result;
+
+ result = __().text.FromString(Locs(default.preferredName));
+ if (result.IsValidName()) {
+ return result;
+ }
+ __().memory.Free(result);
+ return none;
+}
+
+/// Starts caller [`Voting`] using policies, loaded from the given config.
+public final function Start(BaseText votingConfigName) {
+ local int i;
+ local MutableText howToVoteHint;
+ local array voters;
+
+ if (model != none) {
+ return;
+ }
+ model = VotingModel(_.memory.Allocate(class'VotingModel'));
+ model.Initialize(VP_CanChangeVote);
+ voters = UpdateVoters();
+ howToVoteHint = MakeHowToVoteHint();
+ for (i = 0; i < voters.length; i += 1) {
+ voters[i].Notify(F(votingStartedLine), howToVoteHint);
+ voters[i].BorrowConsole().WriteLine(F(votingStartedLine));
+ voters[i].BorrowConsole().WriteLine(howToVoteHint);
+ }
+ _.memory.FreeMany(voters);
+ _.memory.Free(howToVoteHint);
+}
+
+// Assembles "Say {$TextPositive !yes} or {$TextNegative !no} to vote" hint.
+// Replaces "!yes"/"!no" with "!vote yes"/"!vote no" if corresponding aliases aren't properly setup.
+private final function MutableText MakeHowToVoteHint() {
+ local Text resolvedAlias;
+ local MutableText result;
+
+ result = P("Say ").MutableCopy();
+ resolvedAlias = _.alias.ResolveCommand(P("yes"));
+ if (resolvedAlias != none && resolvedAlias.Compare(P("vote.yes"), SCASE_SENSITIVE)) {
+ result.Append(P("!yes"), _.text.FormattingFromColor(_.color.TextPositive));
+ } else {
+ result.Append(P("!vote yes"), _.text.FormattingFromColor(_.color.TextPositive));
+ }
+ _.memory.Free(resolvedAlias);
+ result.Append(P(" or "));
+ resolvedAlias = _.alias.ResolveCommand(P("no"));
+ if (resolvedAlias != none && resolvedAlias.Compare(P("vote.no"), SCASE_SENSITIVE)) {
+ result.Append(P("!no"), _.text.FormattingFromColor(_.color.TextNegative));
+ } else {
+ result.Append(P("!vote no"), _.text.FormattingFromColor(_.color.TextNegative));
+ }
+ _.memory.Free(resolvedAlias);
+ result.Append(P(" to vote"));
+ return result;
+}
+
+/// Forcefully stops [`Voting`].
+///
+/// Only works if [`Voting`] has already started, but didn't yet ended (see [`HasEnded()`]).
+public final function Stop() {
+ if (model != none) {
+ forcefullyEnded = true;
+ }
+}
+
+/// Determines whether the [`Voting`] process has concluded.
+///
+/// Note that this is different from the voting being active, as voting that has not yet started is
+/// also not concluded.
+public final function bool HasEnded() {
+ if (model == none) {
+ return false;
+ }
+ return (forcefullyEnded || model.HasEnded());
+}
+
+/// Returns the current voting status for the specified voter.
+///
+/// If the voter was previously eligible to vote, cast a vote, but later had their voting rights
+/// revoked, their vote will not count, and this method will return [`PVS_NoVote`].
+///
+/// However, if the player regains their voting rights while the voting process is still ongoing,
+/// their previous vote will be automatically restored by the caller [`Voting`].
+public final function VotingModel.PlayerVoteStatus GetVote(UserID voter) {
+ if (model != none) {
+ return model.GetVote(voter);
+ }
+ return PVS_NoVote;
+}
+
+/// Specifies the [`UserID`]s that will be added as additional voters in debug mode.
+///
+/// This method should only be used for debugging purposes and will only function if the game is
+/// running in debug mode.
+public final function SetDebugVoters(array newDebugVoters) {
+ local int i;
+
+ if(!_.environment.IsDebugging()) {
+ return;
+ }
+ _.memory.FreeMany(debugVoters);
+ debugVoters.length = 0;
+ for (i = 0; i < newDebugVoters.length; i += 1) {
+ if (newDebugVoters[i] != none) {
+ debugVoters[debugVoters.length] = newDebugVoters[i];
+ newDebugVoters[i].NewRef();
+ }
+ }
+ _.memory.FreeMany(UpdateVoters());
+ TryEnding();
+}
+
+/// Adds a new vote by a given [`UserID`].
+///
+/// NOTE: this method is intended for use only in debug mode, and is will not do anything otherwise.
+/// This method silently adds a vote using the provided [`UserID`], without any prompt or
+/// notification of updated voting status.
+/// It was added to facilitate testing with fake [`UserID`]s, and is limited to debug mode to
+/// prevent misuse and unintended behavior in production code.
+public final function VotingModel.VotingResult CastVoteByID(UserID voter, bool voteForSuccess) {
+ local array allVoters;
+ local VotingModel.VotingResult result;
+
+ if (model == none) return VFR_NotAllowed;
+ if (voter == none) return VFR_NotAllowed;
+ if (!_.environment.IsDebugging()) return VFR_NotAllowed;
+
+ allVoters = UpdateVoters();
+ result = model.CastVote(voter, voteForSuccess);
+ if (!TryEnding() && result == VFR_Success) {
+ AnnounceNewVote(none, allVoters, voteForSuccess);
+ }
+ _.memory.FreeMany(allVoters);
+ return result;
+}
+
+/// Casts a vote for the specified player.
+///
+/// This method will update the voting status for the specified player and may trigger the end of
+/// the voting process.
+/// After a vote is cast, the updated voting status will be broadcast to all players.
+public final function VotingModel.VotingResult CastVote(EPlayer voter, bool voteForSuccess) {
+ local bool votingContinues;
+ local UserID voterID;
+ local array allVoters;
+ local VotingModel.VotingResult result;
+
+ if (model == none) return VFR_NotAllowed;
+ if (voter == none) return VFR_NotAllowed;
+
+ voterID = voter.GetUserID();
+ allVoters = UpdateVoters();
+ result = model.CastVote(voterID, voteForSuccess);
+ votingContinues = !TryEnding();
+ if (votingContinues) {
+ switch (result) {
+ case VFR_Success:
+ AnnounceNewVote(voter, allVoters, voteForSuccess);
+ break;
+ case VFR_NotAllowed:
+ voter
+ .BorrowConsole()
+ .WriteLine(F("You are {$TextNegative not allowed} to vote right now."));
+ break;
+ case VFR_CannotChangeVote:
+ voter.BorrowConsole().WriteLine(F("Changing vote is {$TextNegative forbidden}."));
+ break;
+ case VFR_VotingEnded:
+ voter.BorrowConsole().WriteLine(F("Voting has already {$TextNegative ended}!"));
+ break;
+ default:
+ }
+ }
+ _.memory.Free(voterID);
+ _.memory.FreeMany(allVoters);
+ return result;
+}
+
+/// Prints information about caller [`Voting`] to the given player.
+public final function PrintVotingInfoFor(EPlayer requester) {
+ local MutableText summaryPart;
+
+ if (requester == none) {
+ return;
+ }
+ summaryTemplate.Reset();
+ summaryTemplate.ArgInt(model.GetVotesFor());
+ summaryTemplate.ArgInt(model.GetVotesAgainst());
+ summaryPart = summaryTemplate.CollectFormattedM();
+ requester
+ .BorrowConsole()
+ .Write(F(infoLine))
+ .Write(P(". "))
+ .Write(summaryPart)
+ .WriteLine(P("."));
+ _.memory.Free(summaryPart);
+}
+
+/// Outputs message about new vote being submitted to all relevant voters.
+private final function AnnounceNewVote(EPlayer voter, array voters, bool voteForSuccess) {
+ local int i;
+ local Text voterName;
+ local MutableText playerVotedPart, summaryPart;
+
+ summaryTemplate.Reset();
+ summaryTemplate.ArgInt(model.GetVotesFor());
+ summaryTemplate.ArgInt(model.GetVotesAgainst());
+ summaryPart = summaryTemplate.CollectFormattedM();
+
+ playerVotedTemplate.Reset();
+ if (voter != none) {
+ voterName = voter.GetName();
+ } else {
+ voterName = P("DEBUG:FAKER").Copy();
+ }
+ playerVotedTemplate.TextArg(P("player_name"), voterName, true);
+ _.memory.Free(voterName);
+ if (voteForSuccess) {
+ playerVotedTemplate.TextArg(P("vote_type"), F("{$TextPositive for}"));
+ } else {
+ playerVotedTemplate.TextArg(P("vote_type"), F("{$TextNegative against}"));
+ }
+ playerVotedPart = playerVotedTemplate.CollectFormattedM();
+ for (i = 0; i < voters.length; i += 1) {
+ voters[i]
+ .BorrowConsole()
+ .Write(playerVotedPart)
+ .Write(P(". "))
+ .Write(summaryPart)
+ .WriteLine(P("."));
+ }
+ _.memory.Free(playerVotedPart);
+ _.memory.Free(summaryPart);
+}
+
+/// Tries to end voting.
+///
+/// Returns `true` iff this method was called for the first time after the voting concluded.
+private final function bool TryEnding() {
+ local MutableText outcomeMessage;
+
+ if (model == none) return false;
+ if (endingHandled) return false;
+ if (!HasEnded()) return false;
+
+ endingHandled = true;
+ if (model.GetStatus() == VPM_Success) {
+ outcomeMessage = _.text.FromFormattedStringM(votingSucceededLine);
+ } else {
+ outcomeMessage = _.text.FromFormattedStringM(votingFailedLine);
+ }
+ AnnounceOutcome(outcomeMessage);
+ if (model.GetStatus() == VPM_Success) {
+ Execute();
+ }
+ _.memory.Free(outcomeMessage);
+ return true;
+}
+
+/// Updates the inner voting model with current list of players allowed to vote.
+/// Also returns said list.
+private final function array UpdateVoters() {
+ local int i;
+ local UserID nextID;
+ local array currentPlayers;
+ local array potentialVoters;
+
+ if (model == none) {
+ return currentPlayers;
+ }
+ for (i = 0; i < debugVoters.length; i += 1) {
+ debugVoters[i].NewRef();
+ potentialVoters[potentialVoters.length] = debugVoters[i];
+ }
+ currentPlayers = _.players.GetAll();
+ for (i = 0; i < currentPlayers.length; i += 1) {
+ nextID = currentPlayers[i].GetUserID();
+ potentialVoters[potentialVoters.length] = nextID;
+ }
+ model.UpdatePotentialVoters(potentialVoters);
+ _.memory.FreeMany(potentialVoters);
+ return currentPlayers;
+}
+
+/// Prints given voting outcome message in console and publishes it as a notification.
+private final function AnnounceOutcome(BaseText outcomeMessage) {
+ local int i;
+ local MutableText summaryLine;
+ local array currentPlayers;
+
+ if (model == none) {
+ return;
+ }
+ summaryTemplate.Reset();
+ summaryTemplate.ArgInt(model.GetVotesFor());
+ summaryTemplate.ArgInt(model.GetVotesAgainst());
+ summaryLine = summaryTemplate.CollectFormattedM();
+ currentPlayers = _.players.GetAll();
+ for (i = 0; i < currentPlayers.length; i += 1) {
+ currentPlayers[i].BorrowConsole().WriteLine(outcomeMessage);
+ currentPlayers[i].BorrowConsole().WriteLine(summaryLine);
+ currentPlayers[i].Notify(outcomeMessage, summaryLine);
+ }
+ _.memory.FreeMany(currentPlayers);
+ _.memory.Free(summaryLine);
+}
+
+defaultproperties {
+ preferredName = "test"
+ infoLine = "Voting for test"
+ votingStartedLine = "Test voting has started"
+ votingSucceededLine = "{$TextPositive Test voting passed!}"
+ votingFailedLine = "{$TextNegative Test voting has failed...}"
+ playerVotedLine = "Player {$TextSubtle %%player_name%%} has voted %%vote_type%% passing test voting"
+ votesDistributionLine = "Vote tally: {$TextPositive %1} vs {$TextNegative %2}"
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Voting/VotingModel.uc b/sources/BaseAPI/API/Commands/Voting/VotingModel.uc
new file mode 100644
index 0000000..4af8f00
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Voting/VotingModel.uc
@@ -0,0 +1,447 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class VotingModel extends AcediaObject;
+
+//! 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.
+//!
+//! # Usage
+//!
+//! 1. Allocate an instance of the [`VotingModel`] class.
+//! 2. Call [`Initialize()`] to set the 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.
+//! 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
+};
+
+/// Current state of voting for this model.
+enum VotingModelStatus {
+ /// Voting hasn't even started, waiting for [`Initialize()`] call
+ VPM_Uninitialized,
+ /// Voting is currently in progress
+ VPM_InProgress,
+ /// 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
+};
+
+/// A result of user trying to make a vote
+enum VotingResult {
+ /// Vote accepted
+ VFR_Success,
+ /// Voting is not allowed for this particular user
+ VFR_NotAllowed,
+ /// User already made a vote and changing votes isn't allowed
+ VFR_CannotChangeVote,
+ /// User has already voted the same way
+ VFR_AlreadyVoted,
+ /// Voting has already ended and doesn't accept new votes
+ VFR_VotingEnded
+};
+
+/// Checks how given user has voted
+enum PlayerVoteStatus {
+ /// User hasn't voted yet
+ PVS_NoVote,
+ /// User voted for the change
+ PVS_VoteFor,
+ /// User voted against the change
+ PVS_VoteAgainst
+};
+
+var private VotingModelStatus status;
+
+var private bool policyCanLeave, policyCanChangeVote;
+
+var private array votesFor, votesAgainst;
+/// Votes of people that voted before, but then were forbidden to vote
+/// (either because they have left or simply lost the right to vote)
+var private array storedVotesFor, storedVotesAgainst;
+/// List of users currently allowed to vote
+var private array allowedVoters;
+
+protected function Constructor() {
+ status = VPM_Uninitialized;
+ policyCanLeave = false;
+ policyCanChangeVote = false;
+}
+
+protected function Finalizer() {
+ _.memory.FreeMany(allowedVoters);
+ _.memory.FreeMany(votesFor);
+ _.memory.FreeMany(votesAgainst);
+ _.memory.FreeMany(storedVotesFor);
+ _.memory.FreeMany(storedVotesAgainst);
+ allowedVoters.length = 0;
+ votesFor.length = 0;
+ votesAgainst.length = 0;
+ storedVotesFor.length = 0;
+ storedVotesAgainst.length = 0;
+}
+
+/// 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);
+ }
+ 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.
+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.
+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.
+public final function UpdatePotentialVoters(array potentialVoters) {
+ local int i;
+
+ _.memory.FreeMany(allowedVoters);
+ allowedVoters.length = 0;
+ for (i = 0; i < potentialVoters.length; i += 1) {
+ potentialVoters[i].NewRef();
+ allowedVoters[i] = potentialVoters[i];
+ }
+ RestoreStoredVoters(potentialVoters);
+ FilterCurrentVoters(potentialVoters);
+ RecountVotes();
+}
+
+/// 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.
+public final function VotingResult CastVote(UserID voter, bool voteForSuccess) {
+ local bool votesSameWay;
+ local PlayerVoteStatus currentVote;
+
+ if (status != VPM_InProgress) {
+ return VFR_VotingEnded;
+ }
+ if (!IsVotingAllowedFor(voter)) {
+ return VFR_NotAllowed;
+ }
+ currentVote = HasVoted(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) {
+ votesFor[votesFor.length] = voter;
+ } else {
+ votesAgainst[votesAgainst.length] = voter;
+ }
+ RecountVotes();
+ return VFR_Success;
+}
+
+/// 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.
+///
+/// Returns true if the user is allowed to vote, false otherwise.
+public final function bool IsVotingAllowedFor(UserID voter) {
+ local int i;
+
+ if (voter == none) {
+ return false;
+ }
+ for (i = 0; i < allowedVoters.length; i += 1) {
+ if (voter.IsEqual(allowedVoters[i])) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/// 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`].
+public final function PlayerVoteStatus GetVote(UserID voter) {
+ local int i;
+
+ if (voter == none) {
+ return PVS_NoVote;
+ }
+ for (i = 0; i < votesFor.length; i += 1) {
+ if (voter.IsEqual(votesFor[i])) {
+ return PVS_VoteFor;
+ }
+ }
+ for (i = 0; i < votesAgainst.length; i += 1) {
+ if (voter.IsEqual(votesAgainst[i])) {
+ 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;
+
+ if (voter == none) {
+ return PVS_NoVote;
+ }
+ for (i = 0; i < votesFor.length; i += 1) {
+ if (voter.IsEqual(votesFor[i])) {
+ return PVS_VoteFor;
+ }
+ }
+ for (i = 0; i < votesAgainst.length; i += 1) {
+ if (voter.IsEqual(votesAgainst[i])) {
+ return PVS_VoteAgainst;
+ }
+ }
+ return PVS_NoVote;
+}
+
+private final function RecountVotes() {
+ local bool canOverturn, everyoneVoted;
+ 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) {
+ status = VPM_Success;
+ } else if (totalVotesFor < totalVotesAgainst) {
+ status = VPM_Failure;
+ } else {
+ status = VPM_Draw;
+ }
+ }
+}
+
+private final function EraseVote(UserID voter) {
+ local int i;
+
+ if (voter == none) {
+ return;
+ }
+ while (i < votesFor.length) {
+ if (voter.IsEqual(votesFor[i])) {
+ _.memory.Free(votesFor[i]);
+ votesFor.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+ i = 0;
+ while (i < votesAgainst.length) {
+ if (voter.IsEqual(votesAgainst[i])) {
+ _.memory.Free(votesAgainst[i]);
+ votesAgainst.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+private final function RestoreStoredVoters(array potentialVoters) {
+ local int i, j;
+ local bool isPotentialVoter;
+
+ while (i < storedVotesFor.length) {
+ isPotentialVoter = false;
+ for (j = 0; j < potentialVoters.length; j += 1) {
+ if (storedVotesFor[i].IsEqual(potentialVoters[j])) {
+ isPotentialVoter = true;
+ break;
+ }
+ }
+ if (isPotentialVoter) {
+ votesFor[votesFor.length] = storedVotesFor[i];
+ storedVotesFor.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+ i = 0;
+ while (i < storedVotesAgainst.length) {
+ isPotentialVoter = false;
+ for (j = 0; j < potentialVoters.length; j += 1) {
+ if (storedVotesAgainst[i].IsEqual(potentialVoters[j])) {
+ isPotentialVoter = true;
+ break;
+ }
+ }
+ if (isPotentialVoter) {
+ votesAgainst[votesAgainst.length] = storedVotesAgainst[i];
+ storedVotesAgainst.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+private final function FilterCurrentVoters(array potentialVoters) {
+ local int i, j;
+ local bool isPotentialVoter;
+
+ while (i < votesFor.length) {
+ isPotentialVoter = false;
+ for (j = 0; j < potentialVoters.length; j += 1) {
+ if (votesFor[i].IsEqual(potentialVoters[j])) {
+ isPotentialVoter = true;
+ break;
+ }
+ }
+ if (isPotentialVoter) {
+ i += 1;
+ } else {
+ storedVotesFor[storedVotesFor.length] = votesFor[i];
+ votesFor.Remove(i, 1);
+ }
+ }
+ i = 0;
+ while (i < votesAgainst.length) {
+ isPotentialVoter = false;
+ for (j = 0; j < potentialVoters.length; j += 1) {
+ if (votesAgainst[i].IsEqual(potentialVoters[j])) {
+ isPotentialVoter = true;
+ break;
+ }
+ }
+ if (isPotentialVoter) {
+ i += 1;
+ } else {
+ storedVotesAgainst[storedVotesAgainst.length] = votesAgainst[i];
+ votesAgainst.Remove(i, 1);
+ }
+ }
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Voting/VotingSettings.uc b/sources/BaseAPI/API/Commands/Voting/VotingSettings.uc
new file mode 100644
index 0000000..6467c52
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/Voting/VotingSettings.uc
@@ -0,0 +1,75 @@
+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 allowedToSeeVotesGroup;
+/// Specifies which group(s) of players are allowed to vote.
+var public config array 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"
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Math/Tests/TEST_BigInt.uc b/sources/BaseAPI/API/Math/Tests/TEST_BigInt.uc
index d327b26..9847965 100644
--- a/sources/BaseAPI/API/Math/Tests/TEST_BigInt.uc
+++ b/sources/BaseAPI/API/Math/Tests/TEST_BigInt.uc
@@ -99,7 +99,6 @@ protected static function SubTest_AddingSameSignValues() {
main = __().math.MakeBigInt_S("927641962323462271784269213864");
addition = __().math.MakeBigInt_S("16324234842947239847239239");
main.Add(addition);
- Log("UMBRA" @ main.ToString());
TEST_ExpectTrue(main.ToString() == "927658286558305219024116453103");
main = __().math.MakeBigInt_S("16324234842947239847239239");
addition = __().math.MakeBigInt_S("927641962323462271784269213864");
diff --git a/sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc b/sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc
index f8d89a7..2983356 100644
--- a/sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc
+++ b/sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc
@@ -39,6 +39,8 @@ var private array enabledFeaturesLifeVersions;
var private string manifestSuffix;
+var private const config bool debugMode;
+
var private LoggerAPI.Definition infoRegisteringPackage, infoAlreadyRegistered;
var private LoggerAPI.Definition errNotRegistered, errFeatureAlreadyEnabled;
var private LoggerAPI.Definition warnFeatureAlreadyEnabled;
@@ -214,6 +216,15 @@ private final function ReadManifest(class<_manifest> manifestClass) {
}
}
+/// Returns `true` iff AcediaCore is running in the debug mode.
+///
+/// AcediaCore's debug mode allows features to enable functionality that is only useful during
+/// development.
+/// Whether AcediaCore is running in a debug mode is decided at launch and cannot be changed.
+public final function bool IsDebugging() {
+ return debugMode;
+}
+
/// Returns all packages registered in the caller [`AcediaEnvironment`].
public final function array< class<_manifest> > GetAvailablePackages() {
return availablePackages;
@@ -390,6 +401,7 @@ public final function DisableAllFeatures() {
defaultproperties
{
manifestSuffix = ".Manifest"
+ debugMode = true
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.")
diff --git a/sources/BaseAPI/Global.uc b/sources/BaseAPI/Global.uc
index e87cb2f..f53553f 100644
--- a/sources/BaseAPI/Global.uc
+++ b/sources/BaseAPI/Global.uc
@@ -50,6 +50,7 @@ var public UserAPI users;
var public PlayersAPI players;
var public JsonAPI json;
var public SchedulerAPI scheduler;
+var public CommandAPI commands;
var public AvariceAPI avarice;
var public AcediaEnvironment environment;
@@ -92,6 +93,7 @@ protected function Initialize() {
players = PlayersAPI(memory.Allocate(class'PlayersAPI'));
scheduler = SchedulerAPI(memory.Allocate(class'SchedulerAPI'));
avarice = AvariceAPI(memory.Allocate(class'AvariceAPI'));
+ commands = CommandAPI(memory.Allocate(class'CommandAPI'));
environment = AcediaEnvironment(memory.Allocate(class'AcediaEnvironment'));
}
@@ -112,6 +114,7 @@ public function DropCoreAPI() {
players = none;
json = none;
scheduler = none;
+ commands = none;
avarice = none;
default.myself = none;
}
diff --git a/sources/Color/ColorAPI.uc b/sources/Color/ColorAPI.uc
index 67b4eed..64dec7f 100644
--- a/sources/Color/ColorAPI.uc
+++ b/sources/Color/ColorAPI.uc
@@ -1070,13 +1070,13 @@ public final function bool ResolveShortTagColor(
}
return false;
}
-
+//LightGray=(R=211,G=211,B=211,A=255)
defaultproperties
{
TextDefault=(R=255,G=255,B=255,A=255)
TextHeader=(R=128,G=0,B=128,A=255)
TextSubHeader=(R=147,G=112,B=219,A=255)
- TextSubtle=(R=128,G=128,B=128,A=255)
+ TextSubtle=(R=211,G=211,B=211,A=255)
TextEmphasis=(R=0,G=128,B=255,A=255)
TextPositive=(R=60,G=220,B=20,A=255)
TextNeutral=(R=255,G=255,B=0,A=255)
diff --git a/sources/Features/Feature.uc b/sources/Features/Feature.uc
index 287522f..0de09a2 100644
--- a/sources/Features/Feature.uc
+++ b/sources/Features/Feature.uc
@@ -170,7 +170,6 @@ public static final function LoadConfigs()
/**
* Changes config for the caller `Feature` class.
*
- * This method should only be called when caller `Feature` is enabled.
* To set initial config on this `Feature`'s start - specify it as a parameter
* to `EnableMe()` method.
*
@@ -179,20 +178,17 @@ public static final function LoadConfigs()
*/
private final function ApplyConfig(BaseText newConfigName)
{
- local Text configNameCopy;
+ local Text configNameCopy;
local FeatureConfig newConfig;
- newConfig =
- FeatureConfig(configClass.static.GetConfigInstance(newConfigName));
- if (newConfig == none)
- {
+
+ newConfig = FeatureConfig(configClass.static.GetConfigInstance(newConfigName));
+ if (newConfig == none) {
_.logger.Auto(errorBadConfigData).ArgClass(class);
// Fallback to "default" config
configNameCopy = _.text.FromString(defaultConfigName);
configClass.static.NewConfig(configNameCopy);
- newConfig =
- FeatureConfig(configClass.static.GetConfigInstance(configNameCopy));
- }
- else {
+ newConfig = FeatureConfig(configClass.static.GetConfigInstance(configNameCopy));
+ } else {
configNameCopy = newConfigName.Copy();
}
SwapConfig(newConfig);
@@ -289,12 +285,8 @@ public static final function bool IsEnabled()
public static final function Feature EnableMe(BaseText configName)
{
local Feature myInstance;
- myInstance = GetEnabledInstance();
- if (myInstance != none)
- {
- myInstance.ApplyConfig(configName);
- return myInstance;
- }
+
+ DisableMe();
myInstance = Feature(__().memory.Allocate(default.class));
__().environment.EnableFeature(myInstance, configName);
return myInstance;
@@ -336,8 +328,8 @@ protected function OnEnabled(){}
protected function OnDisabled(){}
/**
- * Will be called whenever caller `Feature` class must change it's config
- * parameters. This can be done both when the `Feature` is enabled or disabled.
+ * Will be called whenever caller `Feature` class must change it's config parameters.
+ * It is guaranteed that `Feature` will be disabled during this call.
*
* @param newConfigData New config that caller `Feature`'s class must use.
* We pass `FeatureConfig` value for performance and simplicity reasons,
diff --git a/sources/LevelAPI/Features/Commands/Command.uc b/sources/LevelAPI/Features/Commands/Command.uc
deleted file mode 100644
index d3f67d9..0000000
--- a/sources/LevelAPI/Features/Commands/Command.uc
+++ /dev/null
@@ -1,697 +0,0 @@
-/**
- * This class is meant to represent a command type: to create new command
- * one should extend it, then simply define required sub-commands/options and
- * parameters in `BuildData()` and overload `Executed()` / `ExecutedFor()`
- * to perform required actions when command is executed by a player.
- * `Executed()` is called first, whenever command is executed and
- * `ExecuteFor()` is called only for targeted commands, once for each
- * targeted player.
- * Copyright 2021-2022 Anton Tarasenko
- *------------------------------------------------------------------------------
- * This file is part of Acedia.
- *
- * Acedia is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License, or
- * (at your option) any later version.
- *
- * Acedia is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Acedia. If not, see .
- */
-class Command extends AcediaObject
- dependson(BaseText);
-
-/**
- * # `Command`
- *
- * Command class provides an automated way to add a command to a server through
- * AcediaCore's features. It takes care of:
- *
- * 1. Verifying that player has passed correct (expected parameters);
- * 2. Parsing these parameters into usable values (both standard, built-in
- * types like `bool`, `int`, `float`, etc. and more advanced types such
- * as players lists and JSON values);
- * 3. Allowing you to easily specify a set of players you are targeting by
- * supporting several ways to refer to them, such as *by name*, *by id*
- * and *by selector* (@ and @self refer to caller player, @all refers
- * to all players).
- * 4. It can be registered inside AcediaCore's commands feature and be
- * automatically called through the unified system that supports *chat*
- * and *mutate* inputs (as well as allowing you to hook in any other
- * input source);
- * 5. Will also automatically provide a help page through built-in "help"
- * command;
- * 6. Subcommand support - when one command can have several distinct
- * functions, depending on how its called (e.g. "inventory add" vs
- * "inventory remove"). These subcommands have a special treatment in
- * help pages, which makes them more preferable, compared to simply
- * matching first `Text` argument;
- * 7. Add support for "options" - additional flags that can modify commands
- * behavior and behave like usual command options "--force"/"-f".
- * Their short versions can even be combined:
- * "give@ $ebr --ammo --force" can be rewritten as "give@ $ebr -af".
- * And they can have their own parameters: "give@all --list sharp".
- *
- * ## Usage
- *
- * To create a custom command you need to simply:
- *
- * 1. Create a custom command class derived from `Command`;
- * 2. Define `BuildData()` function and use given `CommandDataBuilder` to
- * fill-in data about what parameters your command takes. You can also
- * add optional descriptions that would appear in your command's
- * help page.
- * 3. Overload `Executed()` or `ExecutedFor()` (or both) method and add
- * whatever logic you want to execute once your command was called.
- * All parameters and options will be listed inside passed `CallData`
- * parameter. These methods will only be called if all necessary
- * parameters were correctly specified.
- *
- * ## 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).
- * 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`).
- */
-
-/**
- * Possible errors that can arise when parsing command parameters from
- * user input
- */
-enum ErrorType
-{
- // No error
- CET_None,
- // 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)
- CET_NoSubCommands,
- // 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
- CET_NoRequiredParam,
- CET_NoRequiredParamForOption,
- // Unknown option key was specified
- CET_UnknownOption,
- CET_UnknownShortOption,
- // 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
- CET_UnusedCommandParameters,
- // 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)
- CET_IncorrectTargetList,
- CET_EmptyTargetList
-};
-
-/**
- * Structure that contains all the information about how `Command` was called.
- */
-struct CallData
-{
- // Targeted players (if applicable)
- var public array targetPlayers;
- // Specified sub-command and parameters/options
- var public Text subCommandName;
- // 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.
- var public ErrorType parsingError;
- var public Text errorCause;
-};
-
-/**
- * Possible types of parameters.
- */
-enum ParameterType
-{
- // Parses into `BoolBox`
- CPT_Boolean,
- // Parses into `IntBox`
- CPT_Integer,
- // Parses into `FloatBox`
- CPT_Number,
- // Parses into `Text`
- CPT_Text,
- // Special parameter that consumes the rest of the input into `Text`
- CPT_Remainder,
- // Parses into `HashTable`
- CPT_Object,
- // Parses into `ArrayList`
- CPT_Array,
- // Parses into any JSON value
- CPT_JSON,
- // 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.
- */
-enum PreferredBooleanFormat
-{
- PBF_TrueFalse,
- PBF_EnableDisable,
- PBF_OnOff,
- PBF_YesNo
-};
-
-// Defines a singular command parameter
-struct Parameter
-{
- // Display name (for the needs of help page displaying)
- var Text displayName;
- // 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
- var bool allowsList;
- // 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
- 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.
- var Text aliasSourceName;
-};
-
-// Defines a sub-command of a this command (specified as
-// " ").
-// 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`
- var Text name;
- // Can be `none`
- var Text description;
- var array required;
- var array optional;
-};
-
-// Defines command's option (options are specified by "--long" or "-l").
-// Options are independent from sub-commands.
-struct Option
-{
- var BaseText.Character shortName;
- var Text longName;
- var Text description;
- // Option can also have their own parameters
- var array required;
- var array optional;
-};
-
-// 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
- var protected Text group;
- // Short summary of what command does (recommended to
- // keep it to 80 characters)
- var protected Text summary;
- var protected array subCommands;
- var protected array options;
- var protected bool requiresTarget;
-};
-var private Data commandData;
-
-// 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".
- */
-var protected ConsoleWriter publicConsole, othersConsole;
-var protected ConsoleWriter callerConsole, targetConsole;
-
-protected function Constructor()
-{
- local CommandDataBuilder dataBuilder;
- dataBuilder =
- CommandDataBuilder(_.memory.Allocate(class'CommandDataBuilder'));
- BuildData(dataBuilder);
- commandData = dataBuilder.BorrowData();
- dataBuilder.FreeSelf();
- dataBuilder = none;
-}
-
-protected function Finalizer()
-{
- local int i;
- local array subCommands;
- local array options;
-
- DeallocateConsoles();
- _.memory.Free(commandData.name);
- _.memory.Free(commandData.summary);
- subCommands = commandData.subCommands;
- for (i = 0; i < options.length; i += 1)
- {
- _.memory.Free(subCommands[i].name);
- _.memory.Free(subCommands[i].description);
- CleanParameters(subCommands[i].required);
- CleanParameters(subCommands[i].optional);
- subCommands[i].required.length = 0;
- subCommands[i].optional.length = 0;
- }
- commandData.subCommands.length = 0;
- options = commandData.options;
- for (i = 0; i < options.length; i += 1)
- {
- _.memory.Free(options[i].longName);
- _.memory.Free(options[i].description);
- CleanParameters(options[i].required);
- CleanParameters(options[i].optional);
- options[i].required.length = 0;
- options[i].optional.length = 0;
- }
- commandData.options.length = 0;
-}
-
-private final function CleanParameters(array 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);
- }
-}
-
-/**
- * Overload this method to use `builder` to define parameters and options for
- * your command.
- *
- * @param builder Builder that can be used to define your commands parameters
- * and options. Do not deallocate.
- */
-protected function BuildData(CommandDataBuilder builder){}
-
-/**
- * Overload this method to perform required actions when
- * your command is called.
- *
- * @param arguments `struct` filled with parameters that your command
- * has been called with. Guaranteed to not be in error state.
- * @param instigator 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. 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.
- *
- * @param target Player that this command must perform an action on.
- * @param arguments `struct` filled with parameters that your command
- * has been called with. Guaranteed to not be in error state and contain
- * all the required data.
- * @param instigator Player that instigated this call.
- */
-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.
- */
-public final static function Command GetInstance()
-{
- if (default.mainInstance == none) {
- default.mainInstance = Command(__().memory.Allocate(default.class));
- }
- return default.mainInstance;
-}
-
-/**
- * Forces command to process (parse) player's input, producing a structure
- * with parsed data in Acedia's format instead.
- *
- * @see `Execute()` for actually performing command's actions.
- *
- * @param parser Parser that contains command input.
- * @param callerPlayer Player that initiated this command's call,
- * necessary for parsing player list (since it can point at
- * the caller player).
- * @param subCommandName This method can optionally specify sub-command to
- * caller command to use. If this argument's value is `none` - sub-command
- * name will be parsed from the `parser`'s data.
- * @return `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,
- optional BaseText subCommandName)
-{
- local array targetPlayers;
- local CommandParser commandParser;
- local CallData callData;
-
- if (parser == none || !parser.Ok())
- {
- callData.parsingError = CET_BadParser;
- return callData;
- }
- // Parse targets and handle errors that can arise here
- if (commandData.requiresTarget)
- {
- targetPlayers = ParseTargets(parser, callerPlayer);
- if (!parser.Ok())
- {
- callData.parsingError = CET_IncorrectTargetList;
- return callData;
- }
- if (targetPlayers.length <= 0)
- {
- callData.parsingError = CET_EmptyTargetList;
- return callData;
- }
- }
- // Parse parameters themselves
- commandParser = CommandParser(_.memory.Allocate(class'CommandParser'));
- callData = commandParser.ParseWith(
- parser,
- commandData,
- callerPlayer,
- subCommandName);
- callData.targetPlayers = targetPlayers;
- commandParser.FreeSelf();
- 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.
- *
- * @param callData Data about parameters, options, etc. with which
- * caller `Command` is to be executed.
- * @param callerPlayer Player that should be considered responsible for
- * executing this `Command`.
- * @return `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)
-{
- local int i;
- local array targetPlayers;
-
- if (callerPlayer == none) return false;
- if (!callerPlayer.IsExistent()) return false;
-
- // Report or execute
- if (callData.parsingError != CET_None)
- {
- ReportError(callData, callerPlayer);
- return false;
- }
- targetPlayers = callData.targetPlayers;
- publicConsole = _.console.ForAll();
- callerConsole = _.console.For(callerPlayer);
- callerConsole
- .Write(P("Executing command `"))
- .Write(commandData.name)
- .Say(P("`"));
- // `othersConsole` should also exist in time for `Executed()` call
- othersConsole = _.console.ForAll().ButPlayer(callerPlayer);
- Executed(callData, callerPlayer);
- _.memory.Free(othersConsole);
- if (commandData.requiresTarget)
- {
- for (i = 0; i < targetPlayers.length; i += 1)
- {
- targetConsole = _.console.For(targetPlayers[i]);
- othersConsole = _.console
- .ForAll()
- .ButPlayer(callerPlayer)
- .ButPlayer(targetPlayers[i]);
- ExecutedFor(targetPlayers[i], callData, callerPlayer);
- _.memory.Free(othersConsole);
- _.memory.Free(targetConsole);
- }
- }
- othersConsole = none;
- targetConsole = none;
- DeallocateConsoles();
- return true;
-}
-
-private final function DeallocateConsoles()
-{
- if (publicConsole != none && publicConsole.IsAllocated()) {
- _.memory.Free(publicConsole);
- }
- if (callerConsole != none && callerConsole.IsAllocated()) {
- _.memory.Free(callerConsole);
- }
- if (targetConsole != none && targetConsole.IsAllocated()) {
- _.memory.Free(targetConsole);
- }
- if (othersConsole != none && othersConsole.IsAllocated()) {
- _.memory.Free(othersConsole);
- }
- publicConsole = none;
- callerConsole = none;
- targetConsole = none;
- othersConsole = none;
-}
-
-/**
- * Auxiliary method that cleans up all data and deallocates all objects inside
- * provided `callData` structure.
- *
- * @param callData Structure to clean. All stored data will be cleared,
- * meaning that `DeallocateCallData()` method takes ownership of
- * this parameter.
- */
-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
-private final function ReportError(CallData callData, EPlayer callerPlayer)
-{
- local Text errorMessage;
- local ConsoleWriter console;
-
- if (callerPlayer == none) return;
- if (!callerPlayer.IsExistent()) return;
-
- // Setup console color
- console = callerPlayer.BorrowConsole();
- if (callData.parsingError == CET_EmptyTargetList) {
- console.UseColor(_.color.textWarning);
- }
- else {
- console.UseColor(_.color.textFailure);
- }
- // Send message
- errorMessage = PrintErrorMessage(callData);
- console.Say(errorMessage);
- errorMessage.FreeSelf();
- // Restore console color
- console.ResetColor().Flush();
-}
-
-private final function Text PrintErrorMessage(CallData callData)
-{
- local Text result;
- local MutableText builder;
-
- builder = _.text.Empty();
- switch (callData.parsingError)
- {
- case CET_BadParser:
- builder.Append(P("Internal error occurred: invalid parser"));
- break;
- case CET_NoSubCommands:
- builder.Append(P("Ill defined command: no subcommands"));
- break;
- case CET_BadSubCommand:
- builder.Append(P("Ill defined sub-command: "))
- .Append(callData.errorCause);
- break;
- case CET_NoRequiredParam:
- builder.Append(P("Missing required parameter: "))
- .Append(callData.errorCause);
- break;
- case CET_NoRequiredParamForOption:
- builder.Append(P("Missing required parameter for option: "))
- .Append(callData.errorCause);
- break;
- case CET_UnknownOption:
- builder.Append(P("Invalid option specified: "))
- .Append(callData.errorCause);
- break;
- case CET_UnknownShortOption:
- builder.Append(P("Invalid short option specified"));
- break;
- case CET_RepeatedOption:
- builder.Append(P("Option specified several times: "))
- .Append(callData.errorCause);
- break;
- case CET_UnusedCommandParameters:
- builder.Append(P("Part of command could not be parsed: "))
- .Append(callData.errorCause);
- break;
- case CET_MultipleOptionsWithParams:
- builder.Append(P( "Multiple short options in one declarations"
- @ "require parameters: "))
- .Append(callData.errorCause);
- break;
- case CET_IncorrectTargetList:
- builder.Append(P("Target players are incorrectly specified."))
- .Append(callData.errorCause);
- break;
- case CET_EmptyTargetList:
- builder.Append(P("List of target players is empty"))
- .Append(callData.errorCause);
- break;
- default:
- }
- result = builder.Copy();
- builder.FreeSelf();
- return result;
-}
-
-// Auxiliary method for parsing list of targeted players.
-// Assumes given parser is not `none` and not in a failed state.
-// If parsing failed, guaranteed to return an empty array.
-private final function array ParseTargets(
- Parser parser,
- EPlayer callerPlayer)
-{
- local array targetPlayers;
- local PlayersParser targetsParser;
-
- targetsParser = PlayersParser(_.memory.Allocate(class'PlayersParser'));
- targetsParser.SetSelf(callerPlayer);
- targetsParser.ParseWith(parser);
- if (parser.Ok()) {
- targetPlayers = targetsParser.GetPlayers();
- }
- targetsParser.FreeSelf();
- return targetPlayers;
-}
-
-/**
- * Returns name (in lower case) of the caller command class.
- *
- * @return Name (in lower case) of the caller command class.
- * Guaranteed to be not `none`.
- */
-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.
- *
- * @return Group name (in lower case) of the caller command class.
- * Guaranteed to be not `none`.
- */
-public final function Text GetGroupName()
-{
- if (commandData.group == none) {
- return P("").Copy();
- }
- return commandData.group.LowerCopy();
-}
-
-/**
- * Returns `Command.Data` struct that describes caller `Command`.
- *
- * @return `Command.Data` 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
-{
-}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/CommandDataBuilder.uc b/sources/LevelAPI/Features/Commands/CommandDataBuilder.uc
deleted file mode 100644
index c441157..0000000
--- a/sources/LevelAPI/Features/Commands/CommandDataBuilder.uc
+++ /dev/null
@@ -1,1148 +0,0 @@
-/**
- * Utility class that provides developers with a simple interface to
- * prepare data that describes command's parameters and options.
- * Copyright 2021-2022 Anton Tarasenko
- *------------------------------------------------------------------------------
- * This file is part of Acedia.
- *
- * Acedia is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License, or
- * (at your option) any later version.
- *
- * Acedia is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Acedia. If not, see .
- */
-class CommandDataBuilder extends AcediaObject
- dependson(Command);
-
-/**
- * # `CommandDataBuilder`
- *
- * This class is made for convenient creation of `Command.Data` using
- * a builder pattern.
- *
- * ## Usage
- *
- * `CommandDataBuilder` should be able to fill information about:
- * subcommands/options and their parameters.
- * As far as user is concerned, the process of filling both should be
- * identical. Overall, intended flow for creating a new sub-command or option
- * is to select either, fill it with data with public methods `Param...()` into
- * "selected data" and then copy it into "prepared data"
- * (through a `RecordSelection()` method below).
- * For examples see `BuildData()` methods from `ACommandHelp` or any of
- * the commands from "Futility" package.
- *
- * ## Implementation
- *
- * We will store all defined data in two ways:
- *
- * 1. Selected data: data about parameters for subcommand/option that is
- * currently being filled;
- * 2. Prepared data: data that was already filled as "selected data" then
- * stored in these records. Whenever we want to switch to filling
- * another subcommand/option or return already prepared data we must
- * dump "selected data" into "prepared data" first and then return
- * the latter.
- *
- * Builder object is automatically created when new `Command` instance is
- * allocated and, therefore, doesn't normally need to be allocated by hand.
- */
-
-// "Prepared data"
-var private Text commandName, commandGroup;
-var private Text commandSummary;
-var private array subcommands;
-var private array options;
-var private bool requiresTarget;
-// Auxiliary arrays signifying that we've started adding optional
-// parameters into appropriate `subcommands` and `options`.
-// All optional parameters must follow strictly after required parameters
-// and so, after user have started adding optional parameters to
-// subcommand/option, we prevent them from adding required ones
-// (to that particular command/option).
-var private array subcommandsIsOptional;
-var private array optionsIsOptional;
-
-// "Selected data"
-// `false` means we have selected sub-command, `true` - option
-var private bool selectedItemIsOption;
-// `name` for sub-commands, `longName` for options
-var private Text selectedItemName;
-// Description of selected sub-command/option
-var private Text selectedDescription;
-// Are we filling optional parameters (`true`)? Or required ones (`false`)?
-var private bool selectionIsOptional;
-// Array of parameters we are currently filling (either required or optional)
-var private array selectedParameterArray;
-
-var LoggerAPI.Definition errLongNameTooShort, errShortNameTooLong;
-var LoggerAPI.Definition warnSameLongName, warnSameShortName;
-
-protected function Constructor()
-{
- // Fill empty subcommand (no special key word) by default
- SelectSubCommand(P(""));
-}
-
-protected function Finalizer()
-{
- subcommands.length = 0;
- subcommandsIsOptional.length = 0;
- options.length = 0;
- optionsIsOptional.length = 0;
- selectedParameterArray.length = 0;
- commandName = none;
- commandGroup = none;
- commandSummary = none;
- selectedItemName = none;
- selectedDescription = none;
- requiresTarget = false;
- selectedItemIsOption = false;
- selectionIsOptional = false;
-}
-
-// Find index of sub-command with a given name `name` in `subcommands`.
-// `-1` if there's not sub-command with such name.
-// Case-sensitive.
-private final function int FindSubCommandIndex(BaseText name)
-{
- local int i;
-
- if (name == none) {
- return -1;
- }
- for (i = 0; i < subcommands.length; i += 1)
- {
- if (name.Compare(subcommands[i].name)) {
- return i;
- }
- }
- return -1;
-}
-
-// Find index of option with a given name `name` in `options`.
-// `-1` if there's not sub-command with such name.
-// Case-sensitive.
-private final function int FindOptionIndex(BaseText longName)
-{
- local int i;
-
- if (longName == none) {
- return -1;
- }
- for (i = 0; i < options.length; i += 1)
- {
- if (longName.Compare(options[i].longName)) {
- return i;
- }
- }
- return -1;
-}
-
-// Creates an empty selection record for subcommand or option with
-// name (long name) `name`.
-// Doe not check whether subcommand/option with that name already exists.
-// Copies passed `name`, assumes that it is not `none`.
-private final function MakeEmptySelection(BaseText name, bool selectedOption)
-{
- selectedItemIsOption = selectedOption;
- selectedItemName = name.Copy();
- selectedDescription = none;
- selectedParameterArray.length = 0;
- selectionIsOptional = false;
-}
-
-// Select sub-command with a given name `name` from `subcommands`.
-// If there is no command with specified name `name` in prepared data -
-// creates new record in selection, otherwise copies previously saved data.
-// Automatically saves previously selected data into prepared data.
-// Copies `name` if it has to create new record.
-private final function SelectSubCommand(BaseText name)
-{
- local int subcommandIndex;
-
- if (name == none) return;
- if ( !selectedItemIsOption && selectedItemName != none
- && selectedItemName.Compare(name))
- {
- return;
- }
- RecordSelection();
- subcommandIndex = FindSubCommandIndex(name);
- if (subcommandIndex < 0)
- {
- MakeEmptySelection(name, false);
- return;
- }
- // Load appropriate prepared data, if it exists for
- // sub-command with name `name`
- selectedItemIsOption = false;
- selectedItemName = subcommands[subcommandIndex].name;
- selectedDescription = subcommands[subcommandIndex].description;
- selectionIsOptional = subcommandsIsOptional[subcommandIndex] > 0;
- if (selectionIsOptional) {
- selectedParameterArray = subcommands[subcommandIndex].optional;
- }
- else {
- selectedParameterArray = subcommands[subcommandIndex].required;
- }
-}
-
-// Select option with a given long name `longName` from `options`.
-// If there is no option with specified `longName` in prepared data -
-// creates new record in selection, otherwise copies previously saved data.
-// Automatically saves previously selected data into prepared data.
-// Copies `name` if it has to create new record.
-private final function SelectOption(BaseText longName)
-{
- local int optionIndex;
-
- if (longName == none) return;
- if ( selectedItemIsOption && selectedItemName != none
- && selectedItemName.Compare(longName))
- {
- return;
- }
- RecordSelection();
- optionIndex = FindOptionIndex(longName);
- if (optionIndex < 0)
- {
- MakeEmptySelection(longName, true);
- return;
- }
- // Load appropriate prepared data, if it exists for
- // option with long name `longName`
- selectedItemIsOption = true;
- selectedItemName = options[optionIndex].longName;
- selectedDescription = options[optionIndex].description;
- selectionIsOptional = optionsIsOptional[optionIndex] > 0;
- if (selectionIsOptional) {
- selectedParameterArray = options[optionIndex].optional;
- }
- else {
- selectedParameterArray = options[optionIndex].required;
- }
-}
-
-// Saves currently selected data into prepared data.
-private final function RecordSelection()
-{
- if (selectedItemName == none) {
- return;
- }
- if (selectedItemIsOption) {
- RecordSelectedOption();
- }
- else {
- RecordSelectedSubCommand();
- }
-}
-
-// Saves selected sub-command into prepared records.
-// Assumes that command and not an option is selected.
-private final function RecordSelectedSubCommand()
-{
- local int selectedSubCommandIndex;
- local Command.SubCommand newSubcommand;
-
- if (selectedItemName == none) return;
-
- selectedSubCommandIndex = FindSubCommandIndex(selectedItemName);
- if (selectedSubCommandIndex < 0)
- {
- selectedSubCommandIndex = subcommands.length;
- subcommands[selectedSubCommandIndex] = newSubcommand;
- }
- subcommands[selectedSubCommandIndex].name = selectedItemName;
- subcommands[selectedSubCommandIndex].description = selectedDescription;
- if (selectionIsOptional)
- {
- subcommands[selectedSubCommandIndex].optional = selectedParameterArray;
- subcommandsIsOptional[selectedSubCommandIndex] = 1;
- }
- else
- {
- subcommands[selectedSubCommandIndex].required = selectedParameterArray;
- subcommandsIsOptional[selectedSubCommandIndex] = 0;
- }
-}
-
-// Saves currently selected option into prepared records.
-// Assumes that option and not an command is selected.
-private final function RecordSelectedOption()
-{
- local int selectedOptionIndex;
- local Command.Option newOption;
-
- if (selectedItemName == none) return;
-
- selectedOptionIndex = FindOptionIndex(selectedItemName);
- if (selectedOptionIndex < 0)
- {
- selectedOptionIndex = options.length;
- options[selectedOptionIndex] = newOption;
- }
- options[selectedOptionIndex].longName = selectedItemName;
- options[selectedOptionIndex].description = selectedDescription;
- if (selectionIsOptional)
- {
- options[selectedOptionIndex].optional = selectedParameterArray;
- optionsIsOptional[selectedOptionIndex] = 1;
- }
- else
- {
- options[selectedOptionIndex].required = selectedParameterArray;
- optionsIsOptional[selectedOptionIndex] = 0;
- }
-}
-/**
- * Method to use to start defining a new sub-command.
- *
- * Does two things:
- * 1. Creates new sub-command with a given name (if it's missing);
- * 2. Selects sub-command with name `name` to add parameters to.
- *
- * @param name Name of the sub-command user wants to define,
- * case-sensitive. Variable will be copied.
- * If `none` is passed, this method will do nothing.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder SubCommand(BaseText name)
-{
- SelectSubCommand(name);
- return self;
-}
-
-// Validates names (printing errors in case of failure) for the option.
-// Long name must be at least 2 characters long.
-// Short name must be either:
-// 1. exactly one character long;
-// 2. `none`, which leads to deriving `shortName` from `longName`
-// as a first character.
-// Anything else will result in logging a failure and rejection of
-// the option altogether.
-// Returns `none` if validation failed and chosen short name otherwise
-// (if `shortName` was used for it - it's value will be copied).
-private final function BaseText.Character GetValidShortName(
- BaseText longName,
- BaseText shortName)
-{
- // Validate `longName`
- if (longName == none) {
- return _.text.GetInvalidCharacter();
- }
- if (longName.GetLength() < 2)
- {
- _.logger.Auto(errLongNameTooShort).ArgClass(class).Arg(longName.Copy());
- return _.text.GetInvalidCharacter();
- }
- // Validate `shortName`,
- // deriving if from `longName` if necessary & possible
- if (shortName == none) {
- return longName.GetCharacter(0);
- }
- if (shortName.IsEmpty() || shortName.GetLength() > 1)
- {
- _.logger.Auto(errShortNameTooLong).ArgClass(class).Arg(longName.Copy());
- return _.text.GetInvalidCharacter();
- }
- return shortName.GetCharacter(0);
-}
-
-// Checks that if any option record has a long/short name from a given pair of
-// names (`longName`, `shortName`), then it also has another one.
-//
-// i.e. we cannot have several options with identical names:
-// (--silent, -s) and (--sick, -s).
-private final function bool VerifyNoOptionNamingConflict(
- BaseText longName,
- BaseText.Character shortName)
-{
- local int i;
-
- // To make sure we will search through the up-to-date `options`,
- // record selection into prepared records.
- RecordSelection();
- for (i = 0; i < options.length; i += 1)
- {
- // Is same long name, but different long names?
- if ( !_.text.AreEqual(shortName, options[i].shortName)
- && longName.Compare(options[i].longName))
- {
- _.logger.Auto(warnSameLongName)
- .ArgClass(class)
- .Arg(longName.Copy());
- return true;
- }
- // Is same short name, but different short ones?
- if ( _.text.AreEqual(shortName, options[i].shortName)
- && !longName.Compare(options[i].longName))
- {
- _.logger.Auto(warnSameLongName)
- .ArgClass(class)
- .Arg(_.text.FromCharacter(shortName));
- return true;
- }
- }
- return false;
-}
-
-/**
- * Method to use to start defining a new option.
- *
- * Does three things:
- * 1. Checks if some of the recorded options are in conflict with given
- * `longName` and `shortName` (already using one and only one of them).
- * 2. Creates new option with a long and short names
- * (if such option is missing);
- * 3. Selects option with a long name `longName` to add parameters to.
- *
- * @param longName Long name of the option, case-sensitive
- * (for using an option in form "--...").
- * Must be at least two characters long. If passed value is either `none`
- * or too short, method will log an error and omits this option.
- * @param shortName Short name of the option, case-sensitive
- * (for using an option in form "-...").
- * Must be exactly one character. If `none` value is passed
- * (or the argument altogether omitted) - uses first character of
- * the `longName`.
- * If `shortName` is not `none` and is not exactly 1 character long -
- * logs an error and omits this option.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder Option(
- BaseText longName,
- optional BaseText shortName)
-{
- local int optionIndex;
- local BaseText.Character shortNameAsCharacter;
-
- // Unlike for `SubCommand()`, we need to ensure that option naming is
- // correct and does not conflict with existing options
- // (user might attempt to add two options with same long names and
- // different short ones).
- shortNameAsCharacter = GetValidShortName(longName, shortName);
- if ( !_.text.IsValidCharacter(shortNameAsCharacter)
- || VerifyNoOptionNamingConflict(longName, shortNameAsCharacter))
- {
- // ^ `GetValidShortName()` and `VerifyNoOptionNamingConflict()`
- // are responsible for logging warnings/errors
- return self;
- }
- SelectOption(longName);
- // Set short name for new options
- optionIndex = FindOptionIndex(longName);
- if (optionIndex < 0)
- {
- // We can only be here if option was created for the first time
- RecordSelection();
- // So now it cannot fail
- optionIndex = FindOptionIndex(longName);
- options[optionIndex].shortName = shortNameAsCharacter;
- }
- return self;
-}
-
-/**
- * Adds description to the selected sub-command / option.
- *
- * Previous description is discarded (default description is empty).
- *
- * Does nothing if nothing is selected.
- *
- * @param description New description of selected sub-command / option.
- * Variable will be copied.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder Describe(BaseText description)
-{
- if (selectedDescription == description) {
- return self;
- }
- _.memory.Free(selectedDescription);
- if (description != none) {
- selectedDescription = description.Copy();
- }
- return self;
-}
-
-/**
- * Sets new name of `Command.Data` under construction. This is a name that will
- * be used unless Acedia is configured to do otherwise.
- *
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder Name(BaseText newName)
-{
- if (newName != none && newName == commandName) {
- return self;
- }
- _.memory.Free(commandName);
- if (newName != none) {
- commandName = newName.Copy();
- }
- else {
- commandName = none;
- }
- return self;
-}
-
-/**
- * Sets new group of `Command.Data` under construction. Group name is meant to
- * be shared among several commands, allowing user to filter or fetch commands
- * of a certain group.
- *
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder Group(BaseText newName)
-{
- if (newName != none && newName == commandGroup) {
- return self;
- }
- _.memory.Free(commandGroup);
- if (newName != none) {
- commandGroup = newName.Copy();
- }
- else {
- commandGroup = none;
- }
- return self;
-}
-
-/**
- * Sets new summary of `Command.Data` under construction. Summary gives a short
- * description of the command on the whole, to be displayed in a command list.
- *
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder Summary(BaseText newSummary)
-{
- if (newSummary != none && newSummary == commandSummary) {
- return self;
- }
- _.memory.Free(commandSummary);
- if (newSummary != none) {
- commandSummary = newSummary.Copy();
- }
- else {
- commandSummary = none;
- }
- return self;
-}
-
-/**
- * Makes caller builder to mark `Command.Data` under construction to require
- * a player target.
- *
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder RequireTarget()
-{
- requiresTarget = true;
- return self;
-}
-
-/**
- * Any parameters added to currently selected sub-command / option after
- * calling this method will be marked as optional.
- *
- * Further calls when the same sub-command / option is selected do nothing.
- *
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder OptionalParams()
-{
- if (selectionIsOptional) {
- return self;
- }
- // Record all required parameters first, otherwise there would be no way
- // to distinguish between them and optional parameters
- RecordSelection();
- selectionIsOptional = true;
- selectedParameterArray.length = 0;
- return self;
-}
-
-/**
- * Returns data that has been constructed so far by
- * the caller `CommandDataBuilder`.
- *
- * Does not reset progress.
- *
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function Command.Data BorrowData()
-{
- local Command.Data newData;
-
- RecordSelection();
- newData.name = commandName;
- newData.group = commandGroup;
- newData.summary = commandSummary;
- newData.subcommands = subcommands;
- newData.options = options;
- newData.requiresTarget = requiresTarget;
- return newData;
-}
-
-// Adds new parameter to selected sub-command / option
-private final function PushParameter(Command.Parameter newParameter)
-{
- selectedParameterArray[selectedParameterArray.length] = newParameter;
-}
-
-// Fills `Command.ParameterType` struct with given values
-// (except boolean format). Assumes `displayName != none`.
-private final function Command.Parameter NewParameter(
- BaseText displayName,
- Command.ParameterType parameterType,
- bool isListParameter,
- optional BaseText variableName)
-{
- local Command.Parameter newParameter;
-
- newParameter.displayName = displayName.Copy();
- newParameter.type = parameterType;
- newParameter.allowsList = isListParameter;
- if (variableName != none) {
- newParameter.variableName = variableName.Copy();
- }
- else {
- newParameter.variableName = displayName.Copy();
- }
- return newParameter;
-}
-
-/**
- * Adds new boolean parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- *
- * @param format Preferred format of boolean values.
- * Command parser will still accept boolean values in any form,
- * this setting only affects how parameter will be displayed in
- * generated help.
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamBoolean(
- BaseText name,
- optional Command.PreferredBooleanFormat format,
- optional BaseText variableName)
-{
- local Command.Parameter newParam;
-
- if (name == none) {
- return self;
- }
- newParam = NewParameter(name, CPT_Boolean, false, variableName);
- newParam.booleanFormat = format;
- PushParameter(newParam);
- return self;
-}
-
-/**
- * Adds new boolean list parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param format Preferred format of boolean values.
- * Command parser will still accept boolean values in any form,
- * this setting only affects how parameter will be displayed in
- * generated help.
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamBooleanList(
- BaseText name,
- optional Command.PreferredBooleanFormat format,
- optional BaseText variableName)
-{
- local Command.Parameter newParam;
-
- if (name == none) {
- return self;
- }
- newParam = NewParameter(name, CPT_Boolean, true, variableName);
- newParam.booleanFormat = format;
- PushParameter(newParam);
- return self;
-}
-
-/**
- * Adds new integer parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamInteger(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Integer, false, variableName));
- return self;
-}
-
-/**
- * Adds new integer list parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamIntegerList(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Integer, true, variableName));
- return self;
-}
-
-/**
- * Adds new numeric parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamNumber(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Number, false, variableName));
- return self;
-}
-
-/**
- * Adds new numeric list parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamNumberList(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Number, true, variableName));
- return self;
-}
-
-/**
- * Adds new text parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @param aliasSourceName Name of the alias source that must be used to
- * auto-resolve this parameter's value. `none` means that parameter will be
- * recorded as-is, any other value (either "weapon", "color", "feature",
- * "entity" or some kind of custom alias source name) will make values
- * prefixed with "$" to be resolved as aliases.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamText(
- BaseText name,
- optional BaseText variableName,
- optional BaseText aliasSourceName)
-{
- local Command.Parameter newParameterValue;
-
- if (name == none) {
- return self;
- }
- newParameterValue = NewParameter(name, CPT_Text, false, variableName);
- if (aliasSourceName != none) {
- newParameterValue.aliasSourceName = aliasSourceName.Copy();
- }
- PushParameter(newParameterValue);
- return self;
-}
-
-/**
- * Adds new text list parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @param aliasSource Name of the alias source that must be used to
- * auto-resolve this parameter's value. `none` means that parameter will be
- * recorded as-is, any other value (either "weapon", "color", "feature",
- * "entity" or some kind of custom alias source name) will make values
- * prefixed with "$" to be resolved as aliases.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamTextList(
- BaseText name,
- optional BaseText variableName,
- optional BaseText aliasSourceName)
-{
- local Command.Parameter newParameterValue;
-
- if (name == none) {
- return self;
- }
- newParameterValue = NewParameter(name, CPT_Text, true, variableName);
- if (aliasSourceName != none) {
- newParameterValue.aliasSourceName = aliasSourceName.Copy();
- }
- PushParameter(newParameterValue);
- return self;
-}
-
-/**
- * Adds new remainder parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamRemainder(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Remainder, false, variableName));
- return self;
-}
-
-/**
- * Adds new object parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamObject(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Object, false, variableName));
- return self;
-}
-
-/**
- * Adds new parameter for list of objects (required or optional depends on
- * whether `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamObjectList(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Object, true, variableName));
- return self;
-}
-
-/**
- * Adds new array parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamArray(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Array, false, variableName));
- return self;
-}
-
-/**
- * Adds new parameter for list of arrays (required or optional depends on
- * whether `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamArrayList(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_Array, true, variableName));
- return self;
-}
-
-/**
- * Adds new JSON value parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamJSON(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_JSON, false, variableName));
- return self;
-}
-
-/**
- * Adds new parameter for list of JSON values (required or optional depends on
- * whether `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamJSONList(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_JSON, true, variableName));
- return self;
-}
-
-/**
- * Adds new players value parameter (required or optional depends on whether
- * `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Players parameter is a parameter that allows one to specify a list of
- * players through special selectors, the same way one does for
- * targeted commands.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamPlayers(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_PLAYERS, false, variableName));
- return self;
-}
-
-/**
- * Adds new parameter for list of players values (required or optional depends
- * on whether `RequireTarget()` call happened) to the currently selected
- * sub-command / option.
- *
- * Players parameter is a parameter that allows one to specify a list of
- * players through special selectors, the same way one does for
- * targeted commands.
- *
- * Only fails if provided `name` is `none`.
- *
- * @param name Name of the parameter, will be copied
- * (as it would appear in the generated help info).
- * @param variableName Name of the variable that will store this
- * parameter's value in `HashTable` after user's command input
- * is parsed. Provided value will be copied.
- * If left `none`, - will coincide with `name` parameter.
- * @return Returns the caller `CommandDataBuilder` to allow for
- * method chaining.
- */
-public final function CommandDataBuilder ParamPlayersList(
- BaseText name,
- optional BaseText variableName)
-{
- if (name == none) {
- return self;
- }
- PushParameter(NewParameter(name, CPT_PLAYERS, true, variableName));
- return self;
-}
-
-defaultproperties
-{
- errLongNameTooShort = (l=LOG_Error,m="Command `%1` is trying to register an option with a name that is way too short (<2 characters). Option will be discarded: %2")
- errShortNameTooLong = (l=LOG_Error,m="Command `%1` is trying to register an option with a short name that doesn't consist of just one character. Option will be discarded: %2")
- warnSameLongName = (l=LOG_Error,m="Command `%1` is trying to register several options with the same long name \"%2\", but different short names. This should not happen, do not expect correct behavior.")
- warnSameShortName = (l=LOG_Error,m="Command `%1` is trying to register several options with the same short name \"%2\", but different long names. This should not have happened, do not expect correct behavior.")
-}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/CommandParser.uc b/sources/LevelAPI/Features/Commands/CommandParser.uc
deleted file mode 100644
index 614fa06..0000000
--- a/sources/LevelAPI/Features/Commands/CommandParser.uc
+++ /dev/null
@@ -1,1017 +0,0 @@
-/**
- * Auxiliary class for parsing user's input into a `Command.CallData` based on
- * a given `Command.Data`. While it's meant to be allocated for
- * a `self.ParseWith()` call and deallocated right after, it can be reused
- * without deallocation.
- * Copyright 2021-2022 Anton Tarasenko
- *------------------------------------------------------------------------------
- * This file is part of Acedia.
- *
- * Acedia is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License, or
- * (at your option) any later version.
- *
- * Acedia is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Acedia. If not, see .
- */
-class CommandParser extends AcediaObject
- dependson(Command);
-
-/**
- * # `CommandParser`
- *
- * 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:
- *
- * * 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.
- *
- * ## Usage
- *
- * 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.
- *
- * ## 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.
- *
- * 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.
- * 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 parameters that can contain several values.
- * So main parsing method looks something like:
- *
- * ParseParameterArrays() {
- * loop ParseParameter() {
- * loop ParseSingleValue()
- * }
- * }
- * `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()` 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.
- * Rest of the methods perform simple auxiliary functions.
- */
-
-// Parser filled with user input.
-var private Parser commandParser;
-// Data for sub-command specified by both command we are parsing
-// and user's input; determined early during parsing.
-var private Command.SubCommand pickedSubCommand;
-// Options available for the command we are parsing.
-var private array availableOptions;
-// Result variable we are filling during the parsing process,
-// should be `none` outside of `self.ParseWith()` method call.
-var private Command.CallData nextResult;
-
-// Describes which parameters we are currently parsing, classifying them
-// as either "necessary" or "extra".
-// E.g. if last require parameter is a list of integers,
-// then after parsing first integer we are:
-// * 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
-// result in failed parsing and "extra" otherwise.
-enum ParsingTarget
-{
- // We are in the process of parsing required parameters, that must all
- // be present.
- // This case does not include parsing last required parameter: it needs
- // to be treated differently to track when we change from "necessary" to
- // "extra" parameters.
- CPT_NecessaryParameter,
- // We are parsing last necessary parameter.
- CPT_LastNecessaryParameter,
- // We are not parsing extra parameters that can be safely omitted.
- CPT_ExtraParameter,
-};
-
-// 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
-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
-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`.
-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.
-var private array usedOptions;
-
-// Literals that can be used as boolean values
-var private array booleanTrueEquivalents;
-var private array booleanFalseEquivalents;
-
-var LoggerAPI.Definition errNoSubCommands;
-
-protected function Finalizer()
-{
- Reset();
-}
-
-// Zero important variables
-private final function Reset()
-{
- local Command.CallData blankCallData;
-
- _.memory.Free(currentPlayersParser);
- currentPlayersParser = none;
- // We didn't create this one and are not meant to free it either
- commandParser = none;
- nextResult = blankCallData;
- currentTarget = CPT_NecessaryParameter;
- currentTargetIsOption = false;
- usedOptions.length = 0;
-}
-
-// Auxiliary method for recording errors
-private final function DeclareError(
- Command.ErrorType type,
- optional BaseText cause)
-{
- nextResult.parsingError = type;
- if (cause != none) {
- nextResult.errorCause = cause.Copy();
- }
- if (commandParser != none) {
- commandParser.Fail();
- }
-}
-
-// 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).
-// 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;
- local Command.SubCommand emptySubCommand;
- local array allSubCommands;
-
- allSubCommands = commandData.subCommands;
- if (allSubcommands.length == 0)
- {
- _.logger.Auto(errNoSubCommands).ArgClass(class);
- pickedSubCommand = emptySubCommand;
- return;
- }
- // Get candidate name
- confirmedState = commandParser.GetCurrentState();
- if (specifiedSubCommand != none) {
- candidateSubCommandName = specifiedSubCommand.MutableCopy();
- }
- else {
- commandParser.Skip().MUntil(candidateSubCommandName,, true);
- }
- // Try matching it to sub commands
- pickedSubCommand = allSubcommands[0];
- if (candidateSubCommandName.IsEmpty())
- {
- candidateSubCommandName.FreeSelf();
- return;
- }
- for (i = 0; i < allSubcommands.length; i += 1)
- {
- if (candidateSubCommandName.Compare(allSubcommands[i].name))
- {
- candidateSubCommandName.FreeSelf();
- pickedSubCommand = allSubcommands[i];
- return;
- }
- }
- // We will only reach here if we did not match any sub commands,
- // 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`.
- *
- * @param parser `Parser`, initialized with user's input that will
- * need to be parsed as a command's call.
- * @param commandData Describes what parameters and options should be
- * expected in user's input. `Text` values from `commandData` can be used
- * inside resulting `Command.CallData`, so deallocating them can
- * invalidate returned value.
- * @param callerPlayer Player that called this command, if applicable.
- * @param specifiedSubCommand Optionally, sub-command can be specified for
- * the `CommandParser` to use. If this argument's value is `none` - it will
- * be parsed from `parser`'s data instead.
- * @return 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,
- optional 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()
-{
- local Text remainder;
-
- if (!commandParser.Ok()) return;
- if (commandParser.Skip().GetRemainingLength() <= 0) return;
-
- remainder = commandParser.GetRemainder();
- DeclareError(CET_UnusedCommandParameters, remainder);
- remainder.FreeSelf();
-}
-
-// Assumes `commandParser` is not `none`.
-// 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(
- array requiredParameters,
- array optionalParameters)
-{
- local HashTable parsedParameters;
-
- if (!commandParser.Ok()) {
- return none;
- }
- parsedParameters = _.collections.EmptyHashTable();
- // Parse parameters
- ParseRequiredParameterArray(parsedParameters, requiredParameters);
- ParseOptionalParameterArray(parsedParameters, optionalParameters);
- // Parse trailing options
- while (TryParsingOptions());
- return parsedParameters;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses given required parameters along with any possible option
-// declarations into given `parsedParameters` `HashTable`.
-private final function ParseRequiredParameterArray(
- HashTable parsedParameters,
- array requiredParameters)
-{
- local int i;
-
- if (!commandParser.Ok()) {
- return;
- }
- currentTarget = CPT_NecessaryParameter;
- while (i < requiredParameters.length)
- {
- if (i == requiredParameters.length - 1) {
- currentTarget = CPT_LastNecessaryParameter;
- }
- // Parse parameters one-by-one, reporting appropriate errors
- if (!ParseParameter(parsedParameters, requiredParameters[i]))
- {
- // Any failure to parse required parameter leads to error
- if (currentTargetIsOption)
- {
- DeclareError( CET_NoRequiredParamForOption,
- targetOption.longName);
- }
- else
- {
- DeclareError( CET_NoRequiredParam,
- requiredParameters[i].displayName);
- }
- return;
- }
- i += 1;
- }
- currentTarget = CPT_ExtraParameter;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses given optional parameters along with any possible option
-// declarations into given `parsedParameters` hash table.
-private final function ParseOptionalParameterArray(
- HashTable parsedParameters,
- array optionalParameters)
-{
- local int i;
-
- if (!commandParser.Ok()) {
- return;
- }
- while (i < optionalParameters.length)
- {
- confirmedState = commandParser.GetCurrentState();
- // Parse parameters one-by-one, reporting appropriate errors
- if (!ParseParameter(parsedParameters, optionalParameters[i]))
- {
- // Propagate errors
- if (nextResult.parsingError != CET_None) {
- return;
- }
- // Failure to parse optional parameter is fine if
- // it is caused by that parameters simply missing
- commandParser.RestoreState(confirmedState);
- break;
- }
- i += 1;
- }
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// 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.
-private final function bool ParseParameter(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local bool parsedEnough;
-
- confirmedState = commandParser.GetCurrentState();
- while (ParseSingleValue(parsedParameters, expectedParameter))
- {
- if (currentTarget == CPT_LastNecessaryParameter) {
- currentTarget = CPT_ExtraParameter;
- }
- parsedEnough = true;
- // We are done if there is either no more input or we only needed
- // to parse a single value
- if (!expectedParameter.allowsList) {
- return true;
- }
- if (commandParser.Skip().HasFinished()) {
- return true;
- }
- confirmedState = commandParser.GetCurrentState();
- }
- // We only succeeded in parsing if we've parsed enough for
- // a given parameter and did not encounter any errors
- if (parsedEnough && nextResult.parsingError == CET_None) {
- commandParser.RestoreState(confirmedState);
- return true;
- }
- // Clean up any values `ParseSingleValue` might have recorded
- parsedParameters.RemoveItem(expectedParameter.variableName);
- return false;
-}
-
-// 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` `HashTable`.
-// Returns `true` if we've successfully parsed a single value without
-// any errors.
-private final function bool ParseSingleValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- // Before parsing any other value we need to check if user has
- // specified any options instead.
- // However this might lead to errors if we are already parsing
- // necessary parameters of another option:
- // we must handle such situation and report an error.
- if (currentTargetIsOption)
- {
- // There is no problem is option's parameter is remainder
- if (expectedParameter.type == CPT_Remainder) {
- return ParseRemainderValue(parsedParameters, expectedParameter);
- }
- if (currentTarget != CPT_ExtraParameter && TryParsingOptions())
- {
- DeclareError(CET_NoRequiredParamForOption, targetOption.longName);
- return false;
- }
- }
- while (TryParsingOptions());
- // First we try `CPT_Remainder` parameter, since it is a special case that
- // consumes all further input
- if (expectedParameter.type == CPT_Remainder) {
- return ParseRemainderValue(parsedParameters, expectedParameter);
- }
- // Propagate errors after parsing options
- if (nextResult.parsingError != CET_None) {
- return false;
- }
- // Try parsing one of the variable types
- if (expectedParameter.type == CPT_Boolean) {
- return ParseBooleanValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_Integer) {
- return ParseIntegerValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_Number) {
- return ParseNumberValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_Text) {
- return ParseTextValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_Remainder) {
- return ParseRemainderValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_Object) {
- return ParseObjectValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_Array) {
- return ParseArrayValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_JSON) {
- return ParseJSONValue(parsedParameters, expectedParameter);
- }
- else if (expectedParameter.type == CPT_Players) {
- return ParsePlayersValue(parsedParameters, expectedParameter);
- }
- return false;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single boolean value into given `parsedParameters`
-// hash table.
-private final function bool ParseBooleanValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local int i;
- local bool isValidBooleanLiteral;
- local bool booleanValue;
- local MutableText parsedLiteral;
-
- commandParser.Skip().MUntil(parsedLiteral,, true);
- if (!commandParser.Ok())
- {
- _.memory.Free(parsedLiteral);
- return false;
- }
- // Try to match parsed literal to any recognizable boolean literals
- for (i = 0; i < booleanTrueEquivalents.length; i += 1)
- {
- if (parsedLiteral.CompareToString( booleanTrueEquivalents[i],
- SCASE_INSENSITIVE))
- {
- isValidBooleanLiteral = true;
- booleanValue = true;
- break;
- }
- }
- for (i = 0; i < booleanFalseEquivalents.length; i += 1)
- {
- if (isValidBooleanLiteral) break;
- if (parsedLiteral.CompareToString( booleanFalseEquivalents[i],
- SCASE_INSENSITIVE))
- {
- isValidBooleanLiteral = true;
- booleanValue = false;
- }
- }
- parsedLiteral.FreeSelf();
- if (!isValidBooleanLiteral) {
- return false;
- }
- RecordParameter(parsedParameters, expectedParameter,
- _.box.bool(booleanValue));
- return true;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single integer value into given `parsedParameters`
-// hash table.
-private final function bool ParseIntegerValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local int integerValue;
-
- commandParser.Skip().MInteger(integerValue);
- if (!commandParser.Ok()) {
- return false;
- }
- RecordParameter(parsedParameters, expectedParameter,
- _.box.int(integerValue));
- return true;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single number (float) value into given `parsedParameters`
-// hash table.
-private final function bool ParseNumberValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local float numberValue;
-
- commandParser.Skip().MNumber(numberValue);
- if (!commandParser.Ok()) {
- return false;
- }
- RecordParameter(parsedParameters, expectedParameter,
- _.box.float(numberValue));
- return true;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single `Text` value into given `parsedParameters`
-// hash table.
-private final function bool ParseTextValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local bool failedParsing;
- local MutableText textValue;
- local Parser.ParserState initialState;
-
- // (needs some work for reading formatting `string`s from `Text` objects)
- initialState = commandParser.Skip().GetCurrentState();
- // Try manually parsing as a string literal first, since then we will
- // allow empty `textValue` as a result
- commandParser.MStringLiteral(textValue);
- failedParsing = !commandParser.Ok();
- // Otherwise - empty values are not allowed
- if (failedParsing)
- {
- _.memory.Free(textValue);
- commandParser.RestoreState(initialState).MString(textValue);
- failedParsing = (!commandParser.Ok() || textValue.IsEmpty());
- }
- if (failedParsing)
- {
- _.memory.Free(textValue);
- commandParser.Fail();
- return false;
- }
- AutoResolveAlias(textValue, expectedParameter.aliasSourceName);
- RecordParameter(parsedParameters, expectedParameter, textValue.IntoText());
- return true;
-}
-
-// Resolves alias with appropriate source, if parameter was specified to be
-// auto-resolved.
-// Resolved values is returned through first out-parameter.
-private final function AutoResolveAlias(
- out MutableText textValue,
- Text aliasSourceName)
-{
- local Text resolvedValue;
-
- if (textValue == none) return;
- if (aliasSourceName == none) return;
- if (!textValue.StartsWithS("$")) return;
-
- if (aliasSourceName.Compare(P("weapon"))) {
- resolvedValue = _.alias.ResolveWeapon(textValue, true);
- }
- else if (aliasSourceName.Compare(P("color"))) {
- resolvedValue = _.alias.ResolveColor(textValue, true);
- }
- else if (aliasSourceName.Compare(P("feature"))) {
- resolvedValue = _.alias.ResolveFeature(textValue, true);
- }
- else if (aliasSourceName.Compare(P("entity"))) {
- resolvedValue = _.alias.ResolveEntity(textValue, true);
- }
- else {
- resolvedValue = _.alias.ResolveCustom(aliasSourceName, textValue, true);
- }
- textValue.FreeSelf();
- textValue = resolvedValue.IntoMutableText();
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single `Text` value into given `parsedParameters`
-// hash table, consuming all remaining contents.
-private final function bool ParseRemainderValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local MutableText value;
-
- commandParser.Skip().MUntil(value);
- if (!commandParser.Ok()) {
- return false;
- }
- RecordParameter(parsedParameters, expectedParameter, value.IntoText());
- return true;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single JSON object into given `parsedParameters`
-// hash table.
-private final function bool ParseObjectValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local HashTable objectValue;
-
- objectValue = _.json.ParseHashTableWith(commandParser);
- if (!commandParser.Ok()) {
- return false;
- }
- RecordParameter(parsedParameters, expectedParameter, objectValue);
- return true;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single JSON array into given `parsedParameters`
-// hash table.
-private final function bool ParseArrayValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local ArrayList arrayValue;
-
- arrayValue = _.json.ParseArrayListWith(commandParser);
- if (!commandParser.Ok()) {
- return false;
- }
- RecordParameter(parsedParameters, expectedParameter, arrayValue);
- return true;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single JSON value into given `parsedParameters`
-// hash table.
-private final function bool ParseJSONValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local AcediaObject jsonValue;
-
- jsonValue = _.json.ParseWith(commandParser);
- if (!commandParser.Ok()) {
- return false;
- }
- RecordParameter(parsedParameters, expectedParameter, jsonValue);
- return true;
-}
-
-// Assumes `commandParser` and `parsedParameters` are not `none`.
-// Parses a single JSON value into given `parsedParameters`
-// hash table.
-private final function bool ParsePlayersValue(
- HashTable parsedParameters,
- Command.Parameter expectedParameter)
-{
- local ArrayList resultPlayerList;
- local array targetPlayers;
-
- currentPlayersParser.ParseWith(commandParser);
- if (commandParser.Ok()) {
- targetPlayers = currentPlayersParser.GetPlayers();
- }
- else {
- return false;
- }
- resultPlayerList = _.collections.NewArrayList(targetPlayers);
- _.memory.FreeMany(targetPlayers);
- RecordParameter(parsedParameters, expectedParameter, resultPlayerList);
- return true;
-}
-// Assumes `parsedParameters` is not `none`.
-// Records `value` for a given `parameter` into a given `parametersArray`.
-// If parameter is not a list type - simply records `value` as value under
-// `parameter.variableName` key.
-// If parameter is a list type - pushed value at the end of an array,
-// recorded at `parameter.variableName` key (creating it if missing).
-// All recorded values are managed by `parametersArray`.
-private final function RecordParameter(
- HashTable parametersArray,
- Command.Parameter parameter,
- /* take */ AcediaObject value)
-{
- local ArrayList parameterVariable;
-
- if (!parameter.allowsList)
- {
- parametersArray.SetItem(parameter.variableName, value);
- _.memory.Free(value);
- return;
- }
- parameterVariable =
- ArrayList(parametersArray.GetItem(parameter.variableName));
- if (parameterVariable == none) {
- parameterVariable = _.collections.EmptyArrayList();
- }
- parameterVariable.AddItem(value);
- _.memory.Free(value);
- parametersArray.SetItem(parameter.variableName, parameterVariable);
- _.memory.Free(parameterVariable);
-}
-
-// Assumes `commandParser` is not `none`.
-// 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.
-private final function bool TryParsingOptions()
-{
- local int temporaryInt;
-
- if (!commandParser.Ok()) return false;
-
- confirmedState = commandParser.GetCurrentState();
- // Long options
- commandParser.Skip().Match(P("--"));
- if (commandParser.Ok()) {
- return ParseLongOption();
- }
- // Filter out negative numbers that start similarly to short options:
- // -3, -5.7, -.9
- commandParser.RestoreState(confirmedState)
- .Skip().Match(P("-")).MUnsignedInteger(temporaryInt, 10, 1);
- if (commandParser.Ok())
- {
- commandParser.RestoreState(confirmedState);
- return false;
- }
- commandParser.RestoreState(confirmedState).Skip().Match(P("-."));
- if (commandParser.Ok())
- {
- commandParser.RestoreState(confirmedState);
- return false;
- }
- // Short options
- commandParser.RestoreState(confirmedState).Skip().Match(P("-"));
- if (commandParser.Ok()) {
- return ParseShortOption();
- }
- commandParser.RestoreState(confirmedState);
- return false;
-}
-
-// Assumes `commandParser` is not `none`.
-// 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`).
-private final function bool ParseLongOption()
-{
- local int i, optionIndex;
- local MutableText optionName;
-
- commandParser.MUntil(optionName,, true);
- if (!commandParser.Ok()) {
- return false;
- }
- while (optionIndex < availableOptions.length)
- {
- if (optionName.Compare(availableOptions[optionIndex].longName)) break;
- optionIndex += 1;
- }
- if (optionIndex >= availableOptions.length)
- {
- DeclareError(CET_UnknownOption, optionName);
- optionName.FreeSelf();
- return false;
- }
- for (i = 0; i < usedOptions.length; i += 1)
- {
- if (optionName.Compare(usedOptions[i].longName))
- {
- DeclareError(CET_RepeatedOption, optionName);
- optionName.FreeSelf();
- return false;
- }
- }
- //usedOptions[usedOptions.length] = availableOptions[optionIndex];
- optionName.FreeSelf();
- return ParseOptionParameters(availableOptions[optionIndex]);
-}
-
-// Assumes `commandParser` and `nextResult` are not `none`.
-// 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()
-{
- local int i;
- local bool pickedOptionWithParameters;
- local MutableText optionsList;
-
- commandParser.MUntil(optionsList,, true);
- if (!commandParser.Ok())
- {
- optionsList.FreeSelf();
- return false;
- }
- for (i = 0; i < optionsList.GetLength(); i += 1)
- {
- if (nextResult.parsingError != CET_None) break;
- pickedOptionWithParameters =
- AddOptionByCharacter( optionsList.GetCharacter(i), optionsList,
- pickedOptionWithParameters)
- || pickedOptionWithParameters;
- }
- optionsList.FreeSelf();
- return (nextResult.parsingError == CET_None);
-}
-
-// Assumes `commandParser` and `nextResult` are not `none`.
-// 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.
-// 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.
-private final function bool AddOptionByCharacter(
- BaseText.Character optionCharacter,
- BaseText optionSourceList,
- bool forbidOptionWithParameters)
-{
- local int i;
- local bool optionHasParameters;
-
- // Prevent same option appearing twice
- for (i = 0; i < usedOptions.length; i += 1)
- {
- if (_.text.AreEqual(optionCharacter, usedOptions[i].shortName))
- {
- DeclareError(CET_RepeatedOption, usedOptions[i].longName);
- return false;
- }
- }
- // If it's a new option - look it up in all available options
- for (i = 0; i < availableOptions.length; i += 1)
- {
- if (!_.text.AreEqual(optionCharacter, availableOptions[i].shortName)) {
- continue;
- }
- usedOptions[usedOptions.length] = availableOptions[i];
- optionHasParameters = (availableOptions[i].required.length > 0
- || availableOptions[i].optional.length > 0);
- // Enforce `forbidOptionWithParameters` flag restriction
- if (optionHasParameters && forbidOptionWithParameters)
- {
- DeclareError(CET_MultipleOptionsWithParams, optionSourceList);
- return optionHasParameters;
- }
- // Parse parameters (even if they are empty) and bail
- commandParser.Skip();
- ParseOptionParameters(availableOptions[i]);
- break;
- }
- if (i >= availableOptions.length) {
- DeclareError(CET_UnknownShortOption);
- }
- return optionHasParameters;
-}
-
-// Auxiliary method for parsing option's parameters (including empty ones).
-// 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;
-
- // If we are already parsing other option's parameters and did not finish
- // parsing all required ones - we cannot start another option
- if (currentTargetIsOption && currentTarget != CPT_ExtraParameter)
- {
- DeclareError(CET_NoRequiredParamForOption, targetOption.longName);
- return false;
- }
- if (pickedOption.required.length == 0 && pickedOption.optional.length == 0)
- {
- nextResult.options.SetItem(pickedOption.longName, none);
- return true;
- }
- currentTargetIsOption = true;
- targetOption = pickedOption;
- optionParameters = ParseParameterArrays( pickedOption.required,
- pickedOption.optional);
- currentTargetIsOption = false;
- if (commandParser.Ok())
- {
- nextResult.options
- .SetItem(pickedOption.longName, optionParameters);
- _.memory.Free(optionParameters);
- return true;
- }
- _.memory.Free(optionParameters);
- return false;
-}
-
-defaultproperties
-{
- booleanTrueEquivalents(0) = "true"
- booleanTrueEquivalents(1) = "enable"
- booleanTrueEquivalents(2) = "on"
- booleanTrueEquivalents(3) = "yes"
- booleanFalseEquivalents(0) = "false"
- booleanFalseEquivalents(1) = "disable"
- booleanFalseEquivalents(2) = "off"
- booleanFalseEquivalents(3) = "no"
- errNoSubCommands = (l=LOG_Error,m="`GetSubCommand()` method was called on a command `%1` with zero defined sub-commands.")
-}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/Commands.uc b/sources/LevelAPI/Features/Commands/Commands.uc
deleted file mode 100644
index 35138ca..0000000
--- a/sources/LevelAPI/Features/Commands/Commands.uc
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * Config object for `Commands_Feature`.
- * Copyright 2021-2022 Anton Tarasenko
- *------------------------------------------------------------------------------
- * This file is part of Acedia.
- *
- * Acedia is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License, or
- * (at your option) any later version.
- *
- * Acedia is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Acedia. If not, see .
- */
-class Commands extends FeatureConfig
- perobjectconfig
- config(AcediaSystem);
-
-var public config bool useChatInput;
-var public config bool useMutateInput;
-var public config string chatCommandPrefix;
-var public config array allowedPlayers;
-
-protected function HashTable ToData()
-{
- local int i;
- local HashTable data;
- local ArrayList playerList;
-
- data = __().collections.EmptyHashTable();
- data.SetBool(P("useChatInput"), useChatInput, true);
- data.SetBool(P("useMutateInput"), useMutateInput, true);
- data.SetString(P("chatCommandPrefix"), chatCommandPrefix);
- playerList = _.collections.EmptyArrayList();
- for (i = 0; i < allowedPlayers.length; i += 1) {
- playerList.AddString(allowedPlayers[i]);
- }
- data.SetItem(P("allowedPlayers"), playerList);
- playerList.FreeSelf();
- return data;
-}
-
-protected function FromData(HashTable source)
-{
- local int i;
- local ArrayList playerList;
-
- if (source == none) {
- return;
- }
- useChatInput = source.GetBool(P("useChatInput"));
- useMutateInput = source.GetBool(P("useMutateInput"));
- chatCommandPrefix = source.GetString(P("chatCommandPrefix"), "!");
- playerList = source.GetArrayList(P("allowedPlayers"));
- allowedPlayers.length = 0;
- if (playerList == none) {
- return;
- }
- for (i = 0; i < playerList.GetLength(); i += 1) {
- allowedPlayers[allowedPlayers.length] = playerList.GetString(i);
- }
- playerList.FreeSelf();
-}
-
-protected function DefaultIt()
-{
- useChatInput = true;
- useMutateInput = true;
- chatCommandPrefix = "!";
- allowedPlayers.length = 0;
-}
-
-defaultproperties
-{
- configName = "AcediaSystem"
- useChatInput = true
- useMutateInput = true
- chatCommandPrefix = "!"
-}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/Commands_Feature.uc b/sources/LevelAPI/Features/Commands/Commands_Feature.uc
deleted file mode 100644
index 2f9008c..0000000
--- a/sources/LevelAPI/Features/Commands/Commands_Feature.uc
+++ /dev/null
@@ -1,627 +0,0 @@
-/**
- * This feature provides a mechanism to define commands that automatically
- * parse their arguments into standard Acedia collection. It also allows to
- * manage them (and specify limitation on how they can be called) in a
- * centralized manner.
- * Copyright 2021-2022 Anton Tarasenko
- *------------------------------------------------------------------------------
- * This file is part of Acedia.
- *
- * Acedia is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License, or
- * (at your option) any later version.
- *
- * Acedia is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Acedia. If not, see .
- */
-class Commands_Feature extends Feature;
-
-/**
- * # `Commands_Feature`
- *
- * This feature provides a mechanism to define commands that automatically
- * parse their arguments into standard Acedia collection. It also allows to
- * manage them (and specify limitation on how they can be called) in a
- * centralized manner.
- * Support command input from chat and "mutate" command.
- *
- * ## Usage
- *
- * Should be enabled like any other feature. Additionally support
- * `EmergencyEnable()` enabling method that bypasses regular settings to allow
- * admins to start this feature while forcefully enabling "mutate" command
- * input method.
- * Available configuration:
- *
- * 1. Whether to use command input from chat and what prefix is used to
- * denote a command (by default "!");
- * 2. Whether to use command input from "mutate" command.
- *
- * To add new commands into the system - get enabled instance of this
- * feature and call its `RegisterCommand()` method to add your custom
- * `Command` class. `RemoveCommand()` can also be used to de-register
- * a command, if you need this for some reason.
- *
- * ## Implementation
- *
- * Implementation is simple: calling a method `RegisterCommand()` adds
- * command into two caches `registeredCommands` for obtaining registered
- * commands by name and `groupedCommands` for obtaining arrays of commands by
- * their group name. These arrays are used for providing methods for fetching
- * arrays of commands and obtaining pre-allocated `Command` instances by their
- * name.
- * Depending on settings, this feature also connects to corresponding
- * signals for catching "mutate"/chat input, then it checks user-specified name
- * for being an alias and picks correct command from `registeredCommands`.
- * Emergency enabling this feature sets `emergencyEnabledMutate` flag that
- * enforces connecting to the "mutate" input.
- */
-
-// Delimiters that always separate command name from it's parameters
-var private array commandDelimiters;
-// Registered commands, recorded as (, ) pairs.
-// Keys should be deallocated when their entry is removed.
-var private HashTable registeredCommands;
-// `HashTable` of "" <-> `ArrayList` of commands pairs
-// to allow quick fetch of commands belonging to a single group
-var private HashTable groupedCommands;
-
-// 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;
-// List of steam IDs of players allowed to use commands.
-// Temporary measure until a better solution is finished.
-var private /*config*/ array allowedPlayers;
-
-// Contains name of the command to call plus, optionally,
-// additional sub-command name.
-// Normally sub-command name is parsed by the command itself, however
-// command aliases can try to enforce one.
-struct CommandCallPair
-{
- var MutableText commandName;
- // In case it is enforced by an alias
- var MutableText subCommandName;
-};
-
-var LoggerAPI.Definition errCommandDuplicate;
-
-protected function OnEnabled()
-{
- registeredCommands = _.collections.EmptyHashTable();
- groupedCommands = _.collections.EmptyHashTable();
- RegisterCommand(class'ACommandHelp');
- RegisterCommand(class'ACommandNotify');
- // Macro selector
- commandDelimiters[0] = _.text.FromString("@");
- // Key selector
- commandDelimiters[1] = _.text.FromString("#");
- // Player array (possibly JSON array)
- commandDelimiters[2] = _.text.FromString("[");
- // Negation of the selector
- commandDelimiters[3] = _.text.FromString("!");
-}
-
-protected function OnDisabled()
-{
- if (useChatInput) {
- _.chat.OnMessage(self).Disconnect();
- }
- if (useMutateInput) {
- _server.unreal.mutator.OnMutate(self).Disconnect();
- }
- useChatInput = false;
- useMutateInput = false;
- _.memory.Free(registeredCommands);
- _.memory.Free(groupedCommands);
- _.memory.Free(chatCommandPrefix);
- _.memory.FreeMany(commandDelimiters);
- registeredCommands = none;
- groupedCommands = none;
- chatCommandPrefix = none;
- commandDelimiters.length = 0;
-}
-
-protected function SwapConfig(FeatureConfig config)
-{
- local Commands newConfig;
-
- newConfig = Commands(config);
- if (newConfig == none) {
- return;
- }
- _.memory.Free(chatCommandPrefix);
- chatCommandPrefix = _.text.FromString(newConfig.chatCommandPrefix);
- allowedPlayers = newConfig.allowedPlayers;
- if (useChatInput != newConfig.useChatInput)
- {
- useChatInput = newConfig.useChatInput;
- if (newConfig.useChatInput) {
- _.chat.OnMessage(self).connect = HandleCommands;
- }
- else {
- _.chat.OnMessage(self).Disconnect();
- }
- }
- // Do not make any modifications here in case "mutate" was
- // emergency-enabled
- if (useMutateInput != newConfig.useMutateInput && !emergencyEnabledMutate)
- {
- useMutateInput = newConfig.useMutateInput;
- if (newConfig.useMutateInput) {
- _server.unreal.mutator.OnMutate(self).connect = HandleMutate;
- }
- else {
- _server.unreal.mutator.OnMutate(self).Disconnect();
- }
- }
-}
-
-/**
- * `Command_Feature` is a critical command to have running on your server and,
- * if disabled by accident, there will be no way of starting it again without
- * restarting the level or even editing configs.
- *
- * This method allows to enable it along with "mutate" input in case something
- * goes wrong.
- */
-public final static function EmergencyEnable()
-{
- local Text autoConfig;
- local Commands_Feature feature;
-
- if (!IsEnabled())
- {
- autoConfig = GetAutoEnabledConfig();
- EnableMe(autoConfig);
- __().memory.Free(autoConfig);
- }
- feature = Commands_Feature(GetEnabledInstance());
- if ( !feature.emergencyEnabledMutate
- && !feature.IsUsingMutateInput() && !feature.IsUsingChatInput())
- {
- default.emergencyEnabledMutate = true;
- feature.emergencyEnabledMutate = true;
- __server().unreal.mutator.OnMutate(feature).connect = HandleMutate;
- }
-}
-
-/**
- * Checks if `Commands_Feature` currently uses chat as input.
- * If `Commands_Feature` is not enabled, then it does not use anything
- * as input.
- *
- * @return `true` if `Commands_Feature` is currently enabled and is using chat
- * as input and `false` otherwise.
- */
-public final static function bool IsUsingChatInput()
-{
- local Commands_Feature instance;
-
- instance = Commands_Feature(GetEnabledInstance());
- if (instance != none) {
- return instance.useChatInput;
- }
- return false;
-}
-
-/**
- * Checks if `Commands_Feature` currently uses mutate command as input.
- * If `Commands_Feature` is not enabled, then it does not use anything
- * as input.
- *
- * @return `true` if `Commands_Feature` is currently enabled and is using
- * mutate command as input and `false` otherwise.
- */
-public final static function bool IsUsingMutateInput()
-{
- local Commands_Feature instance;
-
- instance = Commands_Feature(GetEnabledInstance());
- if (instance != none) {
- return instance.useMutateInput;
- }
- return false;
-}
-
-/**
- * Returns prefix that will indicate that chat message is intended to be
- * a command. By default "!".
- *
- * @return Prefix that indicates that chat message is intended to be a command.
- * If `Commands_Feature` is disabled, always returns `false`.
- */
-public final static function Text GetChatPrefix()
-{
- local Commands_Feature instance;
-
- instance = Commands_Feature(GetEnabledInstance());
- if (instance != none && instance.chatCommandPrefix != none) {
- return instance.chatCommandPrefix.Copy();
- }
- return none;
-}
-
-/**
- * Registers given command class, making it available for usage.
- *
- * If `commandClass` provides command with a name that is already taken
- * (comparison is case-insensitive) by a different command - a warning will be
- * logged and newly passed `commandClass` discarded.
- *
- * @param commandClass New command class to register.
- */
-public final function RegisterCommand(class commandClass)
-{
- local Text commandName, groupName;
- local ArrayList groupArray;
- local Command newCommandInstance, existingCommandInstance;
-
- if (commandClass == none) return;
- if (registeredCommands == none) return;
-
- 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;
- }
- // 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.Free(groupArray);
- _.memory.Free(groupName);
- _.memory.Free(commandName);
- _.memory.Free(newCommandInstance);
-}
-
-/**
- * Removes command of class `commandClass` from the list of
- * registered commands.
- *
- * WARNING: 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.
- *
- * @param commandClass Class of command to remove from being registered.
- */
-public final function RemoveCommand(class commandClass)
-{
- local int i;
- local CollectionIterator iter;
- local Command nextCommand;
- local Text nextCommandName;
- local array commandGroup;
- local array 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.Free(nextCommand);
- _.memory.Free(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);
-}
-
-private final function RemoveClassFromGroup(
- class commandClass,
- BaseText commandGroup)
-{
- local int i;
- local ArrayList groupArray;
- local Command nextCommand;
-
- groupArray = groupedCommands.GetArrayList(commandGroup);
- if (groupArray == none) {
- return;
- }
- while (i < groupArray.GetLength())
- {
- nextCommand = Command(groupArray.GetItem(i));
- if (nextCommand != none && nextCommand.class == commandClass) {
- groupArray.RemoveIndex(i);
- }
- else {
- i += 1;
- }
- _.memory.Free(nextCommand);
- }
- if (groupArray.GetLength() == 0) {
- groupedCommands.RemoveItem(commandGroup);
- }
- _.memory.Free(groupArray);
-}
-
-/**
- * Returns command based on a given name.
- *
- * @param commandName Name of the registered `Command` to return.
- * Case-insensitive.
- * @return Command, registered with a given name `commandName`.
- * If no command with such name was registered - returns `none`.
- */
-public final function Command GetCommand(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.
- *
- * @return Array of names of all available (registered) commands.
- */
-public final function array GetCommandNames()
-{
- local array emptyResult;
-
- if (registeredCommands != none) {
- return registeredCommands.GetTextKeys();
- }
- return emptyResult;
-}
-
-/**
- * Returns array of names of all available commands belonging to the group
- * `groupName`.
- *
- * @return Array of names of all available (registered) commands, belonging to
- * the group `groupName`.
- */
-public final function array GetCommandNamesInGroup(BaseText groupName)
-{
- local int i;
- local ArrayList groupArray;
- local Command nextCommand;
- local array 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.
- *
- * @return Array of all available command groups' names.
- */
-public final function array GetGroupsNames()
-{
- local array emptyResult;
-
- if (groupedCommands != none) {
- return groupedCommands.GetTextKeys();
- }
- return emptyResult;
-}
-
-/**
- * Handles user input: finds appropriate command and passes the rest of
- * the arguments to it for further processing.
- *
- * @param input Test that contains user's command input.
- * @param callerPlayer Player that caused this command call.
- */
-public final function HandleInput(BaseText input, EPlayer callerPlayer)
-{
- local Parser wrapper;
-
- if (input == none) {
- return;
- }
- wrapper = input.Parse();
- HandleInputWith(wrapper, callerPlayer);
- wrapper.FreeSelf();
-}
-
-/**
- * Handles user input: finds appropriate command and passes the rest of
- * the arguments to it for further processing.
- *
- * @param parser Parser filled with user input that is expected to
- * contain command's name and it's parameters.
- * @param callerPlayer Player that caused this command call.
- */
-public final function HandleInputWith(Parser parser, EPlayer callerPlayer)
-{
- local int i;
- local bool foundID;
- local string steamID;
- local PlayerController controller;
- local Command commandInstance;
- local Command.CallData callData;
- local CommandCallPair callPair;
-
- if (parser == none) return;
- if (callerPlayer == none) return;
- if (!parser.Ok()) return;
- controller = callerPlayer.GetController();
- if (controller == none) return;
-
- steamID = controller.GetPlayerIDHash();
- for (i = 0; i < allowedPlayers.length; i += 1)
- {
- if (allowedPlayers[i] == steamID)
- {
- foundID = true;
- break;
- }
- }
- if (!foundID) {
- return;
- }
- callPair = ParseCommandCallPairWith(parser);
- commandInstance = GetCommand(callPair.commandName);
- if ( commandInstance == none
- && 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);
- commandInstance.Execute(callData, callerPlayer);
- commandInstance.DeallocateCallData(callData);
- }
- _.memory.Free(callPair.commandName);
- _.memory.Free(callPair.subCommandName);
-}
-
-// Parses command's name into `CommandCallPair` - sub-command is filled in case
-// specified name is an alias with specified sub-command name.
-private final function CommandCallPair ParseCommandCallPairWith(Parser parser)
-{
- local Text resolvedValue;
- local MutableText userSpecifiedName;
- local CommandCallPair result;
- local Text.Character dotCharacter;
-
- if (parser == none) return result;
- if (!parser.Ok()) return result;
-
- parser.MUntilMany(userSpecifiedName, commandDelimiters, true, true);
- resolvedValue = _.alias.ResolveCommand(userSpecifiedName);
- // This isn't an alias
- if (resolvedValue == none)
- {
- result.commandName = userSpecifiedName;
- return result;
- }
- // It is an alias - parse it
- dotCharacter = _.text.GetCharacter(".");
- resolvedValue.Parse()
- .MUntil(result.commandName, dotCharacter)
- .MatchS(".")
- .MUntil(result.subCommandName, dotCharacter)
- .FreeSelf();
- if (result.subCommandName.IsEmpty())
- {
- result.subCommandName.FreeSelf();
- result.subCommandName = none;
- }
- resolvedValue.FreeSelf();
- return result;
-}
-
-private function bool HandleCommands(
- EPlayer sender,
- MutableText message,
- bool teamMessage)
-{
- local Parser parser;
-
- // We are only interested in messages that start with `chatCommandPrefix`
- parser = _.text.Parse(message);
- if (!parser.Match(chatCommandPrefix).Ok())
- {
- parser.FreeSelf();
- return true;
- }
- // Pass input to command feature
- HandleInputWith(parser, sender);
- parser.FreeSelf();
- return false;
-}
-
-private function HandleMutate(string command, PlayerController sendingPlayer)
-{
- local Parser parser;
- local EPlayer sender;
-
- // A lot of other mutators use these commands
- if (command ~= "help") return;
- if (command ~= "version") return;
- if (command ~= "status") return;
- if (command ~= "credits") return;
-
- parser = _.text.ParseString(command);
- sender = _.players.FromController(sendingPlayer);
- HandleInputWith(parser, sender);
- sender.FreeSelf();
- parser.FreeSelf();
-}
-
-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.")
-}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/PlayersParser.uc b/sources/LevelAPI/Features/Commands/PlayersParser.uc
deleted file mode 100644
index 62ba141..0000000
--- a/sources/LevelAPI/Features/Commands/PlayersParser.uc
+++ /dev/null
@@ -1,559 +0,0 @@
-/**
- * Object for parsing what converting textual description of a group of
- * players into array of `EPlayer`s. Depends on the game context.
- * Copyright 2021-2022 Anton Tarasenko
- *------------------------------------------------------------------------------
- * This file is part of Acedia.
- *
- * Acedia is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License, or
- * (at your option) any later version.
- *
- * Acedia is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Acedia. If not, see .
- */
-class PlayersParser extends AcediaObject
- dependson(Parser);
-
-/**
- * # `PlayersParser`
- *
- * This parser is supposed to parse player set definitions as they
- * are used in commands.
- * Basic use is to specify one of the selectors:
- * 1. Key selector: "#" (examples: "#1", "#5").
- * 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.
- * "@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 "@".
- * These specify name prefixes: any player with specified prefix
- * will be considered to match such selector.
- *
- * Negated selectors: "!". Specifying "!" in front of selector
- * will select all players that do not match it instead.
- *
- * Grouped selectors: "['', '', ... '']".
- * 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:
- * *. "[@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;
- * 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 (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.
- */
-
-// 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`.
-var private array playersSnapshot;
-// Players, selected according to selectors we have parsed so far
-var private array 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).
-var private bool parsedFirstSelector;
-// Will be equal to a single-element array [","], used for parsing
-var private array selectorDelimiters;
-
-var const int TSELF, TME, TADMIN, TALL, TNOT, TKEY, TMACRO, TCOMMA;
-var const int TOPEN_BRACKET, TCLOSE_BRACKET;
-
-protected function Finalizer()
-{
- // No need to deallocate `currentSelection`,
- // since it has `EPlayer`s from `playersSnapshot` or `selfPlayer`
- _.memory.Free(selfPlayer);
- _.memory.FreeMany(playersSnapshot);
- selfPlayer = none;
- parsedFirstSelector = false;
- playersSnapshot.length = 0;
- currentSelection.length = 0;
-}
-
-/**
- * Set a player who will be referred to by "@", "@me" and "@self" macros.
- *
- * @param newSelfPlayer Player who will be referred to by "@", "@me" and
- * "@self" macros. Passing `none` will make it so no one is
- * referred by them.
- */
-public final function SetSelf(EPlayer newSelfPlayer)
-{
- _.memory.Free(selfPlayer);
- selfPlayer = none;
- if (newSelfPlayer != none) {
- selfPlayer = EPlayer(newSelfPlayer.Copy());
- }
-}
-
-// 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)
-{
- local int i;
-
- if (toInsert == none) {
- return;
- }
- for (i = 0; i < currentSelection.length; i += 1)
- {
- if (currentSelection[i] == toInsert) {
- return;
- }
- }
- currentSelection[currentSelection.length] = toInsert;
-}
-
-// Adds all the players with specified key (`key`) to the current selection.
-private final function AddByKey(int key)
-{
- local int i;
-
- for (i = 0; i < playersSnapshot.length; i += 1)
- {
- if (playersSnapshot[i].GetIdentity().GetKey() == key) {
- InsertPlayer(playersSnapshot[i]);
- }
- }
-}
-
-// Removes all the players with specified key (`key`) from
-// the current selection.
-private final function RemoveByKey(int key)
-{
- local int i;
-
- while (i < currentSelection.length)
- {
- if (currentSelection[i].GetIdentity().GetKey() == key) {
- currentSelection.Remove(i, 1);
- }
- else {
- i += 1;
- }
- }
-}
-
-// Adds all the players with specified name (`name`) to the current selection.
-private final function AddByName(BaseText name)
-{
- local int i;
- local Text nextPlayerName;
-
- if (name == none) {
- return;
- }
- for (i = 0; i < playersSnapshot.length; i += 1)
- {
- nextPlayerName = playersSnapshot[i].GetName();
- if (nextPlayerName.StartsWith(name, SCASE_INSENSITIVE)) {
- InsertPlayer(playersSnapshot[i]);
- }
- nextPlayerName.FreeSelf();
- }
-}
-
-// Removes all the players with specified name (`name`) from
-// the current selection.
-private final function RemoveByName(BaseText name)
-{
- local int i;
- local Text nextPlayerName;
-
- while (i < currentSelection.length)
- {
- nextPlayerName = currentSelection[i].GetName();
- if (nextPlayerName.StartsWith(name, SCASE_INSENSITIVE)) {
- currentSelection.Remove(i, 1);
- }
- else {
- i += 1;
- }
- nextPlayerName.FreeSelf();
- }
-}
-
-// Adds all the admins to the current selection.
-private final function AddAdmins()
-{
- local int i;
-
- for (i = 0; i < playersSnapshot.length; i += 1)
- {
- if (playersSnapshot[i].IsAdmin()) {
- InsertPlayer(playersSnapshot[i]);
- }
- }
-}
-
-// Removes all the admins from the current selection.
-private final function RemoveAdmins()
-{
- local int i;
-
- while (i < currentSelection.length)
- {
- if (currentSelection[i].IsAdmin()) {
- currentSelection.Remove(i, 1);
- }
- else {
- i += 1;
- }
- }
-}
-
-// Add all the players specified by `macroText` (from macro "@").
-// Does nothing if there is no such macro.
-private final function AddByMacro(BaseText macroText)
-{
- if (macroText.Compare(T(TADMIN), SCASE_INSENSITIVE))
- {
- AddAdmins();
- return;
- }
- if (macroText.Compare(T(TALL), SCASE_INSENSITIVE))
- {
- currentSelection = playersSnapshot;
- return;
- }
- if ( macroText.IsEmpty()
- || macroText.Compare(T(TSELF), SCASE_INSENSITIVE)
- || macroText.Compare(T(TME), SCASE_INSENSITIVE))
- {
- InsertPlayer(selfPlayer);
- }
-}
-
-// Removes all the players specified by `macroText`
-// (from macro "@").
-// Does nothing if there is no such macro.
-private final function RemoveByMacro(BaseText macroText)
-{
- local int i;
-
- if (macroText.Compare(T(TADMIN), SCASE_INSENSITIVE))
- {
- RemoveAdmins();
- return;
- }
- if (macroText.Compare(T(TALL), SCASE_INSENSITIVE))
- {
- currentSelection.length = 0;
- return;
- }
- if (macroText.IsEmpty() || macroText.Compare(T(TSELF), SCASE_INSENSITIVE))
- {
- while (i < currentSelection.length)
- {
- if (currentSelection[i] == selfPlayer) {
- currentSelection.Remove(i, 1);
- }
- else {
- i += 1;
- }
- }
- }
-}
-
-// 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;
-
- if (parser == none) return;
- if (!parser.Ok()) return;
-
- confirmedState = parser.GetCurrentState();
- if (!parser.Match(T(TNOT)).Ok())
- {
- additiveSelector = true;
- parser.RestoreState(confirmedState);
- }
- // Determine whether we stars with empty or full player list
- if (!parsedFirstSelector)
- {
- parsedFirstSelector = true;
- if (additiveSelector) {
- currentSelection.length = 0;
- }
- else {
- currentSelection = playersSnapshot;
- }
- }
- // Try all selector types
- confirmedState = parser.GetCurrentState();
- if (parser.Match(T(TKEY)).Ok())
- {
- ParseKeySelector(parser, additiveSelector);
- return;
- }
- parser.RestoreState(confirmedState);
- if (parser.Match(T(TMACRO)).Ok())
- {
- ParseMacroSelector(parser, additiveSelector);
- return;
- }
- parser.RestoreState(confirmedState);
- ParseNameSelector(parser, additiveSelector);
-}
-
-// 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;
-
- if (parser == none) return;
- if (!parser.Ok()) return;
- if (!parser.MInteger(key).Ok()) return;
-
- if (additiveSelector) {
- AddByKey(key);
- }
- else {
- RemoveByKey(key);
- }
-}
-
-// 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;
-
- if (parser == none) return;
- if (!parser.Ok()) return;
-
- confirmedState = parser.GetCurrentState();
- macroName = ParseLiteral(parser);
- if (!parser.Ok())
- {
- _.memory.Free(macroName);
- return;
- }
- if (additiveSelector) {
- AddByMacro(macroName);
- }
- else {
- RemoveByMacro(macroName);
- }
- _.memory.Free(macroName);
-}
-
-// 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;
-
- if (parser == none) return;
- if (!parser.Ok()) return;
-
- confirmedState = parser.GetCurrentState();
- playerName = ParseLiteral(parser);
- if (!parser.Ok() || playerName.IsEmpty())
- {
- _.memory.Free(playerName);
- return;
- }
- if (additiveSelector) {
- AddByName(playerName);
- }
- else {
- RemoveByName(playerName);
- }
- _.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 "@").
-// 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;
-
- if (parser == none) return none;
- if (!parser.Ok()) return none;
-
- confirmedState = parser.GetCurrentState();
- if (!parser.MStringLiteral(literal).Ok())
- {
- parser.RestoreState(confirmedState);
- parser.MUntilMany(literal, selectorDelimiters, true);
- }
- return literal;
-}
-
-/**
- * Returns players parsed by the last `ParseWith()` or `Parse()` call.
- * If neither were yet called - returns an empty array.
- *
- * @return players parsed by the last `ParseWith()` or `Parse()` call.
- */
-public final function array GetPlayers()
-{
- local int i;
- local array 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.
- *
- * @param parser `Parser` from which to parse player list.
- * It's state will be set to failed in case the parsing fails.
- * @return `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()
-{
- parsedFirstSelector = false;
- currentSelection.length = 0;
- _.memory.FreeMany(playersSnapshot);
- playersSnapshot.length = 0;
- playersSnapshot = _.players.GetAll();
- selectorDelimiters.length = 0;
- selectorDelimiters[0] = T(TCOMMA);
- selectorDelimiters[1] = T(TCLOSE_BRACKET);
-}
-
-/**
- * Parses players from `toParse` according to the currently present players.
- *
- * Array of parsed players can be retrieved by `self.GetPlayers()` method.
- *
- * @param toParse `Text` from which to parse player list.
- * @return `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"
- TADMIN = 1
- stringConstants(1) = "admin"
- TALL = 2
- stringConstants(2) = "all"
- TNOT = 3
- stringConstants(3) = "!"
- TKEY = 4
- stringConstants(4) = "#"
- TMACRO = 5
- stringConstants(5) = "@"
- TCOMMA = 6
- stringConstants(6) = ","
- TOPEN_BRACKET = 7
- stringConstants(7) = "["
- TCLOSE_BRACKET = 8
- stringConstants(8) = "]"
- TME = 9
- stringConstants(9) = "me"
-}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/Tests/MockCommandB.uc b/sources/LevelAPI/Features/Commands/Tests/MockCommandB.uc
deleted file mode 100644
index 49b6896..0000000
--- a/sources/LevelAPI/Features/Commands/Tests/MockCommandB.uc
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * Mock command class for testing.
- * Copyright 2021 Anton Tarasenko
- *------------------------------------------------------------------------------
- * This file is part of Acedia.
- *
- * Acedia is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License, or
- * (at your option) any later version.
- *
- * Acedia is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Acedia. If not, see .
- */
-class MockCommandB extends Command;
-
-protected function BuildData(CommandDataBuilder builder)
-{
- builder.ParamArray(P("just_array"))
- .ParamText(P("just_text"));
- builder.Option(P("values"))
- .ParamIntegerList(P("types"));
- builder.Option(P("long"))
- .ParamInteger(P("num"))
- .ParamNumberList(P("text"))
- .ParamBoolean(P("huh"));
- builder.Option(P("type"), P("t"))
- .ParamText(P("type"));
- builder.Option(P("Test"))
- .ParamText(P("to_test"));
- builder.Option(P("silent"))
- .Option(P("forced"))
- .Option(P("verbose"), P("V"))
- .Option(P("actual"));
- builder.SubCommand(P("do"))
- .OptionalParams()
- .ParamNumberList(P("numeric list"), P("list"))
- .ParamBoolean(P("maybe"));
- builder.Option(P("remainder"))
- .ParamRemainder(P("everything"));
- builder.SubCommand(P("json"))
- .ParamJSON(P("first_json"))
- .ParamJSONList(P("other_json"));
-}
-
-defaultproperties
-{
-}
\ No newline at end of file
diff --git a/sources/Manifest.uc b/sources/Manifest.uc
index 4e35b39..17dae9a 100644
--- a/sources/Manifest.uc
+++ b/sources/Manifest.uc
@@ -43,19 +43,20 @@ defaultproperties
testCases(14) = class'TEST_TextTemplate'
testCases(15) = class'TEST_User'
testCases(16) = class'TEST_Memory'
- testCases(17) = class'TEST_ArrayList'
- testCases(18) = class'TEST_HashTable'
- testCases(19) = class'TEST_CollectionsMixed'
- testCases(20) = class'TEST_Iterator'
- testCases(21) = class'TEST_Command'
- testCases(22) = class'TEST_CommandDataBuilder'
- testCases(23) = class'TEST_LogMessage'
- testCases(24) = class'TEST_SchedulerAPI'
- testCases(25) = class'TEST_BigInt'
- testCases(26) = class'TEST_DatabaseCommon'
- testCases(27) = class'TEST_LocalDatabase'
- testCases(28) = class'TEST_DBConnection'
- testCases(29) = class'TEST_AcediaConfig'
- testCases(30) = class'TEST_UTF8EncoderDecoder'
- testCases(31) = class'TEST_AvariceStreamReader'
+ testCases(17) = class'TEST_Voting'
+ testCases(18) = class'TEST_ArrayList'
+ testCases(19) = class'TEST_HashTable'
+ testCases(20) = class'TEST_CollectionsMixed'
+ testCases(21) = class'TEST_Iterator'
+ testCases(22) = class'TEST_Command'
+ testCases(23) = class'TEST_CommandDataBuilder'
+ testCases(24) = class'TEST_LogMessage'
+ testCases(25) = class'TEST_SchedulerAPI'
+ testCases(26) = class'TEST_BigInt'
+ testCases(27) = class'TEST_DatabaseCommon'
+ testCases(28) = class'TEST_LocalDatabase'
+ testCases(29) = class'TEST_DBConnection'
+ testCases(30) = class'TEST_AcediaConfig'
+ testCases(31) = class'TEST_UTF8EncoderDecoder'
+ testCases(32) = class'TEST_AvariceStreamReader'
}
\ No newline at end of file
diff --git a/sources/Players/PlayerNotificationQueue.uc b/sources/Players/PlayerNotificationQueue.uc
index 968d619..5880e1f 100644
--- a/sources/Players/PlayerNotificationQueue.uc
+++ b/sources/Players/PlayerNotificationQueue.uc
@@ -28,7 +28,7 @@ class PlayerNotificationQueue extends AcediaObject
struct Notification {
var Text title;
var Text body;
- var float time;
+ var float duration;
};
var private array notificationQueue;
/// Reference to the `PlayerController` for the player that owns this queue
@@ -81,7 +81,7 @@ public final /*native*/ function SetupController(NativeActorRef newPlayerControl
}
/// Add new notification to the queue
-public final function AddNotification(BaseText title, BaseText body, float time) {
+public final function AddNotification(BaseText title, BaseText body, float duration) {
local Notification newNotification;
if (body == none) {
@@ -91,6 +91,7 @@ public final function AddNotification(BaseText title, BaseText body, float time)
newNotification.title = title.Copy();
}
newNotification.body = body.Copy();
+ newNotification.duration = duration;
notificationQueue[notificationQueue.length] = newNotification;
if (!IsUpdateScheduled()) {
SetupNextNotification(none);
@@ -193,14 +194,14 @@ private function SetupNextNotification(Timer callerInstance) {
}
nextNotification = notificationQueue[0];
notificationQueue.Remove(0, 1);
- nextNotification.time = FMin(nextNotification.time, maximumNotifyTime);
- if (nextNotification.time <= 0) {
- nextNotification.time = 10.0;
+ nextNotification.duration = FMin(nextNotification.duration, maximumNotifyTime);
+ if (nextNotification.duration <= 0) {
+ nextNotification.duration = 10.0;
}
// And print
playerController.ClearProgressMessages();
- playerController.SetProgressTime(nextNotification.time);
+ playerController.SetProgressTime(nextNotification.duration);
if (nextNotification.title != none) {
upperCaseTitle = nextNotification.title.UpperMutableCopy();
upperCaseTitle.ChangeDefaultColor(_.color.TextHeader);
@@ -209,7 +210,7 @@ private function SetupNextNotification(Timer callerInstance) {
_.memory.Free(upperCaseTitle);
}
PrintNotifcationAt(playerController, nextNotification.body, titleShift, 4 - titleShift);
- ScheduleUpdate(nextNotification.time);
+ ScheduleUpdate(nextNotification.duration);
_.memory.Free2(nextNotification.title, nextNotification.body);
}
diff --git a/sources/Users/ACommandUserGroups.uc b/sources/Users/ACommandUserGroups.uc
index b58f706..77c0e22 100644
--- a/sources/Users/ACommandUserGroups.uc
+++ b/sources/Users/ACommandUserGroups.uc
@@ -22,56 +22,59 @@ class ACommandUserGroups extends Command
protected function BuildData(CommandDataBuilder builder)
{
- builder.Name(P("usergroups"))
- .Group(P("admin"))
- .Summary(P("User groups management."))
- .Describe(P("Allows to add/remove user groups and users to these:"
- @ "groups. Changes made by it will always affect current session,"
- @ "but might fail to be saved in case user groups are stored in"
- @ "a database that is either corrupted or in read-only mode."));
- builder.SubCommand(P("list"))
- .Describe(P("Lists specified groups along with users that belong to"
- @ "them. If no groups were specified at all - lists all available"
- @ "groups."))
- .OptionalParams()
- .ParamTextList(P("groups"));
- builder.SubCommand(P("add"))
- .Describe(P("Adds a new group"))
- .ParamText(P("group_name"));
- builder.SubCommand(P("remove"))
- .Describe(P("Removes a group"))
- .ParamText(P("group_name"));
- builder.SubCommand(P("addplayer"))
- .Describe(F("Adds new user to the group, specified by the player"
- @ "selector. Can add several players at once."
- @ "Allows to also optionally specify annotation"
- @ "(human-readable name) that can be thought of as"
- @ "a {$TextEmphasis comment}. If annotation isn't specified"
- @ "current nickname will be used as one."))
- .ParamText(P("group_name"))
- .ParamPlayers(P("player_selector"))
- .OptionalParams()
- .ParamText(P("annotation"));
- builder.SubCommand(P("removeplayer"))
- .Describe(P("Removes user from the group, specified by player selector."
- @ "Can remove several players at once."))
- .ParamText(P("group_name"))
- .ParamPlayers(P("player_selector"));
- builder.SubCommand(P("adduser"))
- .Describe(F("Adds new user to the group. Allows to also optionally"
- @ "specify annotation (human-readable name) that can be thought of"
- @ "as a {$TextEmphasis comment}."))
- .ParamText(P("group_name"))
- .ParamText(P("user_id"))
- .OptionalParams()
- .ParamText(P("annotation"));
- builder.SubCommand(P("removeuser"))
- .Describe(P("Removes user from the group. User can be specified by both"
- @ "user's id or annotation, with id taking priority."))
- .ParamText(P("group_name"))
- .ParamText(P("user_name"));
- builder.Option(P("force"))
- .Describe(P("Allows to force usage of invalid user IDs."));
+ 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"
+ @ "by it will always affect current session, but might fail to be saved in case user groups"
+ @ "are stored in a database that is either corrupted or in read-only mode."));
+
+ builder.SubCommand(P("list"));
+ builder.Describe(P("Lists specified groups along with users that belong to them. If no groups"
+ @ "were specified at all - lists all available groups."));
+ builder.OptionalParams();
+ builder.ParamTextList(P("groups"));
+
+ builder.SubCommand(P("add"));
+ builder.Describe(P("Adds a new group"));
+ builder.ParamText(P("group_name"));
+
+ builder.SubCommand(P("remove"));
+ builder.Describe(P("Removes a group"));
+ builder.ParamText(P("group_name"));
+
+ builder.SubCommand(P("addplayer"));
+ builder.Describe(P("Adds new user to the group, specified by the player selector. Can add"
+ @ "several players at once. Allows to also optionally specify annotation (human-readable"
+ @ "name) that can be thought of as a `comment`. If annotation isn't specified current"
+ @ "nickname will be used as one."));
+ builder.ParamText(P("group_name"));
+ builder.ParamPlayers(P("player_selector"));
+ builder.OptionalParams();
+ builder.ParamText(P("annotation"));
+
+ builder.SubCommand(P("removeplayer"));
+ builder.Describe(P("Removes user from the group, specified by player selector."
+ @ "Can remove several players at once."));
+ builder.ParamText(P("group_name"));
+ builder.ParamPlayers(P("player_selector"));
+
+ builder.SubCommand(P("adduser"));
+ builder.Describe(P("Adds new user to the group. Allows to also optionally specify annotation"
+ @ "(human-readable name) that can be thought of as a `comment`."));
+ builder.ParamText(P("group_name"));
+ builder.ParamText(P("user_id"));
+ builder.OptionalParams();
+ builder.ParamText(P("annotation"));
+
+ builder.SubCommand(P("removeuser"));
+ builder.Describe(P("Removes user from the group. User can be specified by both user's id or"
+ @ "annotation, with id taking priority."));
+ builder.ParamText(P("group_name"));
+ builder.ParamText(P("user_name"));
+
+ builder.Option(P("force"));
+ builder.Describe(P("Allows to force usage of invalid user IDs."));
}
protected function Executed(CallData arguments, EPlayer instigator)
diff --git a/sources/Users/Users_Feature.uc b/sources/Users/Users_Feature.uc
index 9431038..5899877 100644
--- a/sources/Users/Users_Feature.uc
+++ b/sources/Users/Users_Feature.uc
@@ -80,7 +80,13 @@ protected function OnEnabled()
feature.RegisterCommand(class'ACommandUserGroups');
feature.FreeSelf();
}
- LoadUserData();
+ if (_server.IsAvailable()) {
+ LoadUserData();
+ SetupPersistentData(usePersistentData);
+ } else {
+ _.logger.Auto(errNoServerCore);
+ return;
+ }
}
protected function OnDisabled()
@@ -113,17 +119,6 @@ protected function SwapConfig(FeatureConfig config)
useDatabaseForGroupsData = newConfig.useDatabaseForGroupsData;
groupsDatabaseLink = newConfig.groupsDatabaseLink;
availableUserGroups = newConfig.localUserGroup;
- ResetUploadedUserGroups();
- if (IsEnabled())
- {
- if (!_server.IsAvailable())
- {
- _.logger.Auto(errNoServerCore);
- return;
- }
- LoadUserData();
- SetupPersistentData(usePersistentData);
- }
}
/**
@@ -2153,6 +2148,6 @@ defaultproperties
errDBBadRootUserGroupData = (l=LOG_Error,m="Database link \"%1\" (configured to load user group data in \"AcediaUsers.ini\") contains incompatible data.")
errDBBadLinkPointer = (l=LOG_Error,m="Path inside database link \"%1\" (configured inside \"AcediaUsers.ini\") is invalid.")
errDBDamaged = (l=LOG_Error,m="Database given by the link \"%1\" (configured inside \"AcediaUsers.ini\") seems to be damaged.")
- errNoServerCore = (l=LOG_Error,m="Cannot start \"Users_Feature\", because no `ServerCore` was created.")
+ errNoServerCore = (l=LOG_Error,m="\"Users_Feature\" won't function properly, because no `ServerCore` was created.")
errDBContainsNonLowerRegister = (l=LOG_Error,m="Database given by the link \"%1\" contains non-lower case key \"%2\". This shouldn't happen, unless someone manually edited database.")
}
\ No newline at end of file