Browse Source

Merge branch 'refactor_commands' into develop

pull/12/head
Anton Tarasenko 2 years ago
parent
commit
c32ae4b972
  1. 2
      config/AcediaAliases_Colors.ini
  2. 8
      config/AcediaAliases_Commands.ini
  3. 5
      config/AcediaSystem.ini
  4. 22
      config/AcediaVoting.ini
  5. 3
      sources/Aliases/Aliases_Feature.uc
  6. 166
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc
  7. 31
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc
  8. 0
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
  9. 159
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc
  10. 585
      sources/BaseAPI/API/Commands/Command.uc
  11. 228
      sources/BaseAPI/API/Commands/CommandAPI.uc
  12. 910
      sources/BaseAPI/API/Commands/CommandDataBuilder.uc
  13. 958
      sources/BaseAPI/API/Commands/CommandParser.uc
  14. 54
      sources/BaseAPI/API/Commands/CommandRegistrationJob.uc
  15. 60
      sources/BaseAPI/API/Commands/Commands.uc
  16. 719
      sources/BaseAPI/API/Commands/Commands_Feature.uc
  17. 487
      sources/BaseAPI/API/Commands/PlayersParser.uc
  18. 21
      sources/BaseAPI/API/Commands/Tests/MockCommandA.uc
  19. 61
      sources/BaseAPI/API/Commands/Tests/MockCommandB.uc
  20. 0
      sources/BaseAPI/API/Commands/Tests/TEST_Command.uc
  21. 24
      sources/BaseAPI/API/Commands/Tests/TEST_CommandDataBuilder.uc
  22. 563
      sources/BaseAPI/API/Commands/Voting/TEST_Voting.uc
  23. 437
      sources/BaseAPI/API/Commands/Voting/Voting.uc
  24. 447
      sources/BaseAPI/API/Commands/Voting/VotingModel.uc
  25. 75
      sources/BaseAPI/API/Commands/Voting/VotingSettings.uc
  26. 1
      sources/BaseAPI/API/Math/Tests/TEST_BigInt.uc
  27. 12
      sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc
  28. 3
      sources/BaseAPI/Global.uc
  29. 4
      sources/Color/ColorAPI.uc
  30. 26
      sources/Features/Feature.uc
  31. 697
      sources/LevelAPI/Features/Commands/Command.uc
  32. 1148
      sources/LevelAPI/Features/Commands/CommandDataBuilder.uc
  33. 1017
      sources/LevelAPI/Features/Commands/CommandParser.uc
  34. 84
      sources/LevelAPI/Features/Commands/Commands.uc
  35. 627
      sources/LevelAPI/Features/Commands/Commands_Feature.uc
  36. 559
      sources/LevelAPI/Features/Commands/PlayersParser.uc
  37. 53
      sources/LevelAPI/Features/Commands/Tests/MockCommandB.uc
  38. 31
      sources/Manifest.uc
  39. 15
      sources/Players/PlayerNotificationQueue.uc
  40. 103
      sources/Users/ACommandUserGroups.uc
  41. 19
      sources/Users/Users_Feature.uc

2
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="TextPositive",value="rgb(60,220,20)")
record=(alias="TextNeutral",value="rgb(255,255,0)") record=(alias="TextNeutral",value="rgb(255,255,0)")
record=(alias="TextNegative",value="rgb(220,20,60)") 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="TextEmphasis",value="rgb(0,128,255)")
record=(alias="TextOk",value="rgb(0,255,0)") record=(alias="TextOk",value="rgb(0,255,0)")
record=(alias="TextWarning",value="rgb(255,128,0)") record=(alias="TextWarning",value="rgb(255,128,0)")

8
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"

5
config/AcediaSystem.ini

@ -1,5 +1,8 @@
; Every single option in this config should be considered [ADVANCED]. ; Every single option in this config should be considered [ADVANCED].
; DO NOT CHANGE THEM unless you are sure you know what you're doing. ; DO NOT CHANGE THEM unless you are sure you know what you're doing.
[AcediaCore.AcediaEnvironment]
debugMode=false
[AcediaCore.SideEffects] [AcediaCore.SideEffects]
; Acedia requires adding its own `GameRules` to listen to many different ; Acedia requires adding its own `GameRules` to listen to many different
; game events. ; game events.
@ -150,7 +153,7 @@ maximumNotifyTime=20
TextDefault=(R=255,G=255,B=255,A=255) TextDefault=(R=255,G=255,B=255,A=255)
TextHeader=(R=128,G=0,B=128,A=255) TextHeader=(R=128,G=0,B=128,A=255)
TextSubHeader=(R=147,G=112,B=219,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) TextEmphasis=(R=0,G=128,B=255,A=255)
TextPositive=(R=0,G=128,B=0,A=255) TextPositive=(R=0,G=128,B=0,A=255)
TextNeutral=(R=255,G=255,B=0,A=255) TextNeutral=(R=255,G=255,B=0,A=255)

22
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"

3
sources/Aliases/Aliases_Feature.uc

@ -112,15 +112,12 @@ protected function SwapConfig(FeatureConfig config)
if (newConfig == none) { if (newConfig == none) {
return; return;
} }
_.memory.Free(weaponAliasSource);
DropSources();
weaponAliasSource = GetSource(newConfig.weaponAliasSource); weaponAliasSource = GetSource(newConfig.weaponAliasSource);
colorAliasSource = GetSource(newConfig.colorAliasSource); colorAliasSource = GetSource(newConfig.colorAliasSource);
featureAliasSource = GetSource(newConfig.featureAliasSource); featureAliasSource = GetSource(newConfig.featureAliasSource);
entityAliasSource = GetSource(newConfig.entityAliasSource); entityAliasSource = GetSource(newConfig.entityAliasSource);
commandAliasSource = GetSource(newConfig.commandAliasSource); commandAliasSource = GetSource(newConfig.commandAliasSource);
LoadCustomSources(newConfig.customSource); LoadCustomSources(newConfig.customSource);
_.alias._reloadSources();
} }
private function LoadCustomSources( private function LoadCustomSources(

166
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 <https://www.gnu.org/licenses/>.
*/
class ACommandFakers extends Command
dependsOn(VotingModel);
var private array<UserID> 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 {
}

31
sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandHelp.uc → sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc

@ -102,21 +102,22 @@ protected function Finalizer()
protected function BuildData(CommandDataBuilder builder) protected function BuildData(CommandDataBuilder builder)
{ {
builder.Name(P("help")).Group(P("core")) builder.Name(P("help"));
.Summary(P("Displays detailed information about available commands.")); builder.Group(P("core"));
builder.OptionalParams() builder.Summary(P("Displays detailed information about available commands."));
.ParamTextList(P("commands")) builder.OptionalParams();
.Describe(P("Displays information about all specified commands.")); builder.ParamTextList(P("commands"));
builder.Option(P("aliases"))
.Describe(P("When displaying available commands, specifying this flag" builder.Option(P("aliases"));
@ "will additionally make command to display all of their available" builder.Describe(P("When displaying available commands, specifying this flag will additionally"
@ "aliases.")) @ "make command to display all of their available aliases."));
.Option(P("list"))
.Describe(P("Display available commands. Optionally command groups can" builder.Option(P("list"));
@ "be specified and then only commands from such groups will be" builder.Describe(P("Display available commands. Optionally command groups can be specified and"
@ "listed. Otherwise all commands will be displayed.")) @ "then only commands from such groups will be listed. Otherwise all commands will"
.OptionalParams() @ "be displayed."));
.ParamTextList(P("groups")); builder.OptionalParams();
builder.ParamTextList(P("groups"));
} }
protected function Executed(Command.CallData callData, EPlayer callerPlayer) protected function Executed(Command.CallData callData, EPlayer callerPlayer)

0
sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandNotify.uc → sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc

159
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 <https://www.gnu.org/licenses/>.
*/
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<Voting> 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 {
}

585
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 <https://www.gnu.org/licenses/>.
*/
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<EPlayer> 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 "<command> <sub_command>").
//
// Using sub-command is not optional, but if none defined (in `BuildData()`) / specified by
// the player - an empty (`name.IsEmpty()`) one is automatically created / used.
struct SubCommand {
// Cannot be `none`
var Text name;
// Can be `none`
var Text description;
var array<Parameter> required;
var array<Parameter> 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<Parameter> required;
var array<Parameter> 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<SubCommand> subCommands;
var protected array<Option> 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<SubCommand> subCommands;
local array<Option> 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<Parameter> parameters) {
local int i;
for (i = 0; i < parameters.length; i += 1) {
_.memory.Free(parameters[i].displayName);
_.memory.Free(parameters[i].variableName);
_.memory.Free(parameters[i].aliasSourceName);
}
}
/// 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<EPlayer> 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<EPlayer> 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<EPlayer> ParseTargets(Parser parser, EPlayer callerPlayer) {
local array<EPlayer> 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 {
}

228
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 <https://www.gnu.org/licenses/>.
*/
class CommandAPI extends AcediaObject;
// Classes registered to be registered in async way
var private array< class<Command> > 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<Command> _popPending() {
local class<Command> 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<Command> 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<Command> 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<Command> 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 {
}

910
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 <https://www.gnu.org/licenses/>.
*/
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<Command.SubCommand> subcommands;
var private array<Command.Option> 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<byte> subcommandsIsOptional;
var private array<byte> 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<Command.Parameter> 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.")
}

958
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 <https://www.gnu.org/licenses/>.
*/
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<Command.Option> 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<Command.Option> usedOptions;
// Literals that can be used as boolean values
var private array<string> booleanTrueEquivalents;
var private array<string> 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<Command.SubCommand> 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<Command.Parameter> requiredParameters,
array<Command.Parameter> 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<Command.Parameter> 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<Command.Parameter> 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<EPlayer> 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.")
}

54
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 <https://www.gnu.org/licenses/>.
*/
class CommandRegistrationJob extends SchedulerJob;
var private class<Command> 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
{
}

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

719
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 <https://www.gnu.org/licenses/>.
*/
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<Voting> processClass;
/// Must be guaranteed to not be `none` and lower case as an invariant
var public Text processName;
};
/// Auxiliary struct for passing name of the command to call plus, optionally, additional
/// 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<Text> commandDelimiters;
/// Registered commands, recorded as (<command_name>, <command_instance>) pairs.
/// Keys should be deallocated when their entry is removed.
var private HashTable registeredCommands;
/// [`HashTable`] of "<command_group_name>" <-> [`ArrayList`] of commands pairs to allow quick fetch
/// of commands belonging to a single group
var private HashTable groupedCommands;
/// [`Voting`]s that were already successfully loaded, ensuring that each has a unique name
var private array<NamedVoting> loadedVotings;
/// Currently running voting process.
/// This feature doesn't actively track when voting ends, so reference can be non-`none` even if
/// voting has already ended.
var private Voting currentVoting;
/// An array of [`Voting`] objects that have been successfully loaded and
/// each object has a unique name.
var private array<NamedVoting> registeredVotings;
/// When this flag is set to true, mutate input becomes available despite [`useMutateInput`] flag to
/// allow to unlock server in case of an error
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<Voting> processClass;
if (currentVoting != none && currentVoting.HasEnded()) {
_.memory.Free(currentVoting);
currentVoting = none;
}
if (currentVoting != none) {
return SVR_AlreadyInProgress;
}
for (i = 0; i < registeredVotings.length; i += 1) {
if (processName.Compare(registeredVotings[i].processName, SCASE_INSENSITIVE)) {
processClass = registeredVotings[i].processClass;
}
}
if (processClass == none) {
return SVR_UnknownVoting;
}
currentVoting = Voting(_.memory.Allocate(processClass));
currentVoting.Start(votingSettingsName);
return SVR_Success;
}
/// Registers a new voting class to be accessible through the [`Commands_Feature`].
///
/// When a voting class is registered, players can access it using the standard AcediaCore's "vote"
/// command.
/// However, note that registering a voting class is not mandatory for it to be usable.
/// In fact, if you want to prevent players from initiating a particular voting, you should avoid
/// registering it in this feature.
public final function RegisterVotingClass(class<Voting> newVotingClass) {
local int i;
local ACommandVote votingCommand;
local NamedVoting newRecord;
local Text votingName;
if (newVotingClass == none) return;
votingCommand = GetVotingCommand();
if (votingCommand == none) return;
// We can freely release this reference here, since another reference is guaranteed to be kept in registered command
_.memory.Free(votingCommand);
// But just to make sure
if (!votingCommand.IsAllocated()) return;
// First we check whether we already added this class
for (i = 0; i < registeredVotings.length; i += 1) {
if (registeredVotings[i].processClass == newVotingClass) {
return;
}
}
votingName = newVotingClass.static.GetPreferredName();
if (votingName.Compare(P("yes")) || votingName.Compare(P("no"))) {
_.logger.Auto(errYesNoVotingNamesReserved).ArgClass(newVotingClass).Arg(votingName);
return;
}
// Check for duplicates
for (i = 0; i < registeredVotings.length; i += 1) {
if (registeredVotings[i].processName.Compare(votingName)) {
_.logger
.Auto(errVotingWithSameNameAlreadyRegistered)
.ArgClass(newVotingClass)
.Arg(votingName)
.ArgClass(registeredVotings[i].processClass);
return;
}
}
newRecord.processClass = newVotingClass;
newRecord.processName = votingName;
registeredVotings[registeredVotings.length] = newRecord;
votingCommand.AddVotingInfo(votingName, newVotingClass);
}
/// Unregisters a voting class from the [`Commands_Feature`], preventing players from accessing it
/// through the standard AcediaCore "vote" command.
///
/// This method does not stop any existing voting processes associated with the unregistered class.
///
/// Use this method to remove a voting class that is no longer needed or to prevent players from
/// initiating a particular voting. Note that removing a voting class is a linear operation that may
/// take some time if many votings are currently registered. It is not expected to be a common
/// operation and should be used sparingly.
public final function RemoveVotingClass(class<Voting> newVotingClass) {
local int i;
local ACommandVote votingCommand;
if (newVotingClass == none) {
return;
}
for (i = 0; i < registeredVotings.length; i += 1) {
if (registeredVotings[i].processClass == newVotingClass) {
_.memory.Free(registeredVotings[i].processName);
registeredVotings.Remove(i, 1);
}
}
votingCommand = GetVotingCommand();
if (votingCommand == none) {
return;
}
// Simply rebuild the whole voting set from scratch
votingCommand.ResetVotingInfo();
for (i = 0; i < registeredVotings.length; i += 1) {
votingCommand.AddVotingInfo(
registeredVotings[i].processName,
registeredVotings[i].processClass);
}
_.memory.Free(votingCommand);
}
/// Registers given command class, making it available.
///
/// # Errors
///
/// Returns `true` if command was successfully registered and `false` otherwise`.
///
/// If `commandClass` provides command with a name that is already taken
/// (comparison is case-insensitive) by a different command - a warning will be
/// logged and newly passed `commandClass` discarded.
public final function bool RegisterCommand(class<Command> commandClass) {
local Text commandName, groupName;
local ArrayList groupArray;
local Command newCommandInstance, existingCommandInstance;
if (commandClass == none) return false;
if (registeredCommands == none) return false;
newCommandInstance = Command(_.memory.Allocate(commandClass, true));
commandName = newCommandInstance.GetName();
groupName = newCommandInstance.GetGroupName();
// Check for duplicates and report them
existingCommandInstance = Command(registeredCommands.GetItem(commandName));
if (existingCommandInstance != none) {
_.logger.Auto(errCommandDuplicate)
.ArgClass(existingCommandInstance.class)
.Arg(commandName)
.ArgClass(commandClass);
_.memory.Free(groupName);
_.memory.Free(newCommandInstance);
_.memory.Free(existingCommandInstance);
return false;
}
// Otherwise record new command
// `commandName` used as a key, do not deallocate it
registeredCommands.SetItem(commandName, newCommandInstance);
// Add to grouped collection
groupArray = groupedCommands.GetArrayList(groupName);
if (groupArray == none) {
groupArray = _.collections.EmptyArrayList();
}
groupArray.AddItem(newCommandInstance);
groupedCommands.SetItem(groupName, groupArray);
_.memory.Free4(groupArray, groupName, commandName, newCommandInstance);
return true;
}
/// Removes command of given class from the list of registered commands.
///
/// Removing once registered commands is not an action that is expected to be performed under normal
/// circumstances and it is not efficient.
/// It is linear on the current amount of commands.
public final function RemoveCommand(class<Command> commandClass) {
local int i;
local CollectionIterator iter;
local Command nextCommand;
local Text nextCommandName;
local array<Text> commandGroup;
local array<Text> keysToRemove;
if (commandClass == none) return;
if (registeredCommands == none) return;
for (iter = registeredCommands.Iterate(); !iter.HasFinished(); iter.Next()) {
nextCommand = Command(iter.Get());
nextCommandName = Text(iter.GetKey());
if (nextCommand == none || nextCommandName == none || nextCommand.class != commandClass) {
_.memory.Free2(nextCommand, nextCommandName);
continue;
}
keysToRemove[keysToRemove.length] = nextCommandName;
commandGroup[commandGroup.length] = nextCommand.GetGroupName();
_.memory.Free(nextCommand);
}
iter.FreeSelf();
for (i = 0; i < keysToRemove.length; i += 1) {
registeredCommands.RemoveItem(keysToRemove[i]);
_.memory.Free(keysToRemove[i]);
}
for (i = 0; i < commandGroup.length; i += 1) {
RemoveClassFromGroup(commandClass, commandGroup[i]);
}
_.memory.FreeMany(commandGroup);
}
/// Returns command based on a given name.
///
/// Name of the registered `Command` to return is case-insensitive.
///
/// If no command with such name was registered - returns `none`.
public final function Command GetCommand(BaseText commandName) {
local Text commandNameLowerCase;
local Command commandInstance;
if (commandName == none) return none;
if (registeredCommands == none) return none;
commandNameLowerCase = commandName.LowerCopy();
commandInstance = Command(registeredCommands.GetItem(commandNameLowerCase));
commandNameLowerCase.FreeSelf();
return commandInstance;
}
/// Returns array of names of all available commands.
public final function array<Text> GetCommandNames() {
local array<Text> emptyResult;
if (registeredCommands != none) {
return registeredCommands.GetTextKeys();
}
return emptyResult;
}
/// Returns array of names of all available commands belonging to the group [`groupName`].
public final function array<Text> GetCommandNamesInGroup(BaseText groupName) {
local int i;
local ArrayList groupArray;
local Command nextCommand;
local array<Text> result;
if (groupedCommands == none) return result;
groupArray = groupedCommands.GetArrayList(groupName);
if (groupArray == none) return result;
for (i = 0; i < groupArray.GetLength(); i += 1) {
nextCommand = Command(groupArray.GetItem(i));
if (nextCommand != none) {
result[result.length] = nextCommand.GetName();
}
_.memory.Free(nextCommand);
}
return result;
}
/// Returns all available command groups' names.
public final function array<Text> GetGroupsNames() {
local array<Text> emptyResult;
if (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<Command> 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<NamedVoting> 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.")
}

487
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 <https://www.gnu.org/licenses/>.
*/
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: "#<integer>" (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: "!<selector>". Specifying "!" in front of selector will select all players
//! that do not match it instead.
//!
//! Grouped selectors: "['<selector1>', '<selector2>', ... '<selectorN>']".
//! Specified selectors are process in order: from left to right.
//! First selector works as usual and selects a set of players.
//! All the following selectors either expand that list (additive ones, without "!" prefix) or
//! remove specific players from the list (the ones with "!" prefix).
//! Examples of that:
//!
//! * "[@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<EPlayer> playersSnapshot;
// Players, selected according to selectors we have parsed so far
var private array<EPlayer> currentSelection;
// Have we parsed our first selector?
// We need this to know whether to start with the list of
// all players (if first selector removes them) or
// with empty list (if first selector adds them).
var private bool parsedFirstSelector;
// Will be equal to a single-element array [","], used for parsing
var private array<Text> selectorDelimiters;
var const int TSELF, TME, TADMIN, TALL, TNOT, TKEY, TMACRO, TCOMMA;
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 "@<macroText>").
// 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 "@<macroText>").
// 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<EPlayer> GetPlayers() {
local int i;
local array<EPlayer> result;
for (i = 0; i < currentSelection.length; i += 1) {
if (currentSelection[i].IsExistent()) {
result[result.length] = EPlayer(currentSelection[i].Copy());
}
}
return result;
}
/// Parses players from `parser` according to the currently present players.
///
/// Array of parsed players can be retrieved by `self.GetPlayers()` method.
///
/// Returns `true` if parsing was successful and `false` otherwise.
public final function bool ParseWith(Parser parser) {
local Parser.ParserState confirmedState;
if (parser == none) return false;
if (!parser.Ok()) return false;
if (parser.HasFinished()) return false;
Reset();
confirmedState = parser.Skip().GetCurrentState();
if (!parser.Match(T(TOPEN_BRACKET)).Ok()) {
ParseSelector(parser.RestoreState(confirmedState));
if (parser.Ok()) {
return true;
}
return false;
}
while (parser.Ok() && !parser.HasFinished()) {
confirmedState = parser.Skip().GetCurrentState();
if (parser.Match(T(TCLOSE_BRACKET)).Ok()) {
return true;
}
parser.RestoreState(confirmedState);
if (parsedFirstSelector) {
parser.Match(T(TCOMMA)).Skip();
}
ParseSelector(parser);
parser.Skip();
}
parser.Fail();
return false;
}
// Resets this object to initial state before parsing and update
// `playersSnapshot` to contain current players.
private final function Reset() {
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"
}

21
sources/LevelAPI/Features/Commands/Tests/MockCommandA.uc → sources/BaseAPI/API/Commands/Tests/MockCommandA.uc

@ -21,16 +21,17 @@ class MockCommandA extends Command;
protected function BuildData(CommandDataBuilder builder) protected function BuildData(CommandDataBuilder builder)
{ {
builder.ParamObject(P("just_obj")) builder.ParamObject(P("just_obj"));
.ParamArrayList(P("manyLists")) builder.ParamArrayList(P("manyLists"));
.OptionalParams() builder.OptionalParams();
.ParamObject(P("last_obj")); builder.ParamObject(P("last_obj"));
builder.SubCommand(P("simple"))
.ParamBooleanList(P("isItSimple?")) builder.SubCommand(P("simple"));
.ParamInteger(P("integer variable"), P("int")) builder.ParamBooleanList(P("isItSimple?"));
.OptionalParams() builder.ParamInteger(P("integer variable"), P("int"));
.ParamNumberList(P("numeric list"), P("list")) builder.OptionalParams();
.ParamTextList(P("another list")); builder.ParamNumberList(P("numeric list"), P("list"));
builder.ParamTextList(P("another list"));
} }
defaultproperties defaultproperties

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

0
sources/LevelAPI/Features/Commands/Tests/TEST_Command.uc → sources/BaseAPI/API/Commands/Tests/TEST_Command.uc

24
sources/LevelAPI/Features/Commands/Tests/TEST_CommandDataBuilder.uc → sources/BaseAPI/API/Commands/Tests/TEST_CommandDataBuilder.uc

@ -24,27 +24,33 @@ class TEST_CommandDataBuilder extends TestCase
protected static function CommandDataBuilder PrepareBuilder() protected static function CommandDataBuilder PrepareBuilder()
{ {
local CommandDataBuilder builder; local CommandDataBuilder builder;
builder = builder = CommandDataBuilder(__().memory.Allocate(class'CommandDataBuilder'));
CommandDataBuilder(__().memory.Allocate(class'CommandDataBuilder')); builder.ParamNumber(P("var"));
builder.ParamNumber(P("var")).ParamText(P("str_var"), P("otherName")); builder.ParamText(P("str_var"), P("otherName"));
builder.OptionalParams(); builder.OptionalParams();
builder.Describe(P("Simple command")); builder.Describe(P("Simple command"));
builder.ParamBooleanList(P("list"), PBF_OnOff); builder.ParamBooleanList(P("list"), PBF_OnOff);
// Subcommands // Subcommands
builder.SubCommand(P("sub")).ParamArray(P("array_var")); builder.SubCommand(P("sub"));
builder.ParamArray(P("array_var"));
builder.Describe(P("Alternative command!")); builder.Describe(P("Alternative command!"));
builder.ParamIntegerList(P("int")); builder.ParamIntegerList(P("int"));
builder.SubCommand(P("empty")); builder.SubCommand(P("empty"));
builder.Describe(P("Empty one!")); builder.Describe(P("Empty one!"));
builder.SubCommand(P("huh")).ParamNumber(P("list")); builder.SubCommand(P("huh"));
builder.SubCommand(P("sub")).ParamObjectList(P("one_more"), P("but")); builder.ParamNumber(P("list"));
builder.SubCommand(P("sub"));
builder.ParamObjectList(P("one_more"), P("but"));
builder.Describe(P("Alternative command! Updated!")); builder.Describe(P("Alternative command! Updated!"));
// Options // 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.Option(P("Params"), P("d"));
builder.ParamBoolean(P("www"), PBF_YesNo, P("random")); builder.ParamBoolean(P("www"), PBF_YesNo, P("random"));
builder.OptionalParams().ParamIntegerList(P("www2")); builder.OptionalParams();
return builder.RequireTarget(); builder.ParamIntegerList(P("www2"));
builder.RequireTarget();
return builder;
} }
protected static function Command.SubCommand GetSubCommand( protected static function Command.SubCommand GetSubCommand(

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

437
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 <https://www.gnu.org/licenses/>.
*/
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<UserID> 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<EPlayer> 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<UserID> 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<EPlayer> 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<EPlayer> 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<EPlayer> 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<EPlayer> UpdateVoters() {
local int i;
local UserID nextID;
local array<EPlayer> currentPlayers;
local array<UserID> 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<EPlayer> 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}"
}

447
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 <https://www.gnu.org/licenses/>.
*/
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<UserID> 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<UserID> storedVotesFor, storedVotesAgainst;
/// List of users currently allowed to vote
var private array<UserID> 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<UserID> 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<UserID> 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<UserID> 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 {
}

75
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<string> allowedToSeeVotesGroup;
/// Specifies which group(s) of players are allowed to vote.
var public config array<string> allowedToVoteGroup;
protected function HashTable ToData() {
local int i;
local HashTable data;
local ArrayList arrayOfTexts;
data = __().collections.EmptyHashTable();
data.SetFloat(P("votingTime"), votingTime);
data.SetBool(P("allowSpectatorVoting"), allowSpectatorVoting);
arrayOfTexts = _.collections.EmptyArrayList();
for (i = 0; i < allowedToSeeVotesGroup.length; i += 1) {
arrayOfTexts.AddString(allowedToSeeVotesGroup[i]);
}
data.SetItem(P("allowedToSeeVotesGroup"), arrayOfTexts);
_.memory.Free(arrayOfTexts);
arrayOfTexts = _.collections.EmptyArrayList();
for (i = 0; i < allowedToVoteGroup.length; i += 1) {
arrayOfTexts.AddString(allowedToVoteGroup[i]);
}
data.SetItem(P("allowedToVoteGroup"), arrayOfTexts);
_.memory.Free(arrayOfTexts);
return data;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList arrayOfTexts;
if (source == none) {
return;
}
votingTime = source.GetFloat(P("votingTime"), 30.0);
allowSpectatorVoting = source.GetBool(P("allowSpectatorVoting"), false);
allowedToSeeVotesGroup.length = 0;
arrayOfTexts = source.GetArrayList(P("allowedToSeeVotesGroup"));
for (i = 0; i < arrayOfTexts.GetLength(); i += 1) {
allowedToSeeVotesGroup[allowedToSeeVotesGroup.length] = arrayOfTexts.GetString(i);
}
_.memory.Free(arrayOfTexts);
allowedToVoteGroup.length = 0;
arrayOfTexts = source.GetArrayList(P("allowedToVoteGroup"));
for (i = 0; i < arrayOfTexts.GetLength(); i += 1) {
allowedToVoteGroup[allowedToVoteGroup.length] = arrayOfTexts.GetString(i);
}
_.memory.Free(arrayOfTexts);
}
protected function DefaultIt() {
votingTime = 30.0;
allowSpectatorVoting = false;
allowedToSeeVotesGroup.length = 0;
allowedToVoteGroup.length = 0;
allowedToVoteGroup[0] = "everybody";
}
defaultproperties {
configName = "AcediaVoting"
}

1
sources/BaseAPI/API/Math/Tests/TEST_BigInt.uc

@ -99,7 +99,6 @@ protected static function SubTest_AddingSameSignValues() {
main = __().math.MakeBigInt_S("927641962323462271784269213864"); main = __().math.MakeBigInt_S("927641962323462271784269213864");
addition = __().math.MakeBigInt_S("16324234842947239847239239"); addition = __().math.MakeBigInt_S("16324234842947239847239239");
main.Add(addition); main.Add(addition);
Log("UMBRA" @ main.ToString());
TEST_ExpectTrue(main.ToString() == "927658286558305219024116453103"); TEST_ExpectTrue(main.ToString() == "927658286558305219024116453103");
main = __().math.MakeBigInt_S("16324234842947239847239239"); main = __().math.MakeBigInt_S("16324234842947239847239239");
addition = __().math.MakeBigInt_S("927641962323462271784269213864"); addition = __().math.MakeBigInt_S("927641962323462271784269213864");

12
sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc

@ -39,6 +39,8 @@ var private array<int> enabledFeaturesLifeVersions;
var private string manifestSuffix; var private string manifestSuffix;
var private const config bool debugMode;
var private LoggerAPI.Definition infoRegisteringPackage, infoAlreadyRegistered; var private LoggerAPI.Definition infoRegisteringPackage, infoAlreadyRegistered;
var private LoggerAPI.Definition errNotRegistered, errFeatureAlreadyEnabled; var private LoggerAPI.Definition errNotRegistered, errFeatureAlreadyEnabled;
var private LoggerAPI.Definition warnFeatureAlreadyEnabled; 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`]. /// Returns all packages registered in the caller [`AcediaEnvironment`].
public final function array< class<_manifest> > GetAvailablePackages() { public final function array< class<_manifest> > GetAvailablePackages() {
return availablePackages; return availablePackages;
@ -390,6 +401,7 @@ public final function DisableAllFeatures() {
defaultproperties defaultproperties
{ {
manifestSuffix = ".Manifest" manifestSuffix = ".Manifest"
debugMode = true
infoRegisteringPackage = (l=LOG_Info,m="Registering package \"%1\".") infoRegisteringPackage = (l=LOG_Info,m="Registering package \"%1\".")
infoAlreadyRegistered = (l=LOG_Info,m="Package \"%1\" is already registered.") infoAlreadyRegistered = (l=LOG_Info,m="Package \"%1\" is already registered.")
errNotRegistered = (l=LOG_Error,m="Package \"%1\" has failed to be registered.") errNotRegistered = (l=LOG_Error,m="Package \"%1\" has failed to be registered.")

3
sources/BaseAPI/Global.uc

@ -50,6 +50,7 @@ var public UserAPI users;
var public PlayersAPI players; var public PlayersAPI players;
var public JsonAPI json; var public JsonAPI json;
var public SchedulerAPI scheduler; var public SchedulerAPI scheduler;
var public CommandAPI commands;
var public AvariceAPI avarice; var public AvariceAPI avarice;
var public AcediaEnvironment environment; var public AcediaEnvironment environment;
@ -92,6 +93,7 @@ protected function Initialize() {
players = PlayersAPI(memory.Allocate(class'PlayersAPI')); players = PlayersAPI(memory.Allocate(class'PlayersAPI'));
scheduler = SchedulerAPI(memory.Allocate(class'SchedulerAPI')); scheduler = SchedulerAPI(memory.Allocate(class'SchedulerAPI'));
avarice = AvariceAPI(memory.Allocate(class'AvariceAPI')); avarice = AvariceAPI(memory.Allocate(class'AvariceAPI'));
commands = CommandAPI(memory.Allocate(class'CommandAPI'));
environment = AcediaEnvironment(memory.Allocate(class'AcediaEnvironment')); environment = AcediaEnvironment(memory.Allocate(class'AcediaEnvironment'));
} }
@ -112,6 +114,7 @@ public function DropCoreAPI() {
players = none; players = none;
json = none; json = none;
scheduler = none; scheduler = none;
commands = none;
avarice = none; avarice = none;
default.myself = none; default.myself = none;
} }

4
sources/Color/ColorAPI.uc

@ -1070,13 +1070,13 @@ public final function bool ResolveShortTagColor(
} }
return false; return false;
} }
//LightGray=(R=211,G=211,B=211,A=255)
defaultproperties defaultproperties
{ {
TextDefault=(R=255,G=255,B=255,A=255) TextDefault=(R=255,G=255,B=255,A=255)
TextHeader=(R=128,G=0,B=128,A=255) TextHeader=(R=128,G=0,B=128,A=255)
TextSubHeader=(R=147,G=112,B=219,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) TextEmphasis=(R=0,G=128,B=255,A=255)
TextPositive=(R=60,G=220,B=20,A=255) TextPositive=(R=60,G=220,B=20,A=255)
TextNeutral=(R=255,G=255,B=0,A=255) TextNeutral=(R=255,G=255,B=0,A=255)

26
sources/Features/Feature.uc

@ -170,7 +170,6 @@ public static final function LoadConfigs()
/** /**
* Changes config for the caller `Feature` class. * 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 set initial config on this `Feature`'s start - specify it as a parameter
* to `EnableMe()` method. * to `EnableMe()` method.
* *
@ -181,18 +180,15 @@ private final function ApplyConfig(BaseText newConfigName)
{ {
local Text configNameCopy; local Text configNameCopy;
local FeatureConfig newConfig; local FeatureConfig newConfig;
newConfig =
FeatureConfig(configClass.static.GetConfigInstance(newConfigName)); newConfig = FeatureConfig(configClass.static.GetConfigInstance(newConfigName));
if (newConfig == none) if (newConfig == none) {
{
_.logger.Auto(errorBadConfigData).ArgClass(class); _.logger.Auto(errorBadConfigData).ArgClass(class);
// Fallback to "default" config // Fallback to "default" config
configNameCopy = _.text.FromString(defaultConfigName); configNameCopy = _.text.FromString(defaultConfigName);
configClass.static.NewConfig(configNameCopy); configClass.static.NewConfig(configNameCopy);
newConfig = newConfig = FeatureConfig(configClass.static.GetConfigInstance(configNameCopy));
FeatureConfig(configClass.static.GetConfigInstance(configNameCopy)); } else {
}
else {
configNameCopy = newConfigName.Copy(); configNameCopy = newConfigName.Copy();
} }
SwapConfig(newConfig); SwapConfig(newConfig);
@ -289,12 +285,8 @@ public static final function bool IsEnabled()
public static final function Feature EnableMe(BaseText configName) public static final function Feature EnableMe(BaseText configName)
{ {
local Feature myInstance; local Feature myInstance;
myInstance = GetEnabledInstance();
if (myInstance != none) DisableMe();
{
myInstance.ApplyConfig(configName);
return myInstance;
}
myInstance = Feature(__().memory.Allocate(default.class)); myInstance = Feature(__().memory.Allocate(default.class));
__().environment.EnableFeature(myInstance, configName); __().environment.EnableFeature(myInstance, configName);
return myInstance; return myInstance;
@ -336,8 +328,8 @@ protected function OnEnabled(){}
protected function OnDisabled(){} protected function OnDisabled(){}
/** /**
* Will be called whenever caller `Feature` class must change it's config * Will be called whenever caller `Feature` class must change it's config parameters.
* parameters. This can be done both when the `Feature` is enabled or disabled. * It is guaranteed that `Feature` will be disabled during this call.
* *
* @param newConfigData New config that caller `Feature`'s class must use. * @param newConfigData New config that caller `Feature`'s class must use.
* We pass `FeatureConfig` value for performance and simplicity reasons, * We pass `FeatureConfig` value for performance and simplicity reasons,

697
sources/LevelAPI/Features/Commands/Command.uc

@ -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 <https://www.gnu.org/licenses/>.
*/
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<EPlayer> 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
// "<command> <sub_command>").
// Using sub-command is not optional, but if none defined
// (in `BuildData()`) / specified by the player - an empty (`name.IsEmpty()`)
// one is automatically created / used.
struct SubCommand
{
// Cannot be `none`
var Text name;
// Can be `none`
var Text description;
var array<Parameter> required;
var array<Parameter> 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<Parameter> required;
var array<Parameter> 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<SubCommand> subCommands;
var protected array<Option> 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<SubCommand> subCommands;
local array<Option> 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<Parameter> parameters)
{
local int i;
for (i = 0; i < parameters.length; i += 1)
{
_.memory.Free(parameters[i].displayName);
_.memory.Free(parameters[i].variableName);
_.memory.Free(parameters[i].aliasSourceName);
}
}
/**
* 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<EPlayer> 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<EPlayer> 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<EPlayer> ParseTargets(
Parser parser,
EPlayer callerPlayer)
{
local array<EPlayer> 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
{
}

1148
sources/LevelAPI/Features/Commands/CommandDataBuilder.uc

File diff suppressed because it is too large Load Diff

1017
sources/LevelAPI/Features/Commands/CommandParser.uc

File diff suppressed because it is too large Load Diff

84
sources/LevelAPI/Features/Commands/Commands.uc

@ -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 <https://www.gnu.org/licenses/>.
*/
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<string> 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 = "!"
}

627
sources/LevelAPI/Features/Commands/Commands_Feature.uc

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Text> commandDelimiters;
// Registered commands, recorded as (<command_name>, <command_instance>) pairs.
// Keys should be deallocated when their entry is removed.
var private HashTable registeredCommands;
// `HashTable` of "<command_group_name>" <-> `ArrayList` of commands pairs
// to allow quick fetch of commands belonging to a single group
var private HashTable groupedCommands;
// 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<string> 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<Command> 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<Command> commandClass)
{
local int i;
local CollectionIterator iter;
local Command nextCommand;
local Text nextCommandName;
local array<Text> commandGroup;
local array<Text> keysToRemove;
if (commandClass == none) return;
if (registeredCommands == none) return;
for (iter = registeredCommands.Iterate(); !iter.HasFinished(); iter.Next())
{
nextCommand = Command(iter.Get());
nextCommandName = Text(iter.GetKey());
if ( nextCommand == none || nextCommandName == none
|| nextCommand.class != commandClass)
{
_.memory.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<Command> 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<Text> GetCommandNames()
{
local array<Text> emptyResult;
if (registeredCommands != none) {
return registeredCommands.GetTextKeys();
}
return emptyResult;
}
/**
* Returns array of names of all available commands belonging to the group
* `groupName`.
*
* @return Array of names of all available (registered) commands, belonging to
* the group `groupName`.
*/
public final function array<Text> GetCommandNamesInGroup(BaseText groupName)
{
local int i;
local ArrayList groupArray;
local Command nextCommand;
local array<Text> result;
if (groupedCommands == none) return result;
groupArray = groupedCommands.GetArrayList(groupName);
if (groupArray == none) return result;
for (i = 0; i < groupArray.GetLength(); i += 1)
{
nextCommand = Command(groupArray.GetItem(i));
if (nextCommand != none) {
result[result.length] = nextCommand.GetName();
}
_.memory.Free(nextCommand);
}
return result;
}
/**
* Returns all available command groups' names.
*
* @return Array of all available command groups' names.
*/
public final function array<Text> GetGroupsNames()
{
local array<Text> 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.")
}

559
sources/LevelAPI/Features/Commands/PlayersParser.uc

@ -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 <https://www.gnu.org/licenses/>.
*/
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: "#<integer>" (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: "!<selector>". Specifying "!" in front of selector
* will select all players that do not match it instead.
*
* Grouped selectors: "['<selector1>', '<selector2>', ... '<selectorN>']".
* Specified selectors are process in order: from left to right.
* First selector works as usual and selects a set of players.
* All the following selectors either
* expand that list (additive ones, without "!" prefix)
* or remove specific players from the list (the ones with "!" prefix).
* Examples of that:
* *. "[@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<EPlayer> playersSnapshot;
// Players, selected according to selectors we have parsed so far
var private array<EPlayer> currentSelection;
// Have we parsed our first selector?
// We need this to know whether to start with the list of
// all players (if first selector removes them) or
// with empty list (if first selector adds them).
var private bool parsedFirstSelector;
// Will be equal to a single-element array [","], used for parsing
var private array<Text> selectorDelimiters;
var const int TSELF, TME, TADMIN, TALL, TNOT, TKEY, TMACRO, TCOMMA;
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 "@<macroText>").
// 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 "@<macroText>").
// 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<EPlayer> GetPlayers()
{
local int i;
local array<EPlayer> result;
for (i = 0; i < currentSelection.length; i += 1)
{
if (currentSelection[i].IsExistent()) {
result[result.length] = EPlayer(currentSelection[i].Copy());
}
}
return result;
}
/**
* Parses players from `parser` according to the currently present players.
*
* Array of parsed players can be retrieved by `self.GetPlayers()` method.
*
* @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"
}

53
sources/LevelAPI/Features/Commands/Tests/MockCommandB.uc

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

31
sources/Manifest.uc

@ -43,19 +43,20 @@ defaultproperties
testCases(14) = class'TEST_TextTemplate' testCases(14) = class'TEST_TextTemplate'
testCases(15) = class'TEST_User' testCases(15) = class'TEST_User'
testCases(16) = class'TEST_Memory' testCases(16) = class'TEST_Memory'
testCases(17) = class'TEST_ArrayList' testCases(17) = class'TEST_Voting'
testCases(18) = class'TEST_HashTable' testCases(18) = class'TEST_ArrayList'
testCases(19) = class'TEST_CollectionsMixed' testCases(19) = class'TEST_HashTable'
testCases(20) = class'TEST_Iterator' testCases(20) = class'TEST_CollectionsMixed'
testCases(21) = class'TEST_Command' testCases(21) = class'TEST_Iterator'
testCases(22) = class'TEST_CommandDataBuilder' testCases(22) = class'TEST_Command'
testCases(23) = class'TEST_LogMessage' testCases(23) = class'TEST_CommandDataBuilder'
testCases(24) = class'TEST_SchedulerAPI' testCases(24) = class'TEST_LogMessage'
testCases(25) = class'TEST_BigInt' testCases(25) = class'TEST_SchedulerAPI'
testCases(26) = class'TEST_DatabaseCommon' testCases(26) = class'TEST_BigInt'
testCases(27) = class'TEST_LocalDatabase' testCases(27) = class'TEST_DatabaseCommon'
testCases(28) = class'TEST_DBConnection' testCases(28) = class'TEST_LocalDatabase'
testCases(29) = class'TEST_AcediaConfig' testCases(29) = class'TEST_DBConnection'
testCases(30) = class'TEST_UTF8EncoderDecoder' testCases(30) = class'TEST_AcediaConfig'
testCases(31) = class'TEST_AvariceStreamReader' testCases(31) = class'TEST_UTF8EncoderDecoder'
testCases(32) = class'TEST_AvariceStreamReader'
} }

15
sources/Players/PlayerNotificationQueue.uc

@ -28,7 +28,7 @@ class PlayerNotificationQueue extends AcediaObject
struct Notification { struct Notification {
var Text title; var Text title;
var Text body; var Text body;
var float time; var float duration;
}; };
var private array<Notification> notificationQueue; var private array<Notification> notificationQueue;
/// Reference to the `PlayerController` for the player that owns this queue /// 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 /// 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; local Notification newNotification;
if (body == none) { if (body == none) {
@ -91,6 +91,7 @@ public final function AddNotification(BaseText title, BaseText body, float time)
newNotification.title = title.Copy(); newNotification.title = title.Copy();
} }
newNotification.body = body.Copy(); newNotification.body = body.Copy();
newNotification.duration = duration;
notificationQueue[notificationQueue.length] = newNotification; notificationQueue[notificationQueue.length] = newNotification;
if (!IsUpdateScheduled()) { if (!IsUpdateScheduled()) {
SetupNextNotification(none); SetupNextNotification(none);
@ -193,14 +194,14 @@ private function SetupNextNotification(Timer callerInstance) {
} }
nextNotification = notificationQueue[0]; nextNotification = notificationQueue[0];
notificationQueue.Remove(0, 1); notificationQueue.Remove(0, 1);
nextNotification.time = FMin(nextNotification.time, maximumNotifyTime); nextNotification.duration = FMin(nextNotification.duration, maximumNotifyTime);
if (nextNotification.time <= 0) { if (nextNotification.duration <= 0) {
nextNotification.time = 10.0; nextNotification.duration = 10.0;
} }
// And print // And print
playerController.ClearProgressMessages(); playerController.ClearProgressMessages();
playerController.SetProgressTime(nextNotification.time); playerController.SetProgressTime(nextNotification.duration);
if (nextNotification.title != none) { if (nextNotification.title != none) {
upperCaseTitle = nextNotification.title.UpperMutableCopy(); upperCaseTitle = nextNotification.title.UpperMutableCopy();
upperCaseTitle.ChangeDefaultColor(_.color.TextHeader); upperCaseTitle.ChangeDefaultColor(_.color.TextHeader);
@ -209,7 +210,7 @@ private function SetupNextNotification(Timer callerInstance) {
_.memory.Free(upperCaseTitle); _.memory.Free(upperCaseTitle);
} }
PrintNotifcationAt(playerController, nextNotification.body, titleShift, 4 - titleShift); PrintNotifcationAt(playerController, nextNotification.body, titleShift, 4 - titleShift);
ScheduleUpdate(nextNotification.time); ScheduleUpdate(nextNotification.duration);
_.memory.Free2(nextNotification.title, nextNotification.body); _.memory.Free2(nextNotification.title, nextNotification.body);
} }

103
sources/Users/ACommandUserGroups.uc

@ -22,56 +22,59 @@ class ACommandUserGroups extends Command
protected function BuildData(CommandDataBuilder builder) protected function BuildData(CommandDataBuilder builder)
{ {
builder.Name(P("usergroups")) builder.Name(P("usergroups"));
.Group(P("admin")) builder.Group(P("admin"));
.Summary(P("User groups management.")) builder.Summary(P("User groups management."));
.Describe(P("Allows to add/remove user groups and users to these:" builder.Describe(P("Allows to add/remove user groups and users to these: groups. Changes made"
@ "groups. Changes made by it will always affect current session," @ "by it will always affect current session, but might fail to be saved in case user groups"
@ "but might fail to be saved in case user groups are stored in" @ "are stored in a database that is either corrupted or in read-only mode."));
@ "a database that is either corrupted or in read-only mode."));
builder.SubCommand(P("list")) builder.SubCommand(P("list"));
.Describe(P("Lists specified groups along with users that belong to" builder.Describe(P("Lists specified groups along with users that belong to them. If no groups"
@ "them. If no groups were specified at all - lists all available" @ "were specified at all - lists all available groups."));
@ "groups.")) builder.OptionalParams();
.OptionalParams() builder.ParamTextList(P("groups"));
.ParamTextList(P("groups"));
builder.SubCommand(P("add")) builder.SubCommand(P("add"));
.Describe(P("Adds a new group")) builder.Describe(P("Adds a new group"));
.ParamText(P("group_name")); builder.ParamText(P("group_name"));
builder.SubCommand(P("remove"))
.Describe(P("Removes a group")) builder.SubCommand(P("remove"));
.ParamText(P("group_name")); builder.Describe(P("Removes a group"));
builder.SubCommand(P("addplayer")) builder.ParamText(P("group_name"));
.Describe(F("Adds new user to the group, specified by the player"
@ "selector. Can add several players at once." builder.SubCommand(P("addplayer"));
@ "Allows to also optionally specify annotation" builder.Describe(P("Adds new user to the group, specified by the player selector. Can add"
@ "(human-readable name) that can be thought of as" @ "several players at once. Allows to also optionally specify annotation (human-readable"
@ "a {$TextEmphasis comment}. If annotation isn't specified" @ "name) that can be thought of as a `comment`. If annotation isn't specified current"
@ "current nickname will be used as one.")) @ "nickname will be used as one."));
.ParamText(P("group_name")) builder.ParamText(P("group_name"));
.ParamPlayers(P("player_selector")) builder.ParamPlayers(P("player_selector"));
.OptionalParams() builder.OptionalParams();
.ParamText(P("annotation")); builder.ParamText(P("annotation"));
builder.SubCommand(P("removeplayer"))
.Describe(P("Removes user from the group, specified by player selector." builder.SubCommand(P("removeplayer"));
@ "Can remove several players at once.")) builder.Describe(P("Removes user from the group, specified by player selector."
.ParamText(P("group_name")) @ "Can remove several players at once."));
.ParamPlayers(P("player_selector")); builder.ParamText(P("group_name"));
builder.SubCommand(P("adduser")) builder.ParamPlayers(P("player_selector"));
.Describe(F("Adds new user to the group. Allows to also optionally"
@ "specify annotation (human-readable name) that can be thought of" builder.SubCommand(P("adduser"));
@ "as a {$TextEmphasis comment}.")) builder.Describe(P("Adds new user to the group. Allows to also optionally specify annotation"
.ParamText(P("group_name")) @ "(human-readable name) that can be thought of as a `comment`."));
.ParamText(P("user_id")) builder.ParamText(P("group_name"));
.OptionalParams() builder.ParamText(P("user_id"));
.ParamText(P("annotation")); builder.OptionalParams();
builder.SubCommand(P("removeuser")) builder.ParamText(P("annotation"));
.Describe(P("Removes user from the group. User can be specified by both"
@ "user's id or annotation, with id taking priority.")) builder.SubCommand(P("removeuser"));
.ParamText(P("group_name")) builder.Describe(P("Removes user from the group. User can be specified by both user's id or"
.ParamText(P("user_name")); @ "annotation, with id taking priority."));
builder.Option(P("force")) builder.ParamText(P("group_name"));
.Describe(P("Allows to force usage of invalid user IDs.")); 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) protected function Executed(CallData arguments, EPlayer instigator)

19
sources/Users/Users_Feature.uc

@ -80,7 +80,13 @@ protected function OnEnabled()
feature.RegisterCommand(class'ACommandUserGroups'); feature.RegisterCommand(class'ACommandUserGroups');
feature.FreeSelf(); feature.FreeSelf();
} }
if (_server.IsAvailable()) {
LoadUserData(); LoadUserData();
SetupPersistentData(usePersistentData);
} else {
_.logger.Auto(errNoServerCore);
return;
}
} }
protected function OnDisabled() protected function OnDisabled()
@ -113,17 +119,6 @@ protected function SwapConfig(FeatureConfig config)
useDatabaseForGroupsData = newConfig.useDatabaseForGroupsData; useDatabaseForGroupsData = newConfig.useDatabaseForGroupsData;
groupsDatabaseLink = newConfig.groupsDatabaseLink; groupsDatabaseLink = newConfig.groupsDatabaseLink;
availableUserGroups = newConfig.localUserGroup; 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.") 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.") 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.") 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.") 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.")
} }
Loading…
Cancel
Save