Compare commits

...

63 Commits

Author SHA1 Message Date
Anton Tarasenko f15e704ce2 Fix usage of freed object 1 year ago
Anton Tarasenko 4c0b2a6f1d Update version number 1 year ago
Anton Tarasenko e8ae6fd8d1 Change votings to output outcome message in one line 1 year ago
Anton Tarasenko 93604c7690 Remove temporary comment 1 year ago
Anton Tarasenko 159f1dc5a1 Fix storing user groups in databases not working 1 year ago
Anton Tarasenko 15b1abc8c3 Fix infinite loop bug with UnflectAPI rollback 1 year ago
Anton Tarasenko ff31ef2472 Remove duplicate voting tests 1 year ago
Anton Tarasenko c2a8a5c7de Fix configs 1 year ago
Anton Tarasenko a27e893359 Change `InfoQueryHandler` to respect "help" command's name change 1 year ago
Anton Tarasenko 0ad28839bb Change "usergroups" commands to adapt to new `CommandsAPI` 1 year ago
Anton Tarasenko a26b0adf05 Change `UserAPI` to make "all" user group contain all players 1 year ago
Anton Tarasenko 80cecd1d20 Change Voting classes to work with new API 1 year ago
Anton Tarasenko 7ff806b104 Change built-in commands to support new `CommandAPI` 1 year ago
Anton Tarasenko be9ba80549 Add back voting tests 1 year ago
Anton Tarasenko d7ed4776b4 Fix formatting in some commands-related classes 1 year ago
Anton Tarasenko a7f1a98548 Change CommandAPI and feature to use tools 1 year ago
Anton Tarasenko 86228a960c Add signal classes for `CommandAPI` 1 year ago
Anton Tarasenko c1dccfc2d6 Change data convertion flag to `true` for all feature configs 1 year ago
Anton Tarasenko 001170e092 Add `IsSpectator()` check for `EPlayer` 1 year ago
Anton Tarasenko 87c7ee01bb Add `IntoStrings()` method to `TextAPI` 1 year ago
Anton Tarasenko c76f875620 Add tool classes for `Commands_Feature` 1 year ago
Anton Tarasenko 23dc639536 Change what files are used to store permissions 1 year ago
Anton Tarasenko 7ad3ca55f6 Fix return values in some signals/methods 1 year ago
Anton Tarasenko 757ae39b2e Change environment to not be in debug mode by default 1 year ago
Anton Tarasenko 70c41a5926 Add cleanup for `UnflectApi` 2 years ago
dkanus 632ff8ef2c Merge pull request 'Add voice signal for catching voice messages to `CharApi`' (#14) from feature_voice_messages_api into develop 2 years ago
Anton Tarasenko 87570a4906 Add `OnVoiceMessage()` signal function to `ChatApi` 2 years ago
Anton Tarasenko 8a6891793f Rename local variable to avoid conflicts 2 years ago
Anton Tarasenko 41db52ded8 Add `SendVoiceMessage()` command to `EPlayer` 2 years ago
Anton Tarasenko 4ede173e62 Add `BuiltInVoiceMessage` enum 2 years ago
Anton Tarasenko 26ed844044 Change comment style for `ChatApi` 2 years ago
dkanus ecb29d3759 Merge pull request 'Add "sideeffects" command for viewing active side effects' (#13) from feature_side_effects_command into develop 2 years ago
Anton Tarasenko 9187598252 Add `sideffects` command 2 years ago
Anton Tarasenko fec8535c0f Make `User`'s equality depend on its `UserID` 2 years ago
Anton Tarasenko 3d2518ccdd Add method for checking whether `SideEffect` is active 2 years ago
dkanus 0354396d31 Merge pull request 'Add `UnflectApi`' (#12) from feature_uflect into develop 2 years ago
Anton Tarasenko ced0f1dd99 Add `UnflectApi` 2 years ago
Anton Tarasenko f51cf8ed64 Refactor `SideEffect`s to work better with `UnflectApi` 2 years ago
Anton Tarasenko 10b673ccec Merge branch 'enchance_notify_channels' into develop 2 years ago
Anton Tarasenko c32ae4b972 Merge branch 'refactor_commands' into develop 2 years ago
Anton Tarasenko fc52110d16 Add channel support for notifications 2 years ago
Anton Tarasenko 5daa6e8e02 Remove minimal rounds requirement from voting config 2 years ago
dkanus 4cf66442cb Merge pull request 'Refactor `JsonPointer` into separate (im)mutable versions' (#10) from feature_immutable_json_pointer into develop 2 years ago
Anton Tarasenko 6321368665 Refactor `JsonPointer` into separate (im)mutable versions 2 years ago
Anton Tarasenko 0432c1c074 Add basic voting functionality 2 years ago
Anton Tarasenko 028c2eaf83 Fix documentation and naming for `VotingModel` 2 years ago
Anton Tarasenko 0746aef7c7 Document `VotingModel` class 2 years ago
Anton Tarasenko fed80cc76b Fix noficications not respecting provided duration time 2 years ago
Anton Tarasenko 3924355e79 Fix after merge file duplicate 2 years ago
Anton Tarasenko a9fd4abb64 Merge branch 'develop' into refactor_commands 2 years ago
dkanus 64de53dcd0 Merge pull request 'Add player notifications' (#9) from feat_player_announcements into develop 2 years ago
Anton Tarasenko 070a33b410 Add player notifications 2 years ago
Anton Tarasenko 2606c0d001 Add `VotingModel` for vote counting 2 years ago
Anton Tarasenko 52f9aa5aa5 Add command locks 2 years ago
Anton Tarasenko 677dd84e90 Fix style for remaining `CommandAPI`-related classes 2 years ago
Anton Tarasenko c4247a67d0 Fix style for `CommandParser` 2 years ago
Anton Tarasenko 3d7f11688c Add async methods for registering commands 2 years ago
Anton Tarasenko 9265e97c59 Move `CommandAPI` into base API 2 years ago
Anton Tarasenko 41909851f5 Refactor `Commands_Feature` to use API 2 years ago
Anton Tarasenko 087d8624d3 Change how config is swapped for `Feature`s 2 years ago
Anton Tarasenko 58d1d686b9 Add auto-highlighting of commands' descriptions 2 years ago
Anton Tarasenko 06915cbddf Change auto-alias resolving in commands to be useful 2 years ago
Anton Tarasenko 626124335d Fix style for `CommandDataBuilder` 2 years ago
  1. 2
      config/AcediaAliases_Colors.ini
  2. 8
      config/AcediaAliases_Commands.ini
  3. 117
      config/AcediaCommands.ini
  4. 11
      config/AcediaSystem.ini
  5. 22
      config/AcediaVoting.ini
  6. 3
      sources/Aliases/Aliases_Feature.uc
  7. 163
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc
  8. 256
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandHelp.uc
  9. 69
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
  10. 197
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandSideEffects.uc
  11. 220
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc
  12. 805
      sources/BaseAPI/API/Commands/Command.uc
  13. 1578
      sources/BaseAPI/API/Commands/CommandAPI.uc
  14. 939
      sources/BaseAPI/API/Commands/CommandDataBuilder.uc
  15. 249
      sources/BaseAPI/API/Commands/CommandList.uc
  16. 983
      sources/BaseAPI/API/Commands/CommandParser.uc
  17. 66
      sources/BaseAPI/API/Commands/CommandPermissions.uc
  18. 81
      sources/BaseAPI/API/Commands/CommandRegistrationJob.uc
  19. 230
      sources/BaseAPI/API/Commands/Commands.uc
  20. 708
      sources/BaseAPI/API/Commands/Commands_Feature.uc
  21. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandAdded_Signal.uc
  22. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandAdded_Slot.uc
  23. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandRemoved_Signal.uc
  24. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnCommandRemoved_Slot.uc
  25. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingAdded_Signal.uc
  26. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingAdded_Slot.uc
  27. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingEnded_Signal.uc
  28. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingEnded_Slot.uc
  29. 39
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingRemoved_Signal.uc
  30. 38
      sources/BaseAPI/API/Commands/Events/CommandsAPI_OnVotingRemoved_Slot.uc
  31. 494
      sources/BaseAPI/API/Commands/PlayersParser.uc
  32. 21
      sources/BaseAPI/API/Commands/Tests/MockCommandA.uc
  33. 61
      sources/BaseAPI/API/Commands/Tests/MockCommandB.uc
  34. 0
      sources/BaseAPI/API/Commands/Tests/TEST_Command.uc
  35. 24
      sources/BaseAPI/API/Commands/Tests/TEST_CommandDataBuilder.uc
  36. 351
      sources/BaseAPI/API/Commands/Tests/TEST_Voting.uc
  37. 306
      sources/BaseAPI/API/Commands/Tools/CmdItemsTool.uc
  38. 142
      sources/BaseAPI/API/Commands/Tools/CommandsTool.uc
  39. 177
      sources/BaseAPI/API/Commands/Tools/ItemCard.uc
  40. 119
      sources/BaseAPI/API/Commands/Tools/VotingsTool.uc
  41. 863
      sources/BaseAPI/API/Commands/Voting/Voting.uc
  42. 440
      sources/BaseAPI/API/Commands/Voting/VotingModel.uc
  43. 132
      sources/BaseAPI/API/Commands/Voting/VotingPermissions.uc
  44. 1
      sources/BaseAPI/API/Math/Tests/TEST_BigInt.uc
  45. 277
      sources/BaseAPI/API/SideEffects/SideEffect.uc
  46. 212
      sources/BaseAPI/API/SideEffects/SideEffectAPI.uc
  47. 76
      sources/BaseAPI/API/Unflect/FunctionReplacement.uc
  48. 30
      sources/BaseAPI/API/Unflect/Tests/MockInitialClass.uc
  49. 33
      sources/BaseAPI/API/Unflect/Tests/MockReplacerClass.uc
  50. 87
      sources/BaseAPI/API/Unflect/Tests/TEST_Unflect.uc
  51. 29
      sources/BaseAPI/API/Unflect/TypeCast.uc
  52. 49
      sources/BaseAPI/API/Unflect/UClass.uc
  53. 30
      sources/BaseAPI/API/Unflect/UClassCast.uc
  54. 28
      sources/BaseAPI/API/Unflect/UField.uc
  55. 30
      sources/BaseAPI/API/Unflect/UFieldCast.uc
  56. 35
      sources/BaseAPI/API/Unflect/UFunction.uc
  57. 30
      sources/BaseAPI/API/Unflect/UFunctionCast.uc
  58. 41
      sources/BaseAPI/API/Unflect/UProperty.uc
  59. 30
      sources/BaseAPI/API/Unflect/UPropertyCast.uc
  60. 30
      sources/BaseAPI/API/Unflect/UState.uc
  61. 30
      sources/BaseAPI/API/Unflect/UStateCast.uc
  62. 48
      sources/BaseAPI/API/Unflect/UStruct.uc
  63. 30
      sources/BaseAPI/API/Unflect/UStructCast.uc
  64. 28
      sources/BaseAPI/API/Unflect/UTextBuffer.uc
  65. 32
      sources/BaseAPI/API/Unflect/Unflect.uc
  66. 432
      sources/BaseAPI/API/Unflect/UnflectApi.uc
  67. 15
      sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc
  68. 14
      sources/BaseAPI/Global.uc
  69. 439
      sources/Chat/ChatAPI.uc
  70. 39
      sources/Chat/Events/ChatAPI_OnVoiceMessage_Signal.uc
  71. 41
      sources/Chat/Events/ChatAPI_OnVoiceMessage_Slot.uc
  72. 81
      sources/Chat/Tests/TEST_VoiceMessages.uc
  73. 59
      sources/Chat/Unflect/Unflect_ChatApi_Controller.uc
  74. 1
      sources/ClientAPI/ClientAcediaAdapter.uc
  75. 4
      sources/Color/ColorAPI.uc
  76. 24
      sources/Data/Collections/Collection.uc
  77. 65
      sources/Data/Database/Connection/DBCache.uc
  78. 33
      sources/Data/Database/Connection/DBConnection.uc
  79. 60
      sources/Data/Database/DBAPI.uc
  80. 14
      sources/Data/Database/Database.uc
  81. 37
      sources/Data/Database/Local/DBRecord.uc
  82. 20
      sources/Data/Database/Local/LocalDatabaseInstance.uc
  83. 28
      sources/Features/Feature.uc
  84. 5
      sources/Features/FeatureConfig.uc
  85. 2
      sources/Gameplay/KF1Frontend/Health/KF1_HealthComponent.uc
  86. 37
      sources/InfoQueryHandler/InfoQueryHandler.uc
  87. 115
      sources/KFImplementation/Core/KF1_SideEffectAPI.uc
  88. 2
      sources/KFImplementation/Server/ServerSideEffects.uc
  89. 2
      sources/KFImplementation/Server/Unreal/BroadcastsAPI/BroadcastEventsObserver.uc
  90. 65
      sources/KFImplementation/Server/Unreal/BroadcastsAPI/BroadcastSideEffect.uc
  91. 51
      sources/KFImplementation/Server/Unreal/BroadcastsAPI/KF1_BroadcastAPI.uc
  92. 43
      sources/KFImplementation/Server/Unreal/GameRulesAPI/GameRulesSideEffect.uc
  93. 36
      sources/KFImplementation/Server/Unreal/GameRulesAPI/KF1_GameRulesAPI.uc
  94. 164
      sources/LevelAPI/API/SideEffects/SideEffect.uc
  95. 76
      sources/LevelAPI/API/SideEffects/SideEffectAPI.uc
  96. 1
      sources/LevelAPI/API/Time/Timer.uc
  97. 5
      sources/LevelAPI/AcediaAdapter.uc
  98. 8
      sources/LevelAPI/CoreGlobal.uc
  99. 697
      sources/LevelAPI/Features/Commands/Command.uc
  100. 1148
      sources/LevelAPI/Features/Commands/CommandDataBuilder.uc
  101. Some files were not shown because too many files have changed in this diff Show More

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

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"

117
config/AcediaCommands.ini

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

11
config/AcediaSystem.ini

@ -1,6 +1,9 @@
; Every single option in this config should be considered [ADVANCED].
; DO NOT CHANGE THEM unless you are sure you know what you're doing.
[AcediaCore.SideEffects]
[AcediaCore.AcediaEnvironment]
debugMode=false
[AcediaCore.ServerSideEffects]
; Acedia requires adding its own `GameRules` to listen to many different
; game events.
; It's normal for a mod to add its own game rules: game rules are
@ -140,13 +143,17 @@ requiredGroup=""
maxVisibleLineWidth=80
maxTotalLineWidth=108
[AcediaCore.PlayerNotificationQueue]
; Maximum time that a notification is allowed to be displayed on the player's screen
maximumNotifyTime=20
[AcediaCore.ColorAPI]
; Changing these values will alter color's definitions in `ColorAPI`,
; changing how Acedia behaves
TextDefault=(R=255,G=255,B=255,A=255)
TextHeader=(R=128,G=0,B=128,A=255)
TextSubHeader=(R=147,G=112,B=219,A=255)
TextSubtle=(R=128,G=128,B=128,A=255)
TextSubtle=(R=211,G=211,B=211,A=255)
TextEmphasis=(R=0,G=128,B=255,A=255)
TextPositive=(R=0,G=128,B=0,A=255)
TextNeutral=(R=255,G=255,B=0,A=255)

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) {
return;
}
_.memory.Free(weaponAliasSource);
DropSources();
weaponAliasSource = GetSource(newConfig.weaponAliasSource);
colorAliasSource = GetSource(newConfig.colorAliasSource);
featureAliasSource = GetSource(newConfig.featureAliasSource);
entityAliasSource = GetSource(newConfig.entityAliasSource);
commandAliasSource = GetSource(newConfig.commandAliasSource);
LoadCustomSources(newConfig.customSource);
_.alias._reloadSources();
}
private function LoadCustomSources(

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

@ -0,0 +1,163 @@
/**
* 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 static function StaticFinalizer() {
__().memory.FreeMany(default.fakers);
default.fakers.length = 0;
}
protected function BuildData(CommandDataBuilder builder) {
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,
CommandPermissions permissions
) {
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 static function /*borrow*/ array<UserID> BorrowDebugVoters() {
return default.fakers;
}
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 = _.commands.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;
local Voting currentVoting;
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;
}
default.fakers = fakers;
currentVoting = _.commands.GetCurrentVoting();
if (currentVoting != none) {
currentVoting.SetDebugVoters(default.fakers);
_.memory.Free(currentVoting);
}
}
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 =_.commands.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 {
preferredName = "fakers"
}

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

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

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

@ -0,0 +1,69 @@
/**
* 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 ACommandNotify extends Command
dependsOn(ChatApi);
protected function BuildData(CommandDataBuilder builder) {
builder.Group(P("core"));
builder.Summary(P("Notifies players with provided message."));
builder.ParamText(P("message"));
builder.OptionalParams();
builder.ParamNumber(P("duration"));
builder.Describe(P("Notify to players message with distinct header and body."));
builder.RequireTarget();
builder.Option(P("title"));
builder.Describe(P("Specify the optional title of the notification."));
builder.ParamText(P("title"));
builder.Option(P("channel"));
builder.Describe(P("Specify the optional channel. A channel is a grouping mechanism used to"
@ "control the display of related notifications. Only last message from the same channel is"
@ "stored in queue."));
builder.ParamText(P("channel_name"));
}
protected function ExecutedFor(
EPlayer target,
CallData arguments,
EPlayer instigator,
CommandPermissions permissions
) {
local Text title, message, plainTitle, plainMessage;
plainMessage = arguments.parameters.GetText(P("message"));
if (arguments.options.HasKey(P("title"))) {
plainTitle = arguments.options.GetTextBy(P("/title/title"));
}
title = _.text.FromFormatted(plainTitle);
message = _.text.FromFormatted(plainMessage);
target.Notify(
title,
message,
arguments.parameters.GetFloat(P("duration")),
arguments.options.GetTextBy(P("/channel/channel_name")));
_.memory.Free4(title, message, plainTitle, plainMessage);
}
defaultproperties {
preferredName = "notify"
}

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

@ -0,0 +1,197 @@
/**
* 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 ACommandSideEffects extends Command;
// Maps `UserID` to `ArrayList` with side effects listed for that player last time
var private HashTable displayedLists;
protected function Constructor() {
super.Constructor();
displayedLists = _.collections.EmptyHashTable();
}
protected function Finalizer() {
super.Finalizer();
_.memory.Free(displayedLists);
displayedLists = none;
}
protected function BuildData(CommandDataBuilder builder) {
builder.Group(P("debug"));
builder.Summary(P("Displays information about current side effects."));
builder.Describe(P("This command allows to display current side effects, optionally filtering"
@ "them by specified package names."));
builder.OptionalParams();
builder.ParamTextList(P("package_names"));
builder.SubCommand(P("show"));
builder.Describe(P("This sub-command is only usable after side effects have been shown"
@ "at least once. It takes an index from the last displayed list and displays a verbose"
@ "information about it."));
builder.ParamInteger(P("side_effect_number"));
builder.Option(P("verbose"));
builder.Describe(P("Display verbose information about each side effect."));
}
protected function Executed(
CallData arguments,
EPlayer instigator,
CommandPermissions permissions
) {
local UserID playerID;
local array<SideEffect> relevantSideEffects;
local ArrayList packagesList, storedSideEffectsList;
playerID = instigator.GetUserID();
if (arguments.subCommandName.IsEmpty()) {
relevantSideEffects = _.sideEffects.GetAll();
packagesList = arguments.parameters.GetArrayList(P("package_names"));
FilterSideEffects(/*out*/ relevantSideEffects, packagesList);
_.memory.Free(packagesList);
DisplaySideEffects(relevantSideEffects, arguments.options.HasKey(P("verbose")));
// Store new side effect list
storedSideEffectsList = _.collections.NewArrayList(relevantSideEffects);
displayedLists.SetItem(playerID, storedSideEffectsList);
_.memory.FreeMany(relevantSideEffects);
_.memory.Free(storedSideEffectsList);
} else {
ShowInfoFor(playerID, arguments.parameters.GetInt(P("side_effect_number")));
}
_.memory.Free(playerID);
}
private function FilterSideEffects(out array<SideEffect> sideEffects, ArrayList allowedPackages) {
local int i, j;
local int packagesLength;
local bool matchedPackage;
local Text nextSideEffectPackage, nextAllowedPackage;
if (allowedPackages == none) return;
if (allowedPackages.GetLength() <= 0) return;
packagesLength = allowedPackages.GetLength();
while (i < sideEffects.length) {
nextSideEffectPackage = sideEffects[i].GetPackage();
matchedPackage = false;
for (j = 0; j < packagesLength; j += 1) {
nextAllowedPackage = allowedPackages.GetText(j);
if (nextAllowedPackage.Compare(nextSideEffectPackage, SCASE_INSENSITIVE)) {
matchedPackage = true;
_.memory.Free(nextAllowedPackage);
break;
}
_.memory.Free(nextAllowedPackage);
}
if (!matchedPackage) {
sideEffects.Remove(i, 1);
} else {
i += 1;
}
_.memory.Free(nextSideEffectPackage);
}
}
private function DisplaySideEffects(array<SideEffect> toDisplay, bool verbose) {
local int i;
local MutableText nextPrefix;
if (toDisplay.length <= 0) {
callerConsole.Write(F("List of side effects is {$TextNeutral empty}."));
}
for (i = 0; i < toDisplay.length; i += 1) {
nextPrefix = _.text.FromIntM(i + 1);
nextPrefix.Append(P("."));
DisplaySideEffect(toDisplay[i], nextPrefix, verbose);
_.memory.Free(nextPrefix);
}
}
private function DisplaySideEffect(SideEffect toDisplay, BaseText prefix, bool verbose) {
local Text effectName, effectDescription, effectPackage, effectSource, effectStatus;
if (toDisplay == none) {
return;
}
if (prefix != none) {
callerConsole.Write(prefix);
callerConsole.Write(P(" "));
}
effectName = toDisplay.GetName();
effectPackage = toDisplay.GetPackage();
effectSource = toDisplay.GetSource();
effectStatus = toDisplay.GetStatus();
callerConsole.UseColor(_.color.TextEmphasis);
callerConsole.Write(P("["));
callerConsole.Write(effectPackage);
callerConsole.Write(P(" \\ "));
callerConsole.Write(effectSource);
callerConsole.Write(P("] "));
callerConsole.ResetColor();
callerConsole.Write(effectName);
callerConsole.Write(P(" {"));
callerConsole.Write(effectStatus);
callerConsole.WriteLine(P("}"));
if (verbose) {
effectDescription = toDisplay.GetDescription();
callerConsole.WriteBlock(effectDescription);
}
_.memory.Free5(effectName, effectDescription, effectPackage, effectSource, effectStatus);
}
private function ShowInfoFor(UserID playerID, int sideEffectIndex) {
local SideEffect toDisplay;
local ArrayList sideEffectList;
if (playerID == none) {
return;
}
if (sideEffectIndex <= 0) {
callerConsole.WriteLine(F("Specified side effect index {$TextNegative isn't positive}!"));
return;
}
sideEffectList = displayedLists.GetArrayList(playerID);
if (sideEffectList == none) {
callerConsole.WriteLine(F("{$TextNegative Cannot display} side effect by index without"
@ "first listing them. Call {$TextEmphasis sideeffects} command without"
@ "{$TextEmphasis show} subcommand first."));
return;
}
if (sideEffectIndex > sideEffectList.GetLength()) {
callerConsole.WriteLine(F("Specified side effect index is {$TextNegative out of bounds}."));
_.memory.Free(sideEffectList);
return;
}
// Above we checked that `sideEffectIndex` lies within `[0; sideEffectList.GetLength()]` segment
// This means that `sideEffectIndex - 1` points at non-`none` value
toDisplay = SideEffect(sideEffectList.GetItem(sideEffectIndex - 1));
if (!_.sideEffects.IsRegistered(toDisplay)) {
callerConsole.UseColorOnce(_.color.TextWarning);
callerConsole.WriteLine(P("Selected side effect is no longer active!"));
}
DisplaySideEffect(toDisplay, none, true);
_.memory.Free2(toDisplay, sideEffectList);
}
defaultproperties {
preferredName = "sideeffects"
}

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

@ -0,0 +1,220 @@
/**
* 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
dependson(CommandAPI)
dependson(VotingModel);
var private CommandDataBuilder dataBuilder;
protected function Constructor() {
ResetVotingInfo();
_.commands.OnVotingAdded(self).connect = AddVotingInfo;
_.commands.OnVotingRemoved(self).connect = HandleRemovedVoting;
_.chat.OnVoiceMessage(self).connect = VoteWithVoice;
}
protected function Finalizer() {
super.Finalizer();
_.memory.Free(dataBuilder);
dataBuilder = none;
_.commands.OnVotingAdded(self).Disconnect();
_.commands.OnVotingRemoved(self).Disconnect();
_.chat.OnVoiceMessage(self).Disconnect();
}
protected function BuildData(CommandDataBuilder builder) {
builder.Group(P("core"));
builder.Summary(P("Allows players to initiate any available voting."
@ "Voting options themselves are specified as sub-commands."));
builder.Describe(P("Default command simply displaces information about current vote."));
dataBuilder.SubCommand(P("yes"));
builder.Describe(P("Vote `yes` on the current vote."));
dataBuilder.SubCommand(P("no"));
builder.Describe(P("Vote `no` on the current vote."));
builder.Option(P("force"));
builder.Describe(P("Tries to force voting to end immediately with the desired result."));
}
protected function Executed(
CallData arguments,
EPlayer instigator,
CommandPermissions permissions
) {
local bool forcingVoting;
local VotingModel.ForceEndingType forceType;
local Voting currentVoting;
forcingVoting = arguments.options.HasKey(P("force"));
currentVoting = _.commands.GetCurrentVoting();
if (arguments.subCommandName.IsEmpty()) {
DisplayInfoAboutVoting(instigator, currentVoting);
} else if (arguments.subCommandName.Compare(P("yes"), SCASE_INSENSITIVE)) {
CastVote(currentVoting, instigator, true);
forceType = FET_Success;
} else if (arguments.subCommandName.Compare(P("no"), SCASE_INSENSITIVE)) {
CastVote(currentVoting, instigator, false);
forceType = FET_Failure;
} else if (StartVoting(arguments, currentVoting, instigator)) {
_.memory.Free(currentVoting);
currentVoting = _.commands.GetCurrentVoting();
forceType = FET_Success;
} else {
forcingVoting = false;
}
if (currentVoting != none && !currentVoting.HasEnded() && forcingVoting) {
if (currentVoting.ForceEnding(instigator, forceType) == FEO_Forbidden) {
callerConsole
.WriteLine(F("You {$TextNegative aren't allowed} to forcibly end current voting"));
}
}
_.memory.Free(currentVoting);
}
private final function VoteWithVoice(EPlayer sender, ChatApi.BuiltInVoiceMessage message) {
local Voting currentVoting;
currentVoting = _.commands.GetCurrentVoting();
if (message == BIVM_AckYes) {
CastVote(currentVoting, sender, true);
}
if (message == BIVM_AckNo) {
CastVote(currentVoting, sender, false);
}
_.memory.Free(currentVoting);
}
/// Adds sub-command information about given voting with a given name.
public final function AddVotingInfo(class<Voting> processClass, Text processName) {
if (processName == none) return;
if (processClass == none) return;
if (dataBuilder == none) return;
dataBuilder.SubCommand(processName);
processClass.static.AddInfo(dataBuilder);
commandData = dataBuilder.BorrowData();
}
public final function HandleRemovedVoting(class<Voting> votingClass) {
local int i;
local array<Text> votingsNames;
ResetVotingInfo();
// Rebuild the whole voting data
votingsNames = _.commands.GetAllVotingsNames();
for (i = 0; i < votingsNames.length; i += 1) {
AddVotingInfo(_.commands.GetVotingClass(votingsNames[i]), votingsNames[i]);
}
_.memory.FreeMany(votingsNames);
}
/// Clears all sub-command information added from [`Voting`]s.
public final function ResetVotingInfo() {
_.memory.Free(dataBuilder);
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 bool StartVoting(
CallData arguments,
Voting currentVoting,
EPlayer instigator
) {
local Voting newVoting;
local User callerUser;
local CommandAPI.VotingConfigInfo pair;
local CommandAPI.StartVotingResult result;
callerUser = instigator.GetIdentity();
pair = _.commands.ResolveVotingForUser(arguments.subCommandName, callerUser);
_.memory.Free(callerUser);
if (pair.votingClass == none) {
callerConsole
.UseColor(_.color.TextFailure)
.Write(P("Unknown voting option \""))
.Write(arguments.subCommandName)
.WriteLine(P("\""));
return false;
}
if (pair.usageForbidden) {
callerConsole
.UseColor(_.color.TextFailure)
.Write(P("You aren't allowed to start \""))
.Write(arguments.subCommandName)
.WriteLine(P("\" voting"));
return false;
}
result = _.commands.StartVoting(pair, arguments.parameters);
Log("Result:" @ result);
// Handle errors.
// `SVR_UnknownVoting` is impossible, since we've already checked that
// `pair.votingClass != none`)
if (result == SVR_AlreadyInProgress) {
callerConsole
.UseColor(_.color.TextWarning)
.WriteLine(P("Another voting is already in progress!"));
return false;
}
if (result == SVR_NoVoters) {
callerConsole
.UseColor(_.color.TextWarning)
.WriteLine(P("There are no players eligible for that voting."));
return false;
}
// Cast a vote from instigator
newVoting = _.commands.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);
return false;
}
_.memory.Free(newVoting);
return true;
}
defaultproperties {
preferredName = "vote"
}

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

@ -0,0 +1,805 @@
/**
* 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,
/// Unknown short option key was specified
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,
/// Targets are specified incorrectly (for targeted commands only)
CET_IncorrectTargetList,
// No targets are specified (for targeted commands only)
CET_EmptyTargetList
};
/// Structure that contains all the information about how `Command` was called.
struct CallData {
/// Targeted players (if applicable)
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.
var public ErrorType parsingError;
/// Optional error textual name of the object (parameter, option, etc.)
/// that caused it.
var public Text errorCause;
};
/// Possible types of parameters.
enum ParameterType {
/// Parses into `BoolBox`
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 {
/// Name of the sub command. Cannot be `none`.
var Text name;
/// Human-readable description of the subcommand. Can be `none`.
var Text description;
/// List of required parameters of this [`Command`].
var array<Parameter> required;
/// List of optional parameters of this [`Command`].
var array<Parameter> optional;
};
/// Defines command's option (options are specified by "--long" or "-l").
/// Options are independent from sub-commands.
struct Option {
/// [`Option`]'s short name, i.e. a single letter "f" that can be specified
/// in, e.g. "-laf" type option listings
var BaseText.Character shortName;
/// [`Option`]'s full name, e.g. "--force".
var Text longName;
/// Human-readable description of the option. Can be `none`.
var Text description;
/// List of required parameters of this [`Command::Option`].
var array<Parameter> required;
/// List of required parameters of this [`Command::Option`].
var array<Parameter> optional;
};
/// Structure that defines what sub-commands and options command has
/// (and what parameters they take)
struct Data {
/// 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;
/// Available subcommands.
var protected array<SubCommand> subCommands;
/// Available options, common to all subcommands.
var protected array<Option> options;
/// `true` iff related [`Command`] targets players.
var protected bool requiresTarget;
};
var protected Data commandData;
/// Setting variable that defines a name that will be chosen for command by
/// default.
var protected const string preferredName;
/// Name that was used to register this command.
var protected Text usedName;
/// Settings variable that defines a class to be used for this [`Command`]'s
/// permissions config
var protected const class<CommandPermissions> permissionsConfigClass;
// We do not really ever need to create more than one instance of each class
// of `Command`, so we will simply store and reuse one created instance.
var private Command mainInstance;
/// When command is being executed we create several instances of
/// `ConsoleWriter` that can be used for command output.
/// They will also be automatically deallocated once command is executed.
///
/// DO NOT modify them or deallocate any of them manually.
///
/// This should make output more convenient and standardized.
///
/// 1. `publicConsole` - sends messages to all present players;
/// 2. `callerConsole` - sends messages to the player that called the command;
/// 3. `targetConsole` - sends messages to the player that is currently being
/// targeted (different each call of `ExecutedFor()` and `none` during
/// `Executed()` call);
/// 4. `othersConsole` - sends messaged to every player that is neither
/// "caller" or "target".
var protected ConsoleWriter publicConsole, othersConsole;
var protected ConsoleWriter callerConsole, targetConsole;
protected function Constructor() {
local CommandDataBuilder dataBuilder;
if (permissionsConfigClass != none) {
permissionsConfigClass.static.Initialize();
}
dataBuilder = CommandDataBuilder(_.memory.Allocate(class'CommandDataBuilder'));
// Let user fill-in the rest
BuildData(dataBuilder);
commandData = dataBuilder.BorrowData();
dataBuilder.FreeSelf();
dataBuilder = none;
}
protected function Finalizer() {
local int i;
local array<SubCommand> subCommands;
local array<Option> options;
DeallocateConsoles();
_.memory.Free(usedName);
_.memory.Free(commandData.summary);
usedName = none;
commandData.summary = none;
subCommands = commandData.subCommands;
for (i = 0; i < options.length; i += 1) {
_.memory.Free(subCommands[i].name);
_.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;
}
/// Initializes command, providing it with a specific name.
///
/// Argument cannot be `none`, otherwise initialization fails.
/// [`Command`] can only be successfully initialized once.
public final function bool Initialize(BaseText commandName) {
if (commandName == none) return false;
if (usedName != none) return false;
usedName = commandName.LowerCopy();
return true;
}
/// 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.
/// [`permissions`] is a config with permissions for this command call.
protected function Executed(
CallData arguments,
EPlayer instigator,
CommandPermissions permissions) {}
/// Overload this method to perform required actions when your command is called
/// with a given player as a target.
///
/// If several players have been specified - this method will be called once
/// for each.
///
/// If 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.
/// [`permissions`] is a config with permissions for this command call.
protected function ExecutedFor(
EPlayer target,
CallData arguments,
EPlayer instigator,
CommandPermissions permissions) {}
/// Returns an instance of command (of particular class) that is stored
/// "as a singleton" in command's class itself. Do not deallocate it.
public final static function Command GetInstance() {
if (default.mainInstance == none) {
default.mainInstance = Command(__().memory.Allocate(default.class));
}
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.
///
/// [`permissions`] argument is supposed to specify permissions with which this
/// command runs.
/// If [`permissionsConfigClass`] is `none`, it must always be `none`.
/// If [`permissionsConfigClass`] is not `none`, then [`permissions`] argument
/// being `none` should mean running with minimal priviledges.
public final function bool Execute(
CallData callData,
EPlayer callerPlayer,
CommandPermissions permissions
) {
local int i;
local array<EPlayer> targetPlayers;
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(usedName)
.Say(P("`"));
// `othersConsole` should also exist in time for `Executed()` call
othersConsole = _.console.ForAll().ButPlayer(callerPlayer);
Executed(callData, callerPlayer, permissions);
_.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, permissions);
_.memory.Free(othersConsole);
_.memory.Free(targetConsole);
}
}
othersConsole = none;
targetConsole = none;
DeallocateConsoles();
return true;
}
/// Auxiliary method that cleans up all data and deallocates all objects inside provided structure.
public final static function DeallocateCallData(/* take */ CallData callData) {
__().memory.Free(callData.subCommandName);
__().memory.Free(callData.parameters);
__().memory.Free(callData.options);
__().memory.Free(callData.errorCause);
__().memory.FreeMany(callData.targetPlayers);
if (callData.targetPlayers.length > 0) {
callData.targetPlayers.length = 0;
}
}
private final function CleanParameters(array<Parameter> parameters) {
local int i;
for (i = 0; i < parameters.length; i += 1) {
_.memory.Free(parameters[i].displayName);
_.memory.Free(parameters[i].variableName);
_.memory.Free(parameters[i].aliasSourceName);
}
}
/// Returns name (in lower case) of the caller command class.
public final static function Text GetPreferredName() {
return __().text.FromString(Locs(default.preferredName));
}
/// Returns name (in lower case) of the caller command class.
public final static function string GetPreferredName_S() {
return Locs(default.preferredName);
}
/// Returns name (in lower case) of the caller command class.
public final function Text GetName() {
if (usedName == none) {
return P("").Copy();
}
return usedName.LowerCopy();
}
/// Returns name (in lower case) of the caller command class.
public final function string GetName_S() {
if (usedName == none) {
return "";
}
return _.text.IntoString(/*take*/ usedName.LowerCopy());
}
/// Returns group name (in lower case) of the caller command class.
public final function Text GetGroupName() {
if (commandData.group == none) {
return P("").Copy();
}
return commandData.group.LowerCopy();
}
/// Returns group name (in lower case) of the caller command class.
public final function string GetGroupName_S() {
if (commandData.group == none) {
return "";
}
return _.text.IntoString(/*take*/ commandData.group.LowerCopy());
}
/// Loads permissions config with a given name for the caller [`Command`] class.
///
/// Permission configs describe allowed usage of the [`Command`].
/// Basic settings are contained inside [`CommandPermissions`], but commands
/// should derive their own child classes for storing their settings.
///
/// Returns `none` if caller [`Command`] class didn't specify custom permission
/// settings class or provided name is invalid (according to
/// [`BaseText::IsValidName()`]).
/// Otherwise guaranteed to return a config reference.
public final static function CommandPermissions LoadConfig(BaseText configName) {
if (configName == none) return none;
if (default.permissionsConfigClass == none) return none;
// This creates default config if it is missing
default.permissionsConfigClass.static.NewConfig(configName);
return CommandPermissions(default.permissionsConfigClass.static
.GetConfigInstance(configName));
}
/// Loads permissions config with a given name for the caller [`Command`] class.
///
/// Permission configs describe allowed usage of the [`Command`].
/// Basic settings are contained inside [`CommandPermissions`], but commands
/// should derive their own child classes for storing their settings.
///
/// Returns `none` if caller [`Command`] class didn't specify custom permission
/// settings class or provided name is invalid (according to
/// [`BaseText::IsValidName()`]).
/// Otherwise guaranteed to return a config reference.
public final static function CommandPermissions LoadConfig_S(string configName) {
local MutableText wrapper;
local CommandPermissions result;
wrapper = __().text.FromStringM(configName);
result = LoadConfig(wrapper);
__().memory.Free(wrapper);
return result;
}
/// Returns subcommands of caller [`Command`] according to the provided
/// permissions.
///
/// If provided `none` as permissions, returns all available sub commands.
public final function array<Text> GetSubCommands(optional CommandPermissions permissions) {
local int i, j;
local bool addSubCommand;
local array<string> forbiddenCommands;
local array<Text> result;
forbiddenCommands = permissions.forbiddenSubCommands;
if (permissions != none) {
forbiddenCommands = permissions.forbiddenSubCommands;
}
for (i = 0; i < commandData.subCommands.length; i += 1) {
addSubCommand = true;
for (j = 0; j < forbiddenCommands.length; j += 1) {
if (commandData.subCommands[i].name.ToString() ~= forbiddenCommands[j]) {
addSubCommand = false;
break;
}
}
if (addSubCommand) {
result[result.length] = commandData.subCommands[i].name.LowerCopy();
}
}
return result;
}
/// Returns sub commands of caller [`Command`] according to the provided
/// permissions.
///
/// If provided `none` as permissions, returns all available sub commands.
public final function array<string> GetSubCommands_S(optional CommandPermissions permissions) {
return _.text.IntoStrings(GetSubCommands(permissions));
}
/// Checks whether a given sub command (case insensitive) is allowed to be
/// executed with given permissions.
///
/// If `none` is passed as either argument, returns `true`.
///
/// Doesn't check for the existence of sub command, only that permissions do not
/// explicitly forbid it.
/// In case non-existing subcommand is passed as an argument, the result
/// should be considered undefined.
public final function bool IsSubCommandAllowed(
BaseText subCommand,
CommandPermissions permissions
) {
if (subCommand == none) return true;
if (permissions == none) return true;
return IsSubCommandAllowed_S(subCommand.ToString(), permissions);
}
/// Checks whether a given sub command (case insensitive) is allowed to be
/// executed with given permissions.
///
/// If `none` is passed for permissions, always returns `true`.
///
/// Doesn't check for the existence of sub command, only that permissions do not
/// explicitly forbid it.
/// In case non-existing sub command is passed as an argument, the result
/// should be considered undefined.
public final function bool IsSubCommandAllowed_S(
string subCommand,
CommandPermissions permissions
) {
local int i;
local array<string> forbiddenCommands;
if (permissions == none) {
return true;
}
forbiddenCommands = permissions.forbiddenSubCommands;
for (i = 0; i < forbiddenCommands.length; i += 1) {
if (subCommand ~= forbiddenCommands[i]) {
return false;
}
}
return true;
}
/// Returns `Command.Data` struct that describes caller `Command`.
///
/// Returned struct contains `Text` references that are used internally by
/// the `Command` and not their copies.
///
/// Generally this is undesired approach and leaves `Command` more vulnerable to
/// modification, but copying all the data inside would not only introduce
/// a largely pointless computational overhead, but also would require some
/// cumbersome logic.
/// This might change in the future, so deallocating any objects in the returned
/// `struct` would lead to undefined behavior.
public final function Data BorrowData() {
return commandData;
}
private final function DeallocateConsoles() {
if (publicConsole != none && publicConsole.IsAllocated()) {
_.memory.Free(publicConsole);
}
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;
}
/// 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;
}
defaultproperties {
preferredName = ""
permissionsConfigClass = none
}

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

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,939 @@
/**
* 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;
}
/// 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 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.
/// Group name is case-insensitive.
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
/// be 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;
// TODO: is this copying needed?
RecordSelection();
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`.
///
/// 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 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`.
///
/// 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 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 parameter that defines a set of players (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 ParamPlayers(BaseText name, optional BaseText variableName) {
if (name != none) {
PushParameter(NewParameter(name, CPT_PLAYERS, false, variableName));
}
}
/// Adds new parameter that defines a list of sets of players (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 ParamPlayersList(BaseText name,optional BaseText variableName) {
if (name != none) {
PushParameter(NewParameter(name, CPT_PLAYERS, true, variableName));
}
}
// 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;
}
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.")
}

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

@ -0,0 +1,249 @@
/**
* Config class for storing map lists.
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandList extends AcediaConfig
perObjectConfig
config(AcediaCommands);
//! `CommandList` describes a set of commands and votings that can be made
//! available to users inside Commands feature
//!
//! Optionally, permission configs can be specified for commands and votings,
//! allowing server admins to create command lists for different groups player
//! with the same commands, but different permissions.
// For storing `class<Command>` - `string` pairs in the config
struct CommandConfigStoragePair {
var public class<Command> cmd;
var public string config;
};
// For storing `class` - `string` pairs in the config
struct VotingConfigStoragePair {
var public class<Voting> vtn;
var public string config;
};
// For returning `class` - `Text` pairs into other Acedia classes
struct EntityConfigPair {
var public class<AcediaObject> class;
var public Text config;
};
/// Allows to specify if this list should only be added when server is running
/// in debug mode.
/// `true` means yes, `false` means that list will always be available.
var public config bool debugOnly;
/// Adds a command of specified class with a "default" permissions config.
var public config array< class<Command> > command;
/// Adds a command of specified class with specified permissions config
var public config array<CommandConfigStoragePair> commandWith;
/// Adds a voting of specified class with a "default" permissions config
var public config array< class<Voting> > voting;
/// Adds a voting of specified class with specified permissions config
var public config array<VotingConfigStoragePair> votingWith;
public final function array<EntityConfigPair> GetCommandData() {
local int i;
local EntityConfigPair nextPair;
local array<EntityConfigPair> result;
for (i = 0; i < command.length; i += 1) {
if (command[i] != none) {
nextPair.class = command[i];
result[result.length] = nextPair;
}
}
for (i = 0; i < commandWith.length; i += 1) {
if (commandWith[i].cmd != none) {
nextPair.class = commandWith[i].cmd;
if (commandWith[i].config != "") {
nextPair.config = _.text.FromString(commandWith[i].config);
}
result[result.length] = nextPair;
// Moved into the `result`
nextPair.config = none;
}
}
return result;
}
public final function array<EntityConfigPair> GetVotingData() {
local int i;
local EntityConfigPair nextPair;
local array<EntityConfigPair> result;
for (i = 0; i < voting.length; i += 1) {
if (voting[i] != none) {
nextPair.class = voting[i];
result[result.length] = nextPair;
}
}
for (i = 0; i < votingWith.length; i += 1) {
if (votingWith[i].vtn != none) {
nextPair.class = votingWith[i].vtn;
if (votingWith[i].config != "") {
nextPair.config = _.text.FromString(votingWith[i].config);
}
result[result.length] = nextPair;
// Moved into the `result`
nextPair.config = none;
}
}
return result;
}
protected function HashTable ToData() {
local int i;
local ArrayList entityArray;
local HashTable result, innerPair;
result = _.collections.EmptyHashTable();
result.SetBool(P("debugOnly"), debugOnly);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < command.length; i += 1) {
entityArray.AddString(string(command[i]));
}
result.SetItem(P("commands"), entityArray);
_.memory.Free(entityArray);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < voting.length; i += 1) {
entityArray.AddString(string(voting[i]));
}
result.SetItem(P("votings"), entityArray);
_.memory.Free(entityArray);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < commandWith.length; i += 1) {
innerPair = _.collections.EmptyHashTable();
innerPair.SetString(P("command"), string(commandWith[i].cmd));
innerPair.SetString(P("config"), commandWith[i].config);
entityArray.AddItem(innerPair);
_.memory.Free(innerPair);
}
result.SetItem(P("commandsWithConfig"), entityArray);
_.memory.Free(entityArray);
entityArray = _.collections.EmptyArrayList();
for (i = 0; i < votingWith.length; i += 1) {
innerPair = _.collections.EmptyHashTable();
innerPair.SetString(P("voting"), string(votingWith[i].vtn));
innerPair.SetString(P("config"), votingWith[i].config);
entityArray.AddItem(innerPair);
_.memory.Free(innerPair);
}
result.SetItem(P("votingsWithConfig"), entityArray);
_.memory.Free(entityArray);
return result;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList entityArray;
local HashTable innerPair;
local class<Command> nextCommandClass;
local class<Voting> nextVotingClass;
local CommandConfigStoragePair nextCommandPair;
local VotingConfigStoragePair nextVotingPair;
if (source == none) {
return;
}
debugOnly = source.GetBool(P("debugOnly"));
command.length = 0;
entityArray = source.GetArrayList(P("commands"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
nextCommandClass = class<Command>(_.memory.LoadClass_S(entityArray.GetString(i)));
if (nextCommandClass != none) {
command[command.length] = nextCommandClass;
}
}
}
_.memory.Free(entityArray);
voting.length = 0;
entityArray = source.GetArrayList(P("votings"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
nextVotingClass = class<Voting>(_.memory.LoadClass_S(entityArray.GetString(i)));
if (nextVotingClass != none) {
voting[voting.length] = nextVotingClass;
}
}
}
_.memory.Free(entityArray);
commandWith.length = 0;
entityArray = source.GetArrayList(P("commandsWithConfig"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
innerPair = entityArray.GetHashTable(i);
if (innerPair == none) {
continue;
}
nextCommandPair.cmd =
class<Command>(_.memory.LoadClass_S(innerPair.GetString(P("command"))));
nextCommandPair.config = innerPair.GetString(P("config"));
_.memory.Free(innerPair);
if (nextCommandPair.cmd != none) {
commandWith[commandWith.length] = nextCommandPair;
}
}
}
_.memory.Free(entityArray);
votingWith.length = 0;
entityArray = source.GetArrayList(P("votingsWithConfig"));
if (entityArray != none) {
for (i = 0; i < entityArray.GetLength(); i += 1) {
innerPair = entityArray.GetHashTable(i);
if (innerPair == none) {
continue;
}
nextVotingPair.vtn =
class<Voting>(_.memory.LoadClass_S(innerPair.GetString(P("voting"))));
nextVotingPair.config = innerPair.GetString(P("config"));
_.memory.Free(innerPair);
if (nextVotingPair.vtn != none) {
votingWith[votingWith.length] = nextVotingPair;
}
}
}
_.memory.Free(entityArray);
}
protected function DefaultIt() {
debugOnly = false;
command.length = 0;
commandWith.length = 0;
voting.length = 0;
votingWith.length = 0;
command[0] = class'ACommandHelp';
command[1] = class'ACommandVote';
}
defaultproperties {
configName = "AcediaCommands"
supportsDataConversion = true
debugOnly = false
command(0) = class'ACommandHelp'
command(1) = class'ACommandVote'
}

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

@ -0,0 +1,983 @@
/**
* 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();
}
/// Parses user's input given in [`parser`] using command's information given by
/// [`commandData`].
///
/// Optionally, sub-command can be specified for the [`CommandParser`] to use
/// via [`specifiedSubCommand`] argument.
/// If this argument's value is `none` - it will be parsed from [`parser`]'s
/// data instead.
///
/// Returns results of parsing, described by [`Command.CallData`].
/// Returned object is guaranteed to be not `none`.
public final function Command.CallData ParseWith(
Parser parser,
Command.Data commandData,
EPlayer callerPlayer,
optional BaseText specifiedSubCommand
) {
local HashTable commandParameters;
// Temporary object to return `nextResult` while setting variable to `none`
local Command.CallData toReturn;
nextResult.parameters = _.collections.EmptyHashTable();
nextResult.options = _.collections.EmptyHashTable();
if (commandData.subCommands.length == 0) {
DeclareError(CET_NoSubCommands, none);
toReturn = nextResult;
Reset();
return toReturn;
}
if (parser == none || !parser.Ok()) {
DeclareError(CET_BadParser, none);
toReturn = nextResult;
Reset();
return toReturn;
}
commandParser = parser;
availableOptions = commandData.options;
currentPlayersParser =
PlayersParser(_.memory.Allocate(class'PlayersParser'));
currentPlayersParser.SetSelf(callerPlayer);
// (subcommand) (parameters, possibly with options) and nothing else!
PickSubCommand(commandData, specifiedSubCommand);
nextResult.subCommandName = pickedSubCommand.name.Copy();
commandParameters = ParseParameterArrays(pickedSubCommand.required, pickedSubCommand.optional);
AssertNoTrailingInput(); // make sure there is nothing else
if (commandParser.Ok()) {
nextResult.parameters = commandParameters;
} else {
_.memory.Free(commandParameters);
}
// Clean up
toReturn = nextResult;
Reset();
return toReturn;
}
// Zero important variables
private final function Reset() {
local Command.CallData blankCallData;
_.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);
}
// 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("$")) {
result.SetItem(P("value"), immutableValue);
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.Free(resolvedValue);
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.")
}

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

@ -0,0 +1,66 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandPermissions extends AcediaConfig
perobjectconfig
config(AcediaCommands)
abstract;
var public config array<string> forbiddenSubCommands;
protected function HashTable ToData() {
local int i;
local HashTable data;
local ArrayList forbiddenList;
data = _.collections.EmptyHashTable();
forbiddenList = _.collections.EmptyArrayList();
for (i = 0; i < forbiddenSubCommands.length; i += 1) {
forbiddenList.AddString(Locs(forbiddenSubCommands[i]));
}
data.SetItem(P("forbiddenSubCommands"), forbiddenList);
_.memory.Free(forbiddenList);
return data;
}
protected function FromData(HashTable source) {
local int i;
local ArrayList forbiddenList;
if (source == none) return;
forbiddenList = source.GetArrayList(P("forbiddenSubCommands"));
if (forbiddenList == none) return;
forbiddenSubCommands.length = 0;
for (i = 0; i < forbiddenList.GetLength(); i += 1) {
forbiddenSubCommands[i] = forbiddenList.GetString(i);
}
_.memory.Free(forbiddenList);
}
protected function DefaultIt() {
forbiddenSubCommands.length = 0;
}
defaultproperties {
configName = "AcediaCommands"
supportsDataConversion = true
}

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

@ -0,0 +1,81 @@
/**
* 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
dependson(CommandAPI);
var private CommandAPI.AsyncTask nextItem;
// Expecting 300 units of work, this gives us registering 20 commands per tick
const ADDING_COMMAND_COST = 15;
// Adding voting option is approximately the same as adding a command's
// single sub-command - we'll estimate it as 1/3rd of the full value
const ADDING_VOTING_COST = 5;
// Authorizing is relatively cheap, whether it's commands or voting
const AUTHORIZING_COST = 1;
protected function Constructor() {
nextItem = _.commands._popPending();
}
protected function Finalizer() {
_.memory.Free3(nextItem.entityName, nextItem.userGroup, nextItem.configName);
nextItem.entityClass = none;
nextItem.entityName = none;
nextItem.userGroup = none;
nextItem.configName = none;
}
public function bool IsCompleted() {
return (nextItem.entityName == none);
}
public function DoWork(int allottedWorkUnits) {
while (allottedWorkUnits > 0 && nextItem.entityName != none) {
if (nextItem.type == CAJT_AddCommand) {
allottedWorkUnits -= ADDING_COMMAND_COST;
_.commands.AddCommand(class<Command>(nextItem.entityClass), nextItem.entityName);
_.memory.Free(nextItem.entityName);
} else if (nextItem.type == CAJT_AddVoting) {
allottedWorkUnits -= ADDING_VOTING_COST;
_.commands.AddVoting(class<Voting>(nextItem.entityClass), nextItem.entityName);
_.memory.Free(nextItem.entityName);
} else if (nextItem.type == CAJT_AuthorizeCommand) {
allottedWorkUnits -= AUTHORIZING_COST;
_.commands.AuthorizeCommandUsage(
nextItem.entityName,
nextItem.userGroup,
nextItem.configName);
_.memory.Free3(nextItem.entityName, nextItem.userGroup, nextItem.configName);
} else /*if (nextItem.type == CAJT_AuthorizeVoting)*/ {
allottedWorkUnits -= AUTHORIZING_COST;
_.commands.AuthorizeVotingUsage(
nextItem.entityName,
nextItem.userGroup,
nextItem.configName);
_.memory.Free3(nextItem.entityName, nextItem.userGroup, nextItem.configName);
}
nextItem = _.commands._popPending();
}
}
defaultproperties {
}

230
sources/BaseAPI/API/Commands/Commands.uc

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

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

@ -0,0 +1,708 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class Commands_Feature extends Feature
dependson(CommandAPI)
dependson(Commands);
//! This feature manages commands that automatically parse their arguments into standard Acedia
//! collections.
//!
//! # Implementation
//!
//! Implementation is simple: calling a method `RegisterCommand()` adds
//! command into two caches `registeredCommands` for obtaining registered
//! commands by name and `groupedCommands` for obtaining arrays of commands by
//! their group name. These arrays are used for providing methods for fetching
//! arrays of commands and obtaining pre-allocated `Command` instances by their
//! name.
//! Depending on settings, this feature also connects to corresponding
//! signals for catching "mutate"/chat input, then it checks user-specified name
//! for being an alias and picks correct command from `registeredCommands`.
//! Emergency enabling this feature sets `emergencyEnabledMutate` flag that
//! enforces connecting to the "mutate" input.
/// Auxiliary struct for passing name of the command to call with pre-specified
/// sub-command name.
///
/// Normally sub-command name is parsed by the command itself, however command
/// aliases can try to enforce one.
struct CommandCallPair {
var MutableText commandName;
/// Not `none` in case it is enforced by an alias
var MutableText subCommandName;
};
/// Auxiliary struct that stores all the information needed to load
/// a certain command
struct EntityLoadInfo {
/// Command class to load.
var public class<AcediaObject> entityClass;
/// Name to load that command class under.
var public Text name;
/// Groups that are authorized to use that command.
var public array<Text> authorizedGroups;
/// Groups that are authorized to use that command.
var public array<Text> groupsConfig;
};
/// Auxiliary struct for describing adding a particular command set to
/// a particular group of users.
struct CommandListGroupPair {
/// Name of the command set to add
var public Text commandListName;
/// Name of the group, for which to add this set
var public Text permissionGroup;
};
/// Auxiliary struct for describing a rule to rename a particular command for
/// compatibility reasons.
struct RenamingRulePair {
/// Command class to rename
var public class<AcediaObject> class;
/// Name to use for that class
var public Text newName;
};
/// Tools that provide functionality of managing registered commands and votings
var private CommandAPI.CommandFeatureTools tools;
/// Delimiters that always separate command name from it's parameters
var private array<Text> commandDelimiters;
/// When this flag is set to true, mutate input becomes available despite
/// [`useMutateInput`] flag to allow to unlock server in case of an error
var private bool emergencyEnabledMutate;
var private /*config*/ bool useChatInput;
var private /*config*/ bool useMutateInput;
var private /*config*/ Text chatCommandPrefix;
var public /*config*/ array<string> commandGroup;
var public /*config*/ array<Commands.CommandSetGroupPair> addCommandSet;
var public /*config*/ array<Commands.RenamingRulePair> renamingRule;
var public /*config*/ array<Commands.RenamingRulePair> votingRenamingRule;
// Converted version of `commandGroup`
var private array<Text> permissionGroupOrder;
/// Converted version of `addCommandSet`
var private array<CommandListGroupPair> usedCommandLists;
/// Converted version of `renamingRule` and `votingRenamingRule`
var private array<RenamingRulePair> commandRenamingRules;
var private array<RenamingRulePair> votingRenamingRules;
// Name, under which `ACommandHelp` is registered
var private Text helpCommandName;
var LoggerAPI.Definition errServerAPIUnavailable, warnDuplicateRenaming, warnNoCommandList;
var LoggerAPI.Definition infoCommandAdded, infoVotingAdded;
protected function OnEnabled() {
helpCommandName = P("help");
// Macro selector
commandDelimiters[0] = _.text.FromString("@");
// Key selector
commandDelimiters[1] = _.text.FromString("#");
// Player array (possibly JSON array)
commandDelimiters[2] = _.text.FromString("[");
// Negation of the selector
// NOT the same thing as default command prefix in chat
commandDelimiters[3] = _.text.FromString("!");
if (useChatInput) {
_.chat.OnMessage(self).connect = HandleCommands;
}
else {
_.chat.OnMessage(self).Disconnect();
}
if (useMutateInput || emergencyEnabledMutate) {
if (__server() != none) {
__server().unreal.mutator.OnMutate(self).connect = HandleMutate;
} else {
_.logger.Auto(errServerAPIUnavailable);
}
}
LoadConfigArrays();
// `SetPermissionGroupOrder()` must be called *after* loading configs
tools.commands = CommandsTool(_.memory.Allocate(class'CommandsTool'));
tools.votings = VotingsTool(_.memory.Allocate(class'VotingsTool'));
tools.commands.SetPermissionGroupOrder(permissionGroupOrder);
tools.votings.SetPermissionGroupOrder(permissionGroupOrder);
_.commands._reloadFeature();
// Uses `CommandAPI`, so must be done after `_reloadFeature()` call
LoadCommands();
LoadVotings();
}
protected function OnDisabled() {
if (useChatInput) {
_.chat.OnMessage(self).Disconnect();
}
if (useMutateInput && __server() != none) {
__server().unreal.mutator.OnMutate(self).Disconnect();
}
useChatInput = false;
useMutateInput = false;
_.memory.Free3(tools.commands, tools.votings, chatCommandPrefix);
tools.commands = none;
tools.votings = none;
chatCommandPrefix = none;
_.memory.FreeMany(commandDelimiters);
commandDelimiters.length = 0;
_.memory.FreeMany(permissionGroupOrder);
permissionGroupOrder.length = 0;
FreeUsedCommandSets();
FreeRenamingRules();
_.commands._reloadFeature();
}
protected function SwapConfig(FeatureConfig config) {
local Commands newConfig;
newConfig = Commands(config);
if (newConfig == none) {
return;
}
_.memory.Free(chatCommandPrefix);
chatCommandPrefix = _.text.FromString(newConfig.chatCommandPrefix);
useChatInput = newConfig.useChatInput;
useMutateInput = newConfig.useMutateInput;
commandGroup = newConfig.commandGroup;
addCommandSet = newConfig.addCommandList;
renamingRule = newConfig.renamingRule;
votingRenamingRule = newConfig.votingRenamingRule;
}
/// This method allows to forcefully enable `Command_Feature` along with
/// "mutate" input in case something goes wrong.
///
/// `Command_Feature` is a critical command to have running on your server and,
/// if disabled by accident, there will be no way of starting it again without
/// restarting the level or even editing configs.
public final static function EmergencyEnable() {
local bool noWayToInputCommands;
local Text autoConfig;
local Commands_Feature feature;
if (!IsEnabled()) {
autoConfig = GetAutoEnabledConfig();
EnableMe(autoConfig);
__().memory.Free(autoConfig);
}
feature = Commands_Feature(GetEnabledInstance());
noWayToInputCommands = !feature.emergencyEnabledMutate
&& !feature.IsUsingMutateInput()
&& !feature.IsUsingChatInput();
if (noWayToInputCommands) {
default.emergencyEnabledMutate = true;
feature.emergencyEnabledMutate = true;
if (__server() != none) {
__server().unreal.mutator.OnMutate(feature).connect = HandleMutate;
} else {
__().logger.Auto(default.errServerAPIUnavailable);
}
}
}
/// Checks if `Commands_Feature` currently uses chat as input.
///
/// If `Commands_Feature` is not enabled, then it does not use anything
/// as input.
public final static function bool IsUsingChatInput() {
local Commands_Feature instance;
instance = Commands_Feature(GetEnabledInstance());
if (instance != none) {
return instance.useChatInput;
}
return false;
}
/// Checks if `Commands_Feature` currently uses mutate command as input.
///
/// If `Commands_Feature` is not enabled, then it does not use anything
/// as input.
public final static function bool IsUsingMutateInput() {
local Commands_Feature instance;
instance = Commands_Feature(GetEnabledInstance());
if (instance != none) {
return instance.useMutateInput;
}
return false;
}
/// Returns prefix that will indicate that chat message is intended to be
/// a command. By default "!".
///
/// If `Commands_Feature` is disabled, always returns `none`.
public final static function Text GetChatPrefix() {
local Commands_Feature instance;
instance = Commands_Feature(GetEnabledInstance());
if (instance != none && instance.chatCommandPrefix != none) {
return instance.chatCommandPrefix.Copy();
}
return none;
}
/// Returns name, under which [`ACommandHelp`] is registered.
///
/// If `Commands_Feature` is disabled, always returns `none`.
public final static function Text GetHelpCommandName() {
local Commands_Feature instance;
instance = Commands_Feature(GetEnabledInstance());
if (instance != none && instance.helpCommandName != none) {
return instance.helpCommandName.Copy();
}
return none;
}
/// Executes command based on the input.
///
/// Takes [`commandLine`] as input with command's call, finds appropriate
/// registered command instance and executes it with parameters specified in
/// the [`commandLine`].
///
/// [`callerPlayer`] has to be specified and represents instigator of this
/// command that will receive appropriate result/error messages.
///
/// Returns `true` iff command was successfully executed.
///
/// # Errors
///
/// Doesn't log any errors, but can complain about errors in name or parameters
/// to the [`callerPlayer`]
public final function bool HandleInput(BaseText input, EPlayer callerPlayer) {
local bool result;
local Parser wrapper;
if (input == none) {
return false;
}
wrapper = input.Parse();
result = HandleInputWith(wrapper, callerPlayer);
wrapper.FreeSelf();
return result;
}
/// Executes command based on the input.
///
/// Takes [`commandLine`] as input with command's call, finds appropriate
/// registered command instance and executes it with parameters specified in
/// the [`commandLine`].
///
/// [`callerPlayer`] has to be specified and represents instigator of this
/// command that will receive appropriate result/error messages.
///
/// Returns `true` iff command was successfully executed.
///
/// # Errors
///
/// Doesn't log any errors, but can complain about errors in name or parameters
/// to the [`callerPlayer`]
public final function bool HandleInputWith(Parser parser, EPlayer callerPlayer) {
local bool errorOccured;
local User identity;
local CommandAPI.CommandConfigInfo commandPair;
local Command.CallData callData;
local CommandCallPair callPair;
if (parser == none) return false;
if (callerPlayer == none) return false;
if (!parser.Ok()) return false;
identity = callerPlayer.GetIdentity();
callPair = ParseCommandCallPairWith(parser);
commandPair = _.commands.ResolveCommandForUser(callPair.commandName, identity);
if (commandPair.instance == none || commandPair.usageForbidden) {
if (callerPlayer != none && callerPlayer.IsExistent()) {
callerPlayer
.BorrowConsole()
.Flush()
.Say(F("{$TextFailure Command not found!}"));
}
}
if (parser.Ok() && commandPair.instance != none && !commandPair.usageForbidden) {
callData =
commandPair.instance.ParseInputWith(parser, callerPlayer, callPair.subCommandName);
errorOccured = commandPair.instance.Execute(callData, callerPlayer, commandPair.config);
commandPair.instance.DeallocateCallData(callData);
}
_.memory.Free4(commandPair.instance, callPair.commandName, callPair.subCommandName, identity);
return errorOccured;
}
// Parses command's name into `CommandCallPair` - sub-command is filled in case
// specified name is an alias with specified sub-command name.
private final function CommandCallPair ParseCommandCallPairWith(Parser parser) {
local Text resolvedValue;
local MutableText userSpecifiedName;
local CommandCallPair result;
local Text.Character dotCharacter;
if (parser == none) return result;
if (!parser.Ok()) return result;
parser.MUntilMany(userSpecifiedName, commandDelimiters, true, true);
resolvedValue = _.alias.ResolveCommand(userSpecifiedName);
// This isn't an alias
if (resolvedValue == none) {
result.commandName = userSpecifiedName;
return result;
}
// It is an alias - parse it
dotCharacter = _.text.GetCharacter(".");
resolvedValue.Parse()
.MUntil(result.commandName, dotCharacter)
.MatchS(".")
.MUntil(result.subCommandName, dotCharacter)
.FreeSelf();
if (result.subCommandName.IsEmpty()) {
result.subCommandName.FreeSelf();
result.subCommandName = none;
}
resolvedValue.FreeSelf();
return result;
}
private function bool HandleCommands(EPlayer sender, MutableText message, bool teamMessage) {
local Parser parser;
// We are only interested in messages that start with `chatCommandPrefix`
parser = _.text.Parse(message);
if (!parser.Match(chatCommandPrefix).Ok()) {
parser.FreeSelf();
return true;
}
// Pass input to command feature
HandleInputWith(parser, sender);
parser.FreeSelf();
return false;
}
private function HandleMutate(string command, PlayerController sendingPlayer) {
local Parser parser;
local EPlayer sender;
// A lot of other mutators use these commands
if (command ~= "help") return;
if (command ~= "version") return;
if (command ~= "status") return;
if (command ~= "credits") return;
parser = _.text.ParseString(command);
sender = _.players.FromController(sendingPlayer);
HandleInputWith(parser, sender);
sender.FreeSelf();
parser.FreeSelf();
}
private final function LoadConfigArrays() {
local int i;
local CommandListGroupPair nextCommandSetGroupPair;
for (i = 0; i < commandGroup.length; i += 1) {
permissionGroupOrder[i] = _.text.FromString(commandGroup[i]);
}
for (i = 0; i < addCommandSet.length; i += 1) {
nextCommandSetGroupPair.commandListName = _.text.FromString(addCommandSet[i].name);
nextCommandSetGroupPair.permissionGroup = _.text.FromString(addCommandSet[i].for);
usedCommandLists[i] = nextCommandSetGroupPair;
}
FreeRenamingRules();
commandRenamingRules = LoadRenamingRules(renamingRule);
votingRenamingRules = LoadRenamingRules(votingRenamingRule);
}
private final function array<RenamingRulePair> LoadRenamingRules(
array<Commands.RenamingRulePair> inputRules) {
local int i, j;
local RenamingRulePair nextRule;
local array<RenamingRulePair> result;
// Clear away duplicates
for (i = 0; i < inputRules.length; i += 1) {
j = i + 1;
while (j < inputRules.length) {
if (inputRules[i].rename == inputRules[j].rename) {
_.logger.Auto(warnDuplicateRenaming)
.ArgClass(inputRules[i].rename)
.Arg(_.text.FromString(inputRules[i].to))
.Arg(_.text.FromString(inputRules[j].to));
inputRules.Remove(j, 1);
} else {
j += 1;
}
}
}
// Translate rules
for (i = 0; i < inputRules.length; i += 1) {
nextRule.class = inputRules[i].rename;
nextRule.newName = _.text.FromString(inputRules[i].to);
if (nextRule.class == class'ACommandHelp') {
_.memory.Free(helpCommandName);
helpCommandName = nextRule.newName.Copy();
}
result[result.length] = nextRule;
}
return result;
}
private final function LoadCommands() {
local int i, j;
local Text nextName;
local array<EntityLoadInfo> commandClassesToLoad;
commandClassesToLoad = CollectAllCommandClasses();
// Load command names to use, according to preferred names and name rules
for (i = 0; i < commandClassesToLoad.length; i += 1) {
nextName = none;
for (j = 0; j < commandRenamingRules.length; j += 1) {
if (commandClassesToLoad[i].entityClass == commandRenamingRules[j].class) {
nextName = commandRenamingRules[j].newName.Copy();
break;
}
}
if (nextName == none) {
nextName = class<Command>(commandClassesToLoad[i].entityClass)
.static.GetPreferredName();
}
commandClassesToLoad[i].name = nextName;
}
// Actually load commands
for (i = 0; i < commandClassesToLoad.length; i += 1) {
_.commands.AddCommandAsync(
class<Command>(commandClassesToLoad[i].entityClass),
commandClassesToLoad[i].name);
for (j = 0; j < commandClassesToLoad[i].authorizedGroups.length; j += 1) {
_.commands.AuthorizeCommandUsageAsync(
commandClassesToLoad[i].name,
commandClassesToLoad[i].authorizedGroups[j],
commandClassesToLoad[i].groupsConfig[j]);
}
_.logger.Auto(infoCommandAdded)
.ArgClass(commandClassesToLoad[i].entityClass)
.Arg(/*take*/ commandClassesToLoad[i].name);
}
for (i = 0; i < commandClassesToLoad.length; i += 1) {
// `name` field was already released through `Arg()` logger function
_.memory.FreeMany(commandClassesToLoad[i].authorizedGroups);
_.memory.FreeMany(commandClassesToLoad[i].groupsConfig);
}
}
private final function LoadVotings() {
local int i, j;
local Text nextName;
local array<EntityLoadInfo> votingClassesToLoad;
votingClassesToLoad = CollectAllVotingClasses();
// Load voting names to use, according to preferred names and name rules
for (i = 0; i < votingClassesToLoad.length; i += 1) {
nextName = none;
for (j = 0; j < votingRenamingRules.length; j += 1) {
if (votingClassesToLoad[i].entityClass == votingRenamingRules[j].class) {
nextName = votingRenamingRules[j].newName.Copy();
break;
}
}
if (nextName == none) {
nextName = class<Voting>(votingClassesToLoad[i].entityClass)
.static.GetPreferredName();
}
votingClassesToLoad[i].name = nextName;
}
// Actually load votings
for (i = 0; i < votingClassesToLoad.length; i += 1) {
_.commands.AddVotingAsync(
class<Voting>(votingClassesToLoad[i].entityClass),
votingClassesToLoad[i].name);
for (j = 0; j < votingClassesToLoad[i].authorizedGroups.length; j += 1) {
_.commands.AuthorizeVotingUsageAsync(
votingClassesToLoad[i].name,
votingClassesToLoad[i].authorizedGroups[j],
votingClassesToLoad[i].groupsConfig[j]);
}
_.logger.Auto(infoVotingAdded)
.ArgClass(votingClassesToLoad[i].entityClass)
.Arg(/*take*/ votingClassesToLoad[i].name);
}
for (i = 0; i < votingClassesToLoad.length; i += 1) {
// `name` field was already released through `Arg()` logger function
_.memory.FreeMany(votingClassesToLoad[i].authorizedGroups);
_.memory.FreeMany(votingClassesToLoad[i].groupsConfig);
}
}
// Guaranteed to not return `none` items in the array
private final function array<EntityLoadInfo> CollectAllCommandClasses() {
local int i;
local bool debugging;
local CommandList nextList;
local array<EntityLoadInfo> result;
debugging = _.environment.IsDebugging();
class'CommandList'.static.Initialize();
for (i = 0; i < usedCommandLists.length; i += 1) {
nextList = CommandList(class'CommandList'.static
.GetConfigInstance(usedCommandLists[i].commandListName));
if (nextList != none) {
if (!debugging && nextList.debugOnly) {
continue;
}
MergeEntityClassArrays(
result,
/*take*/ nextList.GetCommandData(),
usedCommandLists[i].permissionGroup);
} else {
_.logger.Auto(warnNoCommandList).Arg(usedCommandLists[i].commandListName.Copy());
}
}
return result;
}
// Guaranteed to not return `none` items in the array
private final function array<EntityLoadInfo> CollectAllVotingClasses() {
local int i;
local bool debugging;
local CommandList nextList;
local array<EntityLoadInfo> result;
debugging = _.environment.IsDebugging();
class'CommandList'.static.Initialize();
for (i = 0; i < usedCommandLists.length; i += 1) {
nextList = CommandList(class'CommandList'.static
.GetConfigInstance(usedCommandLists[i].commandListName));
if (nextList != none) {
if (!debugging && nextList.debugOnly) {
continue;
}
MergeEntityClassArrays(
result,
/*take*/ nextList.GetVotingData(),
usedCommandLists[i].permissionGroup);
} else {
_.logger.Auto(warnNoCommandList).Arg(usedCommandLists[i].commandListName.Copy());
}
}
return result;
}
// Adds `newCommands` into `infoArray`, adding `commandsGroup` to
// their array `authorizedGroups`
//
// Assumes that all arguments aren't `none`.
//
// Guaranteed to not add `none` commands from `newCommands`.
//
// Assumes that items from `infoArray` all have `name` field set to `none`,
// will also leave them as `none`.
private final function MergeEntityClassArrays(
out array<EntityLoadInfo> infoArray,
/*take*/ array<CommandList.EntityConfigPair> newCommands,
Text commandsGroup
) {
local int i, infoToEditIndex;
local EntityLoadInfo infoToEdit;
local array<Text> editedArray;
for (i = 0; i < newCommands.length; i += 1) {
if (newCommands[i].class == none) {
continue;
}
// Search for an existing record of class `newCommands[i]` in
// `infoArray`. If found, copy to `infoToEdit` and index into
// `infoToEditIndex`, else `infoToEditIndex` will hold the next unused
// index in `infoArray`.
infoToEditIndex = 0;
while (infoToEditIndex < infoArray.length) {
if (infoArray[infoToEditIndex].entityClass == newCommands[i].class) {
infoToEdit = infoArray[infoToEditIndex];
break;
}
infoToEditIndex += 1;
}
// Update data inside `infoToEdit`.
infoToEdit.entityClass = newCommands[i].class;
editedArray = infoToEdit.authorizedGroups;
editedArray[editedArray.length] = commandsGroup.Copy();
infoToEdit.authorizedGroups = editedArray;
editedArray = infoToEdit.groupsConfig;
editedArray[editedArray.length] = newCommands[i].config; // moving here
infoToEdit.groupsConfig = editedArray;
// Update `infoArray` with the modified record.
infoArray[infoToEditIndex] = infoToEdit;
// Forget about data `authorizedGroups` and `groupsConfig`:
//
// 1. Their references have already been moved into `infoArray` and
// don't need to be released;
// 2. If we don't find corresponding struct inside `infoArray` on
// the next iteration, we'll override `commandClass`,
// but not `authorizedGroups`/`groupsConfig`, so we'll just reset them
// to empty now.
// (`name` field is expected to be `none` during this method)
infoToEdit.authorizedGroups.length = 0;
infoToEdit.groupsConfig.length = 0;
}
}
private final function FreeUsedCommandSets() {
local int i;
for (i = 0; i < usedCommandLists.length; i += 1) {
_.memory.Free(usedCommandLists[i].commandListName);
_.memory.Free(usedCommandLists[i].permissionGroup);
}
usedCommandLists.length = 0;
}
private final function FreeRenamingRules() {
local int i;
for (i = 0; i < commandRenamingRules.length; i += 1) {
_.memory.Free(commandRenamingRules[i].newName);
}
commandRenamingRules.length = 0;
for (i = 0; i < votingRenamingRules.length; i += 1) {
_.memory.Free(votingRenamingRules[i].newName);
}
votingRenamingRules.length = 0;
}
public final function CommandAPI.CommandFeatureTools _borrowTools() {
return tools;
}
defaultproperties {
configClass = class'Commands'
errServerAPIUnavailable = (l=LOG_Error,m="Server API is currently unavailable. Input methods through chat and mutate command will not be available.")
warnDuplicateRenaming = (l=LOG_Warning,m="Class `%1` has duplicate renaming rules: \"%2\" and \"%3\". Latter will be discarded. It is recommended that you fix your `AcediaCommands` configuration file.")
warnNoCommandList = (l=LOG_Warning,m="Command list \"%1\" not found.")
infoCommandAdded = (l=LOG_Info,m="`Commands_Feature` has resolved to load command `%1` as \"%2\".")
infoVotingAdded = (l=LOG_Info,m="`Commands_Feature` has resolved to load voting `%1` as \"%2\".")
}

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

@ -0,0 +1,39 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnCommandAdded_Signal extends Signal;
public final function bool Emit(class<Command> addedClass, Text usedName) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnCommandAdded_Slot(nextSlot).connect(addedClass, usedName);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnCommandAdded_Slot'
}

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

@ -0,0 +1,38 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnCommandAdded_Slot extends Slot;
delegate connect(class<Command> addedClass, Text usedName) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

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

@ -0,0 +1,39 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnCommandRemoved_Signal extends Signal;
public final function bool Emit(class<Command> removedClass) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnCommandRemoved_Slot(nextSlot).connect(removedClass);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnCommandRemoved_Slot'
}

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

@ -0,0 +1,38 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnCommandRemoved_Slot extends Slot;
delegate connect(class<Command> removedClass) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

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

@ -0,0 +1,39 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnVotingAdded_Signal extends Signal;
public final function bool Emit(class<Voting> addedClass, Text usedName) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnVotingAdded_Slot(nextSlot).connect(addedClass, usedName);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnVotingAdded_Slot'
}

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

@ -0,0 +1,38 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnVotingAdded_Slot extends Slot;
delegate connect(class<Voting> addedClass, Text usedName) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

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

@ -0,0 +1,39 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnVotingEnded_Signal extends Signal;
public final function bool Emit(bool success, HashTable arguments) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnVotingEnded_Slot(nextSlot).connect(success, arguments);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnVotingEnded_Slot'
}

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

@ -0,0 +1,38 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnVotingEnded_Slot extends Slot;
delegate connect(bool success, HashTable arguments) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

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

@ -0,0 +1,39 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnVotingRemoved_Signal extends Signal;
public final function bool Emit(class<Voting> removedClass) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
CommandsAPI_OnVotingRemoved_Slot(nextSlot).connect(removedClass);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
return true;
}
defaultproperties {
relatedSlotClass = class'CommandsAPI_OnVotingRemoved_Slot'
}

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

@ -0,0 +1,38 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsAPI_OnVotingRemoved_Slot extends Slot;
delegate connect(class<Voting> removedClass) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

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

@ -0,0 +1,494 @@
/**
* 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());
}
}
/// Returns players parsed by the last `ParseWith()` or `Parse()` call.
///
/// If neither were yet called - returns an empty array.
public final function array<EPlayer> GetPlayers() {
local int i;
local array<EPlayer> result;
for (i = 0; i < currentSelection.length; i += 1) {
if (currentSelection[i].IsExistent()) {
result[result.length] = EPlayer(currentSelection[i].Copy());
}
}
return result;
}
/// Parses players from `parser` according to the currently present players.
///
/// Array of parsed players can be retrieved by `self.GetPlayers()` method.
///
/// Returns `true` if parsing was successful and `false` otherwise.
public final function bool ParseWith(Parser parser) {
local Parser.ParserState confirmedState;
if (parser == none) return false;
if (!parser.Ok()) return false;
if (parser.HasFinished()) return false;
Reset();
confirmedState = parser.Skip().GetCurrentState();
if (!parser.Match(T(TOPEN_BRACKET)).Ok()) {
ParseSelector(parser.RestoreState(confirmedState));
if (parser.Ok()) {
return true;
}
return false;
}
while (parser.Ok() && !parser.HasFinished()) {
confirmedState = parser.Skip().GetCurrentState();
if (parser.Match(T(TCLOSE_BRACKET)).Ok()) {
return true;
}
parser.RestoreState(confirmedState);
if (parsedFirstSelector) {
parser.Match(T(TCOMMA)).Skip();
}
ParseSelector(parser);
parser.Skip();
}
parser.Fail();
return false;
}
/// Parses players from according to the currently present players.
///
/// Array of parsed players can be retrieved by `self.GetPlayers()` method.
/// Returns `true` if parsing was successful and `false` otherwise.
public final function bool Parse(BaseText toParse) {
local bool wasSuccessful;
local Parser parser;
if (toParse == none) {
return false;
}
parser = _.text.Parse(toParse);
wasSuccessful = ParseWith(parser);
parser.FreeSelf();
return wasSuccessful;
}
// Insert a new player into currently selected list of players
// (`currentSelection`) such that there will be no duplicates.
//
// `none` values are auto-discarded.
private final function InsertPlayer(EPlayer toInsert) {
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;
}
// 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);
}
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)
{
builder.ParamObject(P("just_obj"))
.ParamArrayList(P("manyLists"))
.OptionalParams()
.ParamObject(P("last_obj"));
builder.SubCommand(P("simple"))
.ParamBooleanList(P("isItSimple?"))
.ParamInteger(P("integer variable"), P("int"))
.OptionalParams()
.ParamNumberList(P("numeric list"), P("list"))
.ParamTextList(P("another list"));
builder.ParamObject(P("just_obj"));
builder.ParamArrayList(P("manyLists"));
builder.OptionalParams();
builder.ParamObject(P("last_obj"));
builder.SubCommand(P("simple"));
builder.ParamBooleanList(P("isItSimple?"));
builder.ParamInteger(P("integer variable"), P("int"));
builder.OptionalParams();
builder.ParamNumberList(P("numeric list"), P("list"));
builder.ParamTextList(P("another list"));
}
defaultproperties

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

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

@ -0,0 +1,351 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class TEST_Voting extends TestCase
abstract
dependsOn(VotingModel);
enum ExpectedOutcome {
TEST_EO_Continue,
TEST_EO_End,
};
protected static function VotingModel MakeVotingModel(bool drawMeansWin) {
local VotingModel model;
model = VotingModel(__().memory.Allocate(class'VotingModel'));
model.Start(drawMeansWin);
return model;
}
protected static function SetVoters(
VotingModel model,
optional string voterID0,
optional string voterID1,
optional string voterID2,
optional string voterID3,
optional string voterID4,
optional string voterID5,
optional string voterID6,
optional string voterID7,
optional string voterID8,
optional string voterID9
) {
local UserID nextID;
local array<UserID> voterIDs;
if (voterID0 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID0));
voterIDs[voterIDs.length] = nextID;
}
if (voterID1 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID1));
voterIDs[voterIDs.length] = nextID;
}
if (voterID2 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID2));
voterIDs[voterIDs.length] = nextID;
}
if (voterID3 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID3));
voterIDs[voterIDs.length] = nextID;
}
if (voterID4 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID4));
voterIDs[voterIDs.length] = nextID;
}
if (voterID5 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID5));
voterIDs[voterIDs.length] = nextID;
}
if (voterID6 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID6));
voterIDs[voterIDs.length] = nextID;
}
if (voterID7 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID7));
voterIDs[voterIDs.length] = nextID;
}
if (voterID8 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID8));
voterIDs[voterIDs.length] = nextID;
}
if (voterID9 != "") {
nextID = UserID(__().memory.Allocate(class'UserID'));
nextID.Initialize(__().text.FromString(voterID9));
voterIDs[voterIDs.length] = nextID;
}
model.UpdatePotentialVoters(voterIDs);
}
protected static function MakeFaultyYesVote(
VotingModel model,
string voterID,
VotingModel.VotingResult expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Illegal vote had unexpected result.");
TEST_ExpectTrue(model.CastVote(id, true) == expected);
}
protected static function MakeFaultyNoVote(
VotingModel model,
string voterID,
VotingModel.VotingResult expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Illegal vote had unexpected result.");
TEST_ExpectTrue(model.CastVote(id, false) == expected);
}
protected static function VoteYes(VotingModel model, string voterID, ExpectedOutcome expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Failed to add legitimate vote.");
TEST_ExpectTrue(model.CastVote(id, true) == VFR_Success);
if (expected == TEST_EO_Continue) {
Issue("Vote, that shouldn't have ended voting, ended it.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
} else if (expected == TEST_EO_End) {
Issue("Vote, that should've ended voting with one side's victory, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
}
}
protected static function VoteNo(VotingModel model, string voterID, ExpectedOutcome expected) {
local UserID id;
id = UserID(__().memory.Allocate(class'UserID'));
id.Initialize(__().text.FromString(voterID));
Issue("Failed to add legitimate vote.");
TEST_ExpectTrue(model.CastVote(id, false) == VFR_Success);
if (expected == TEST_EO_Continue) {
Issue("Vote, that shouldn't have ended voting, ended it.");
TEST_ExpectTrue(model.GetStatus() == VPM_InProgress);
} else if (expected == TEST_EO_End) {
Issue("Vote, that should've ended voting with one side's victory, didn't do it.");
TEST_ExpectTrue(model.GetStatus() == VPM_Failure);
}
}
protected static function TESTS() {
Test_VotingModel();
}
protected static function Test_VotingModel() {
SubTest_YesVoting();
SubTest_NoVoting();
SubTest_FaultyVoting();
SubTest_DisconnectVoting_DrawMeansWin();
SubTest_DisconnectVoting_DrawMeansLoss();
SubTest_ReconnectVoting_DrawMeansWin();
SubTest_ReconnectVoting_DrawMeansLoss();
}
protected static function SubTest_YesVoting() {
local VotingModel model;
Context("Testing \"yes\" voting.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteNo(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
}
protected static function SubTest_NoVoting() {
local VotingModel model;
Context("Testing \"no\" voting.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteNo(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_End);
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteNo(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_End);
}
protected static function SubTest_FaultyVoting() {
local VotingModel model;
Context("Testing \"faulty\" voting.");
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
MakeFaultyYesVote(model, "4", VFR_VotingEnded); // voting already ended, so no more votes here
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6");
VoteYes(model, "3", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_End);
MakeFaultyYesVote(model, "4", VFR_VotingEnded); // voting already ended, so no more votes here
}
protected static function SubTest_DisconnectVoting_DrawMeansWin() {
local VotingModel model;
Context("Testing \"disconnect\" voting when draw means victory.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
SetVoters(model, "2", "4", "5", "6", "8", "9", "10"); // remove 1, 3, 7 - 2 "yes" votes
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "9", TEST_EO_Continue);
// Here we're at 3 "no" votes, 3 "yes" votes out of 7 total;
// disconnect "6" and "9" for "yes" to win
SetVoters(model, "2", "4", "5", "8", "10");
Issue("Unexpected result after voting users disconnected.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
}
protected static function SubTest_DisconnectVoting_DrawMeansLoss() {
local VotingModel model;
Context("Testing \"disconnect\" voting when draw means loss.");
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteYes(model, "2", TEST_EO_Continue);
VoteYes(model, "6", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "5", TEST_EO_Continue);
VoteYes(model, "4", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
VoteYes(model, "7", TEST_EO_Continue);
SetVoters(model, "2", "4", "5", "6", "8", "9", "10"); // remove 1, 3, 7 - 3 "yes" votes
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "9", TEST_EO_Continue);
// Here we're at 3 "no" votes, 3 "yes" votes out of 7 total;
// disconnect "6" and "9" for "yes" to win
SetVoters(model, "2", "4", "5", "8", "10");
Issue("Unexpected result after voting users disconnected.");
TEST_ExpectTrue(model.GetStatus() == VPM_Success);
}
protected static function SubTest_ReconnectVoting_DrawMeansWin() {
local VotingModel model;
Context("Testing \"reconnect\" voting when draw means victory.");
model = MakeVotingModel(true);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 3 "yes" voters
SetVoters(model, "2", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "7", TEST_EO_Continue);
VoteNo(model, "8", TEST_EO_Continue);
VoteYes(model, "9", TEST_EO_Continue);
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "3", VFR_NotAllowed);
// Restore 3 "yes" voter
SetVoters(model, "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteNo(model, "3", TEST_EO_End);
}
protected static function SubTest_ReconnectVoting_DrawMeansLoss() {
local VotingModel model;
Context("Testing \"reconnect\" voting when draw means loss.");
model = MakeVotingModel(false);
SetVoters(model, "1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "1", TEST_EO_Continue);
VoteNo(model, "2", TEST_EO_Continue);
VoteYes(model, "3", TEST_EO_Continue);
VoteNo(model, "4", TEST_EO_Continue);
VoteYes(model, "5", TEST_EO_Continue);
VoteNo(model, "6", TEST_EO_Continue);
// Disconnect 1 3 "yes" voters
SetVoters(model, "2", "4", "5", "6", "7", "8", "9", "10");
VoteYes(model, "7", TEST_EO_Continue);
VoteYes(model, "9", TEST_EO_Continue);
MakeFaultyNoVote(model, "1", VFR_NotAllowed);
MakeFaultyNoVote(model, "3", VFR_NotAllowed);
// Restore 3 "yes" voter
SetVoters(model, "2", "3", "4", "5", "6", "7", "8", "9", "10");
VoteNo(model, "8", TEST_EO_Continue);
VoteNo(model, "3", TEST_EO_End);
}
defaultproperties {
caseGroup = "Commands"
caseName = "Voting model"
}

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

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

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

@ -0,0 +1,142 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class CommandsTool extends CmdItemsTool;
//! This is a base class for auxiliary objects that will be used for storing
//! named [`Command`] instances.
//!
//! This storage class allows for efficient manipulation and retrieval of
//! [`Command`]s, along with information about what use groups were authorized
//! to use them.
//!
//! Additionally, this tool allows for efficient fetching of commands that
//! belong to a particular *command group*.
/// [`HashTable`] that maps a command group name to a set of command names that
/// belong to it.
var private HashTable groupedCommands;
protected function Constructor() {
super.Constructor();
groupedCommands = _.collections.EmptyHashTable();
}
protected function Finalizer() {
super.Finalizer();
_.memory.Free(groupedCommands);
groupedCommands = none;
}
/// Returns all known command groups' names.
public final function array<Text> GetGroupsNames() {
local array<Text> emptyResult;
if (groupedCommands != none) {
return groupedCommands.GetTextKeys();
}
return emptyResult;
}
/// Returns array of names of all available commands belonging to the specified
/// group.
public final function array<Text> GetCommandNamesInGroup(BaseText groupName) {
local int i;
local ArrayList commandNamesArray;
local array<Text> result;
if (groupedCommands == none) return result;
commandNamesArray = groupedCommands.GetArrayList(groupName);
if (commandNamesArray == none) return result;
for (i = 0; i < commandNamesArray.GetLength(); i += 1) {
result[result.length] = commandNamesArray.GetText(i);
}
_.memory.Free(commandNamesArray);
return result;
}
protected function ItemCard MakeCard(class<AcediaObject> commandClass, BaseText itemName) {
local Command newCommandInstance;
local ItemCard newCard;
local Text commandGroup;
if (class<Command>(commandClass) != none) {
newCommandInstance = Command(_.memory.Allocate(commandClass, true));
newCommandInstance.Initialize(itemName);
newCard = ItemCard(_.memory.Allocate(class'ItemCard'));
newCard.InitializeWithInstance(newCommandInstance);
// Guaranteed to be lower case (keys of [`HashTable`])
if (itemName != none) {
itemName = itemName.LowerCopy();
} else {
itemName = newCommandInstance.GetPreferredName();
}
commandGroup = newCommandInstance.GetGroupName();
AssociateGroupAndName(commandGroup, itemName);
_.memory.Free3(newCommandInstance, itemName, commandGroup);
}
return newCard;
}
protected function DiscardCard(ItemCard toDiscard) {
local Text groupKey, commandName;
local Command storedCommand;
local ArrayList listOfCommands;
if (toDiscard == none) return;
// Guaranteed to store a [`Command`]
storedCommand = Command(toDiscard.GetItem());
if (storedCommand == none) return;
// Guaranteed to be stored in a lower case
commandName = storedCommand.GetName();
listOfCommands = groupedCommands.GetArrayList(groupKey);
if (listOfCommands != none && commandName != none) {
listOfCommands.RemoveItem(commandName);
}
_.memory.Free2(commandName, storedCommand);
}
// Expect both arguments to be not `none`.
// Expect both arguments to be lower-case.
private final function AssociateGroupAndName(BaseText groupKey, BaseText commandName) {
local ArrayList listOfCommands;
if (groupedCommands != none) {
listOfCommands = groupedCommands.GetArrayList(groupKey);
if (listOfCommands == none) {
listOfCommands = _.collections.EmptyArrayList();
}
if (listOfCommands.Find(commandName) < 0) {
// `< 0` means not found
listOfCommands.AddItem(commandName);
}
// Set `listOfCommands` in case we've just created that array.
// Won't do anything if it is already recorded there.
groupedCommands.SetItem(groupKey, listOfCommands);
}
}
defaultproperties {
ruleBaseClass = class'Command';
}

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

@ -0,0 +1,177 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class ItemCard extends AcediaObject;
//! Utility class designed for storing either class of an object
//! (possibly also a specific instance) along with authorization information:
//! which user groups are allowed to use stored entity and with what level of
//! permissions (defined by the name of a config with permissions).
//!
//! [`ItemCard`] has to be initialized with either [`InitializeWithClass()`] or
//! [`InitializeWithInstance()`] before it can be used.
/// Class of object that this card describes.
var private class<AcediaObject> storedClass;
/// Instance of an object (can also *optionally* be stored in this card)
var private AcediaObject storedInstance;
/// This [`HashTable`] maps authorized groups to their respective config names.
///
/// Each key represents an authorized group, and its corresponding value
/// indicates the associated config name. If a key has a value of `none`,
/// the default config (named "default") should be used for that group.
var private HashTable groupToConfig;
var LoggerAPI.Definition errGroupAlreadyHasConfig;
protected function Finalizer() {
_.memory.Free2(storedInstance, groupToConfig);
storedInstance = none;
storedClass = none;
groupToConfig = none;
}
/// Initializes the caller [`ItemCard`] object with class to be stored.
///
/// Initialization can only be done once: once method returned `true`,
/// all future calls will fail.
///
/// Returns `false` if caller was already initialized or `none` is provided as
/// an argument. Otherwise succeeds and returns `true`.
public function bool InitializeWithClass(class<AcediaObject> toStore) {
if (storedClass != none) return false;
if (toStore == none) return false;
storedClass = toStore;
groupToConfig = _.collections.EmptyHashTable();
return true;
}
/// Initializes the caller [`ItemCard`] object with an object to be stored.
///
/// Initialization can only be done once: once method returned `true`,
/// all future calls will fail.
///
/// Returns `false` caller was already initialized or `none` is provided as
/// an argument. Otherwise succeeds and returns `true`.
public function bool InitializeWithInstance(AcediaObject toStore) {
if (storedClass != none) return false;
if (toStore == none) return false;
storedClass = toStore.class;
storedInstance = toStore;
storedInstance.NewRef();
groupToConfig = _.collections.EmptyHashTable();
return true;
}
/// Authorizes a new group to use the this card's item.
///
/// This function allows to specify the config name for a particular user group.
/// If this config name is skipped (specified as `none`), then "default" will be
/// used instead.
///
/// Function will return `true` if group was successfully authorized and
/// `false` otherwise (either group already authorized or caller [`ItemCard`]
/// isn't initialized).
///
/// # Errors
///
/// If specified group was already authorized to use card's item, then it
/// will log an error message about it.
public function bool AuthorizeGroupWithConfig(BaseText groupName, optional BaseText configName) {
local Text itemKey;
local Text storedConfigName;
if (storedClass == none) return false;
if (groupToConfig == none) return false;
if (groupName == none) return false;
if (groupName.IsEmpty()) return false;
/// Make group name immutable and have its characters have a uniform case to
/// be usable as case-insensitive keys for [`HashTable`].
itemKey = groupName.LowerCopy();
storedConfigName = groupToConfig.GetText(itemKey);
if (storedConfigName != none) {
_.logger.Auto(errGroupAlreadyHasConfig)
.ArgClass(storedClass)
.Arg(groupName.Copy())
.Arg(storedConfigName)
.Arg(configName.Copy());
_.memory.Free(itemKey);
return false;
}
// We don't actually record "default" value at this point, instead opting
// to return "default" in getter functions in case stored `configName`
// is `none`.
groupToConfig.SetItem(itemKey, configName);
_.memory.Free(itemKey);
return true;
}
/// Returns item instance for the caller [`ItemCard`].
///
/// Returns `none` iff this card wasn't initialized with an instance.
public function AcediaObject GetItem() {
if (storedInstance != none) {
storedInstance.NewRef();
}
return storedInstance;
}
/// Returns item class for the caller [`ItemCard`].
///
/// Returns `none` iff this card wasn't initialized.
public function class<AcediaObject> GetItemClass() {
return storedClass;
}
/// Returns the name of config that was authorized for the specified group.
///
/// Returns `none` if group wasn't authorized, otherwise guaranteed to
/// return non-`none` and non-empty `Text` value.
public function Text GetConfigNameForGroup(BaseText groupName) {
local Text groupNameAsKey, result;
if (storedClass == none) return none;
if (groupToConfig == none) return none;
if (groupName == none) return none;
/// Make group name immutable and have its characters a uniform case to
/// be usable as case-insensitive keys for [`HashTable`]
groupNameAsKey = groupName.LowerCopy();
if (groupToConfig.HasKey(groupNameAsKey)) {
result = groupToConfig.GetText(groupNameAsKey);
if (result == none) {
// If we do have specified group recorded as a key, then we must
// return non-`none` config name, defaulting to "default" value
// if none was provided
result = P("default").Copy();
}
}
_.memory.Free(groupNameAsKey);
return result;
}
defaultproperties {
errGroupAlreadyHasConfig = (l=LOG_Error,m="Item `%1` is already added to group '%2' with config '%3'. Attempt to add it with config '%4' is ignored.")
}

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

@ -0,0 +1,119 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class VotingsTool extends CmdItemsTool
dependson(CommandAPI);
//! This is a base class for auxiliary objects that will be used for storing
//! named [`Voting`] classes.
//!
//! This storage class allows for efficient manipulation and retrieval of
//! [`Voting`] classes, along with information about what use groups were
//! authorized to use them.
//!
//! Additionally this tool is used to keep track of the currently ongoing
//! voting, preventing [`CommandsAPI`] from starting several votings at once.
/// Currently running voting process.
/// This tool doesn't actively track when voting ends, so reference can be
/// non-`none` even if voting has already ended. Instead `DropFinishedVoting()`
/// method is used as needed to figure out whether that voting has ended and
/// should be deallocated.
var private Voting currentVoting;
protected function Finalizer() {
super.Finalizer();
_.memory.Free(currentVoting);
currentVoting = none;
}
/// Starts a voting process with a given name, returning its result.
public final function CommandAPI.StartVotingResult StartVoting(
CommandAPI.VotingConfigInfo votingData,
HashTable arguments
) {
local CommandAPI.StartVotingResult result;
DropFinishedVoting();
if (currentVoting != none) {
return SVR_AlreadyInProgress;
}
if (votingData.votingClass == none) {
return SVR_UnknownVoting;
}
currentVoting = Voting(_.memory.Allocate(votingData.votingClass));
result = currentVoting.Start(votingData.config, arguments);
if (result != SVR_Success) {
_.memory.Free(currentVoting);
currentVoting = none;
}
return result;
}
/// Returns `true` iff some voting is currently active.
public final function bool IsVotingRunning() {
DropFinishedVoting();
return (currentVoting != none);
}
/// Returns instance of the active voting.
///
/// `none` iff no voting is currently active.
public final function Voting GetCurrentVoting() {
DropFinishedVoting();
if (currentVoting != none) {
currentVoting.NewRef();
}
return currentVoting;
}
protected function ItemCard MakeCard(class<AcediaObject> votingClass, BaseText itemName) {
local ItemCard newCard;
if (class<Voting>(votingClass) != none) {
newCard = ItemCard(_.memory.Allocate(class'ItemCard'));
newCard.InitializeWithClass(votingClass);
}
return newCard;
}
private final function class<Voting> GetVoting(BaseText itemName) {
local ItemCard relevantCard;
local class<Voting> result;
relevantCard = GetCard(itemName);
if (relevantCard != none) {
result = class<Voting>(relevantCard.GetItemClass());
}
_.memory.Free(relevantCard);
return result;
}
// Clears `currentVoting` if it has already finished
private final function DropFinishedVoting() {
if (currentVoting != none && currentVoting.HasEnded()) {
_.memory.Free(currentVoting);
currentVoting = none;
}
}
defaultproperties {
ruleBaseClass = class'Voting'
}

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

@ -0,0 +1,863 @@
/**
* 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)
dependson(CommandAPI);
//! 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.
//!
//! Generally, [`Voting`] will only update whenever its methods are called.
//! The only exception is when a time limit was specified, then
//! `TryAnnounceTimer()` will be called every tick, voting emulating countdown.
//!
//! ## 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
//! [`ForceEnding()`] method.
//!
//! When implementing your own voting:
//!
//! 1. You normally would override [`Execute()`] method to perform any
//! actions you want upon the voting success.
//! 2. If you want your voting to take any arguments, you should also
//! overload [`AddInfo()`].
//! 3. You can also override [`HandleVotingStart()`] method to setup custom
//! voting's messages inside `currentAnnouncements` or to reject
//! starting this voting altogether.
/// Describes possible results of [`ForceEnding()`] method.
enum ForceEndingOutcome {
/// Voting forcing was successful.
FEO_Success,
/// User is not allowed to force voting.
FEO_Forbidden,
/// No voting to end at this moment.
FEO_NotApplicable
};
/*******************************************************************************
* Voting settings that should be specified for child classes.
******************************************************************************/
/// During its lifecycle voting outputs several messages to players about its
/// current state.
/// This struct contains all such messages.
struct VotingAnnouncementSet {
/// Message that is displayed when voting starts, inviting others to vote
/// (e.g. "Voting to end trader has started")
var public Text started;
/// Message that is displayed once voting succeeds
/// (e.g. "{$TextPositive Voting to end trader was successful}")
var public Text succeeded;
/// Message that is displayed once voting succeeds
/// (e.g. "{$TextNegative Voting to end trader has failed}")
var public Text failed;
/// Message that is displayed when voting info is displayed mid-voting.
/// (e.g. "Voting to end trader currently active.")
var public Text info;
};
/// Variable that contains current messages that voting will use to communicate
/// its status to the players.
///
/// It is auto-filled with `string` values [`votingStartedLine`],
/// [`votingSucceededLine`], [`votingFailedLine`], [`votingInfoLine`].
/// If you want to change/customize these values based on voting arguments,
/// then override [`HandleVotingStart()`] method and set values inside of this
/// variable directly (they will all be `none` at this point).
var protected VotingAnnouncementSet currentAnnouncements;
/// Preferred name of this voting. Actual name is decided by
/// server owner/mod author.
///
/// Has to satisfy limitations described in the `BaseText::IsValidName()`
var protected const string preferredName;
/// 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 protected 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 protected 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 protected const string votingFailedLine;
/// 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 is currently active." line, avoid adding
/// formatting and don't add comma/exclamation mark at the end.
var protected const string votingInfoLine;
/// Settings variable that defines a class to be used for this [`Voting`]'s
/// permissions config.
var protected const class<VotingPermissions> permissionsConfigClass;
/*******************************************************************************
* Variables that describe current state of the voting.
******************************************************************************/
/// Underlying voting model that does actual vote calculations.
var private VotingModel model;
/// How much time remains in the voting.
/// Both negative and zero values mean that countdown either ended or wasn't
/// started to begin with.
/// This value can only be decreased inside [`TryAnnounceTimer()`] event method;
/// voting end due to countdown is also expected to be handled there.
var private float remainingVotingTime;
/// Tracks index of the next timing inside [`announcementTimings`] to announce.
var private int nextTimingToAnnounce;
/// Records whether end of the voting announcement was already made.
var private bool endingHandled;
/// Arguments that this voting was called with.
var private HashTable usedArguments;
var private array<Text> policyAllowedToVoteGroups;
var private array<Text> policyAllowedToSeeVotingGroups;
var private array<Text> policyAllowedToForceVoting;
var private bool policySpectatorsCanVote;
/// Fake voters that are only used in debug mode to allow for simpler vote
/// testing.
var private array<UserID> debugVoters;
// Timings at which to announce how much time is left for this voting
var private const array<int> announcementTimings;
/*******************************************************************************
* Auxiliary variables (`string`s + templates from them) used for producing
* output to the user.
******************************************************************************/
/// Text that serves as a template for announcing current vote counts.
var private const string voteSummaryTemplateString;
/// Text that serves as a template for announcing player making a new vote.
var private const string playerVotedTemplateString, playerVotedAnonymousTemplateString;
var private const string timeRemaningAnnounceTemplateString;
// [`TextTemplate`]s made from the above `string` templates.
var private TextTemplate voteSummaryTemplate, playerVotedTemplate, playerVotedAnonymousTemplate;
var private TextTemplate timeRemaningAnnounceTemplate;
/// Text that is used instead of how to vote hint for players not allowed
/// to vote
var private const string cannotVoteHint;
/*******************************************************************************
* Signals.
******************************************************************************/
var private CommandsAPI_OnVotingEnded_Signal onVotingEndedSignal;
protected function Constructor() {
nextTimingToAnnounce = 0;
voteSummaryTemplate = _.text.MakeTemplate_S(voteSummaryTemplateString);
playerVotedTemplate = _.text.MakeTemplate_S(playerVotedTemplateString);
playerVotedAnonymousTemplate = _.text.MakeTemplate_S(playerVotedAnonymousTemplateString);
timeRemaningAnnounceTemplate = _.text.MakeTemplate_S(timeRemaningAnnounceTemplateString);
onVotingEndedSignal = CommandsAPI_OnVotingEnded_Signal(
_.memory.Allocate(class'CommandsAPI_OnVotingEnded_Signal'));
}
protected function Finalizer() {
_.memory.Free(model);
model = none;
endingHandled = false;
policySpectatorsCanVote = false;
_.memory.Free2(usedArguments, onVotingEndedSignal);
usedArguments = none;
onVotingEndedSignal = none;
_server.unreal.OnTick(self).Disconnect();
_.memory.Free4(currentAnnouncements.started,
currentAnnouncements.succeeded,
currentAnnouncements.failed,
currentAnnouncements.info);
currentAnnouncements.started = none;
currentAnnouncements.succeeded = none;
currentAnnouncements.failed = none;
currentAnnouncements.info = none;
_.memory.Free4(voteSummaryTemplate,
playerVotedTemplate,
playerVotedAnonymousTemplate,
timeRemaningAnnounceTemplate);
voteSummaryTemplate = none;
playerVotedTemplate = none;
playerVotedAnonymousTemplate = none;
timeRemaningAnnounceTemplate = none;
_.memory.FreeMany(policyAllowedToVoteGroups);
_.memory.FreeMany(policyAllowedToSeeVotingGroups);
_.memory.FreeMany(policyAllowedToForceVoting);
policyAllowedToVoteGroups.length = 0;
policyAllowedToSeeVotingGroups.length = 0;
policyAllowedToForceVoting.length = 0;
}
/// Signal that will be emitted when voting ends.
///
/// # Slot description
///
/// bool <slot>(bool success, HashTable arguments)
///
/// ## Parameters
///
/// * [`success`]: `true` if voting ended successfully and `false` otherwise.
/// * [`arguments`]: Arguments with which voting was called.
public /*signal*/ function CommandsAPI_OnVotingEnded_Slot OnVotingEnded(AcediaObject receiver) {
return CommandsAPI_OnVotingEnded_Slot(onVotingEndedSignal.NewSlot(receiver));
}
/// 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) {
}
/// Loads permissions config with a given name for the caller [`Voting`] class.
///
/// Permission configs describe allowed usage of the [`Voting`].
/// Basic settings are contained inside [`VotingPermissions`], but votings
/// should derive their own child classes for storing their settings.
///
/// Returns `none` if caller [`Voting`] class didn't specify custom permission
/// settings class or provided name is invalid (according to
/// [`BaseText::IsValidName()`]).
/// Otherwise guaranteed to return a config reference.
public final static function VotingPermissions LoadConfig(BaseText configName) {
if (configName == none) return none;
if (default.permissionsConfigClass == none) return none;
default.permissionsConfigClass.static.Initialize();
// This creates default config if it is missing
default.permissionsConfigClass.static.NewConfig(configName);
return VotingPermissions(default.permissionsConfigClass.static
.GetConfigInstance(configName));
}
/// Returns name of this voting in the lower case.
///
/// If voting class was configured incorrectly (with a `preferredName`
/// that doesn't satisfy limitations, described in `BaseText::IsValidName()`),
/// then this method will return `none`.
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;
}
/// Forcibly ends the voting, deciding winner depending on the argument.
/// By default decides result by the votes that already have been cast.
///
/// Only does anything if voting is currently in progress
/// (in `VPM_InProgress` state).
public final function ForceEndingOutcome ForceEnding(
EPlayer instigator,
VotingModel.ForceEndingType type
) {
local int i;
local UserID id;
local bool canForce;
if (model == none) return FEO_NotApplicable;
if (instigator == none) return FEO_Forbidden;
id = instigator.GetUserID();
if (id == none) return FEO_Forbidden;
for (i = 0; i < policyAllowedToForceVoting.length; i += 1) {
if (_.users.IsUserIDInGroup(id, policyAllowedToForceVoting[i])) {
canForce = true;
break;
}
}
if (canForce) {
if (model.ForceEnding(type)) {
TryEnding(instigator);
return FEO_Success;
}
return FEO_NotApplicable;
}
_.memory.Free(id);
return FEO_Forbidden;
}
/// Starts caller [`Voting`] using policies, loaded from the given config.
///
/// Provided config instance must not be `none`, otherwise method is guaranteed
/// to fail with `SVR_InvalidState`.
/// Method will also fail if voting was already started (even if it already
/// ended), there is no one eligible to vote or [`Voting`] itself has decided to
/// reject being started at this moment, with given arguments.
public final function CommandAPI.StartVotingResult Start(
VotingPermissions config,
HashTable arguments
) {
local bool hasDebugVoters;
local array<EPlayer> voters;
if (model != none) return SVR_InvalidState;
if (config == none) return SVR_InvalidState;
// Check whether we even have enough voters
ReadConfigIntoPolicies(config); // we need to know permission policies
voters = FindAllVotingPlayers();
hasDebugVoters = _.environment.IsDebugging()
&& class'ACommandFakers'.static.BorrowDebugVoters().length > 0;
if (voters.length == 0 && !hasDebugVoters) {
return SVR_NoVoters;
}
// Check if voting even wants to start with these arguments
if (HandleVotingStart(config, arguments)) {
// ^ this was supposed to pre-fill `currentAnnouncements` struct if
// it wanted to change any messages, so now is the good time to fill
// the rest with defaults/fallback
FillAnnouncementGaps();
if (arguments != none) {
arguments.NewRef();
usedArguments = arguments;
}
} else {
_.memory.FreeMany(voters);
return SVR_Rejected;
}
// Actually start voting
model = VotingModel(_.memory.Allocate(class'VotingModel'));
model.Start(config.drawEqualsSuccess);
// Inform new voting about fake voters, in case we're debugging
if (hasDebugVoters) {
// This method will call also `UpdateVoters()`
SetDebugVoters(class'ACommandFakers'.static.BorrowDebugVoters());
} else {
UpdateVoters(voters);
}
SetupCountdownTimer(config);
AnnounceStart();
_.memory.FreeMany(voters);
return SVR_Success;
}
/// Checks if the [`Voting`] process has reached its conclusion.
///
/// Please note that this differs from determining whether voting is currently
// active. Even voting that hasn't started is not considered concluded.
public final function bool HasEnded() {
if (model == none) {
return false;
}
return model.HasEnded();
}
/// Retrieves 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 be counted, and this method
/// will return [`PVS_NoVote`].
///
/// In case the voter regains their voting rights while the voting process is
/// still ongoing, their previous vote will be automatically reinstated by
/// the caller [`Voting`].
public final function VotingModel.PlayerVoteStatus GetVote(UserID voter) {
if (model != none) {
return model.GetVote(voter);
}
return PVS_NoVote;
}
/// Adds specified [`UserID`]s as additional voters in debug mode.
///
/// This method is intended for debugging purposes and only functions when
/// the game is running in debug mode.
public final function SetDebugVoters(array<UserID> newDebugVoters) {
local int i;
local array<EPlayer> realVoters;
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();
}
}
realVoters = FindAllVotingPlayers();
UpdateVoters(realVoters);
_.memory.FreeMany(realVoters);
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> realVoters;
local VotingModel.VotingResult result;
if (model == none) return VFR_NotAllowed;
if (voter == none) return VFR_NotAllowed;
if (!_.environment.IsDebugging()) return VFR_NotAllowed;
realVoters = FindAllVotingPlayers();
UpdateVoters(realVoters);
result = model.CastVote(voter, voteForSuccess);
if (result == VFR_Success) {
AnnounceNewVote(none, voteForSuccess);
}
TryEnding();
_.memory.FreeMany(realVoters);
return result;
}
/// Registers a vote on behalf of the specified player.
///
/// This method updates the voting status for the specified player and may
/// initiate the conclusion of the voting process.
/// After a vote is registered, the updated voting status is broadcast to all
/// players.
public final function VotingModel.VotingResult CastVote(EPlayer voter, bool voteForSuccess) {
local UserID voterID;
local array<EPlayer> realVoters;
local VotingModel.VotingResult result;
if (model == none) return VFR_NotAllowed;
if (voter == none) return VFR_NotAllowed;
voterID = voter.GetUserID();
realVoters = FindAllVotingPlayers();
UpdateVoters(realVoters);
result = model.CastVote(voterID, voteForSuccess);
switch (result) {
case VFR_Success:
AnnounceNewVote(voter, 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:
}
TryEnding();
_.memory.Free(voterID);
_.memory.FreeMany(realVoters);
return result;
}
/// Prints information about caller [`Voting`] to the given player.
public final function PrintVotingInfoFor(EPlayer requester) {
local ConsoleWriter writer;
local MutableText summaryPart, timeRemaining;
if (requester == none) {
return;
}
voteSummaryTemplate.Reset();
voteSummaryTemplate.ArgInt(model.GetVotesFor());
voteSummaryTemplate.ArgInt(model.GetVotesAgainst());
summaryPart = voteSummaryTemplate.CollectFormattedM();
writer = requester.BorrowConsole();
writer.Write(currentAnnouncements.info);
writer.Write(P(". "));
writer.Write(summaryPart);
writer.WriteLine(P("."));
if (remainingVotingTime > 0) {
timeRemaining = _.text.FromIntM(int(Ceil(remainingVotingTime)));
writer.Write(P("Time remaining: "));
writer.Write(timeRemaining);
writer.WriteLine(P(" seconds."));
_.memory.Free(timeRemaining);
}
_.memory.Free(summaryPart);
}
/// Override this to perform necessary logic after voting has succeeded.
protected function Execute(HashTable arguments) {}
/// Override this method to:
///
/// 1. Specify any of the messages inside `currentAnnouncements` to fit passed
/// [`arguments`].
/// 2. Optionally reject starting this voting altogether by returning `false`
/// (returning `true` will allow voting to proceed).
protected function bool HandleVotingStart(VotingPermissions config, HashTable arguments) {
return true;
}
// 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;
}
private final function ReadConfigIntoPolicies(VotingPermissions config) {
local int i;
if (config != none) {
policySpectatorsCanVote = config.allowSpectatorVoting;
for (i = 0; i < config.allowedToVoteGroup.length; i += 1) {
policyAllowedToVoteGroups[i] = _.text.FromString(config.allowedToVoteGroup[i]);
}
for (i = 0; i < config.allowedToSeeVotesGroup.length; i += 1) {
policyAllowedToSeeVotingGroups[i] = _.text.FromString(config.allowedToSeeVotesGroup[i]);
}
for (i = 0; i < config.allowedToForceGroup.length; i += 1) {
policyAllowedToForceVoting[i] = _.text.FromString(config.allowedToForceGroup[i]);
}
}
}
private final function SetupCountdownTimer(VotingPermissions config) {
if (config != none && config.votingTime > 0) {
remainingVotingTime = config.votingTime;
_server.unreal.OnTick(self).connect = TryAnnounceTimer;
nextTimingToAnnounce = 0;
while (nextTimingToAnnounce < announcementTimings.length) {
if (announcementTimings[nextTimingToAnnounce] <= remainingVotingTime) {
break;
}
nextTimingToAnnounce += 1;
}
}
}
private function TryAnnounceTimer(float delta, float dilationCoefficient) {
local MutableText message;
local ConsoleWriter writer;
if (remainingVotingTime <= 0) {
return;
}
remainingVotingTime -= delta / dilationCoefficient;
if (remainingVotingTime <= 0) {
model.ForceEnding();
TryEnding();
return;
}
if (nextTimingToAnnounce >= announcementTimings.length) {
return;
}
if (announcementTimings[nextTimingToAnnounce] > int(remainingVotingTime)) {
writer = _.console.ForAll();
timeRemaningAnnounceTemplate.Reset();
timeRemaningAnnounceTemplate.ArgInt(announcementTimings[nextTimingToAnnounce]);
message = timeRemaningAnnounceTemplate.CollectFormattedM();
writer.WriteLine(message);
_.memory.Free(writer);
nextTimingToAnnounce += 1;
}
}
/// Outputs message about new vote being submitted to all relevant voters.
private final function AnnounceNewVote(EPlayer voter, bool voteForSuccess) {
local int i, j;
local bool playerAllowedToSee;
local Text voterName;
local array<EPlayer> allPlayers;
local UserID nextID;
local MutableText playerVotedPart, playerVotedAnonymousPart, summaryPart;
voteSummaryTemplate.Reset();
voteSummaryTemplate.ArgInt(model.GetVotesFor());
voteSummaryTemplate.ArgInt(model.GetVotesAgainst());
summaryPart = voteSummaryTemplate.CollectFormattedM();
playerVotedTemplate.Reset();
playerVotedAnonymousTemplate.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}"));
playerVotedAnonymousTemplate.TextArg(P("vote_type"), F("{$TextPositive for}"));
} else {
playerVotedTemplate.TextArg(P("vote_type"), F("{$TextNegative against}"));
playerVotedAnonymousTemplate.TextArg(P("vote_type"), F("{$TextNegative against}"));
}
playerVotedPart = playerVotedTemplate.CollectFormattedM();
playerVotedAnonymousPart = playerVotedAnonymousTemplate.CollectFormattedM();
allPlayers = _.players.GetAll();
for (i = 0; i < allPlayers.length; i += 1) {
nextID = allPlayers[i].GetUserID();
playerAllowedToSee = false;
for (j = 0; j < policyAllowedToSeeVotingGroups.length; j += 1) {
if (_.users.IsUserIDInGroup(nextID, policyAllowedToSeeVotingGroups[j])) {
playerAllowedToSee = true;
break;
}
}
if (playerAllowedToSee) {
allPlayers[i].BorrowConsole().Write(playerVotedPart);
} else {
allPlayers[i].BorrowConsole().Write(playerVotedAnonymousPart);
}
allPlayers[i].BorrowConsole().Write(P(". ")).Write(summaryPart).WriteLine(P("."));
_.memory.Free(nextID);
}
_.memory.Free3(playerVotedPart, playerVotedAnonymousPart, summaryPart);
_.memory.FreeMany(allPlayers);
}
/// Tries to end voting.
///
/// Returns `true` iff this method was called for the first time after
/// the voting concluded.
private final function bool TryEnding(optional EPlayer forcedBy) {
local Text outcomeMessage;
if (model == none) return false;
if (endingHandled) return false;
if (!HasEnded()) return false;
endingHandled = true;
if (model.GetStatus() == VPM_Success) {
outcomeMessage = currentAnnouncements.succeeded;
} else {
outcomeMessage = currentAnnouncements.failed;
}
onVotingEndedSignal.Emit(model.GetStatus() == VPM_Success, usedArguments);
AnnounceOutcome(outcomeMessage, forcedBy);
if (model.GetStatus() == VPM_Success) {
Execute(usedArguments);
}
_server.unreal.OnTick(self).Disconnect();
return true;
}
private final function FillAnnouncementGaps() {
if (currentAnnouncements.started == none) {
currentAnnouncements.started = _.text.FromFormattedString(votingStartedLine);
}
if (currentAnnouncements.succeeded == none) {
currentAnnouncements.succeeded = _.text.FromFormattedString(votingSucceededLine);
}
if (currentAnnouncements.failed == none) {
currentAnnouncements.failed = _.text.FromFormattedString(votingFailedLine);
}
if (currentAnnouncements.info == none) {
currentAnnouncements.info = _.text.FromFormattedString(votingInfoLine);
}
}
private final function array<EPlayer> FindAllVotingPlayers() {
local int i, j;
local bool userAllowedToVote;
local UserID nextID;
local array<EPlayer> currentPlayers, voterPlayers;
currentPlayers = _.players.GetAll();
for (i = 0; i < currentPlayers.length; i += 1) {
if (!policySpectatorsCanVote && currentPlayers[i].IsSpectator()) {
continue;
}
nextID = currentPlayers[i].GetUserID();
userAllowedToVote = false;
for (j = 0; j < policyAllowedToVoteGroups.length; j += 1) {
if (_.users.IsUserIDInGroup(nextID, policyAllowedToVoteGroups[j])) {
userAllowedToVote = true;
break;
}
}
if (userAllowedToVote) {
currentPlayers[i].NewRef();
voterPlayers[voterPlayers.length] = currentPlayers[i];
}
_.memory.Free(nextID);
}
_.memory.FreeMany(currentPlayers);
return voterPlayers;
}
/// Updates the inner voting model with current list of players allowed to vote.
/// Also returns said list.
private final function UpdateVoters(array<EPlayer> votingPlayers) {
local int i;
local array<UserID> votersIDs;
if (model == none) {
return;
}
for (i = 0; i < votingPlayers.length; i += 1) {
votersIDs[votersIDs.length] = votingPlayers[i].GetUserID();
}
for (i = 0; i < debugVoters.length; i += 1) {
debugVoters[i].NewRef();
votersIDs[votersIDs.length] = debugVoters[i];
}
model.UpdatePotentialVoters(votersIDs);
_.memory.FreeMany(votersIDs);
}
/// Prints given voting outcome message in console and publishes it as
/// a notification.
private final function AnnounceStart() {
local int i, j;
local bool playerAllowedToSee;
local UserID nextID;
local MutableText howToVoteHint;
local array<EPlayer> currentPlayers;
howToVoteHint = MakeHowToVoteHint();
currentPlayers = _.players.GetAll();
for (i = 0; i < currentPlayers.length; i += 1) {
nextID = currentPlayers[i].GetUserID();
playerAllowedToSee = false;
for (j = 0; j < policyAllowedToVoteGroups.length; j += 1) {
if (_.users.IsUserIDInGroup(nextID, policyAllowedToVoteGroups[j])) {
playerAllowedToSee = true;
break;
}
}
_.memory.Free(nextID);
if (playerAllowedToSee) {
currentPlayers[i].Notify(currentAnnouncements.started, howToVoteHint,, P("voting"));
currentPlayers[i].BorrowConsole().WriteLine(currentAnnouncements.started);
currentPlayers[i].BorrowConsole().WriteLine(howToVoteHint);
} else {
currentPlayers[i].Notify(currentAnnouncements.started, F(cannotVoteHint),, P("voting"));
currentPlayers[i].BorrowConsole().WriteLine(currentAnnouncements.started);
}
}
_.memory.Free(howToVoteHint);
_.memory.FreeMany(currentPlayers);
}
/// Prints given voting outcome message in console and publishes it as
/// a notification.
private final function AnnounceOutcome(BaseText outcomeMessage, optional EPlayer forcedBy) {
local int i;
local Text playerName;
local MutableText editedOutcomeMessage, summaryLine;
local ConsoleWriter writer;
local array<EPlayer> currentPlayers;
if (model == none) {
return;
}
if (outcomeMessage != none) {
editedOutcomeMessage = outcomeMessage.MutableCopy();
}
if (editedOutcomeMessage != none && forcedBy != none) {
editedOutcomeMessage.Append(F(" {$TextEmphasis (forced by }"));
playerName = forcedBy.GetName();
editedOutcomeMessage.Append(playerName, _.text.FormattingFromColor(_.color.Gray));
_.memory.Free(playerName);
editedOutcomeMessage.Append(F("{$TextEmphasis )}"));
}
voteSummaryTemplate.Reset();
voteSummaryTemplate.ArgInt(model.GetVotesFor());
voteSummaryTemplate.ArgInt(model.GetVotesAgainst());
summaryLine = voteSummaryTemplate.CollectFormattedM();
currentPlayers = _.players.GetAll();
for (i = 0; i < currentPlayers.length; i += 1) {
writer = currentPlayers[i].BorrowConsole();
writer.Write(editedOutcomeMessage);
writer.Write(P(" / "));
writer.WriteLine(summaryLine);
currentPlayers[i].Notify(editedOutcomeMessage, summaryLine,, P("voting"));
}
_.memory.FreeMany(currentPlayers);
_.memory.Free(summaryLine);
}
defaultproperties {
// You can override these
preferredName = "test"
votingInfoLine = "Debug voting is running"
votingStartedLine = "Test voting has started"
votingSucceededLine = "{$TextPositive Test voting passed}"
votingFailedLine = "{$TextNegative Test voting has failed}"
permissionsConfigClass = class'VotingPermissions'
// You cannot override these
voteSummaryTemplateString = "Vote tally: {$TextPositive %1} vs {$TextNegative %2}"
playerVotedTemplateString = "Player {$TextSubtle %%player_name%%} has voted %%vote_type%% passing test voting"
playerVotedAnonymousTemplateString = "Someone has voted %%vote_type%% passing test voting"
timeRemaningAnnounceTemplateString = "Time remaining for voting: %1 seconds"
cannotVoteHint = "{$TextNegative You aren't allowed to vote :(}"
announcementTimings(0) = 60
announcementTimings(1) = 30
announcementTimings(2) = 15
announcementTimings(3) = 10
announcementTimings(4) = 5
announcementTimings(5) = 4
announcementTimings(6) = 3
announcementTimings(7) = 2
announcementTimings(8) = 1
}

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

@ -0,0 +1,440 @@
/**
* 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
dependsOn(MathApi);
//! This class counts votes according to the configured voting policies.
//!
//! Its main purpose is to separate the voting logic from the voting interface,
//! making the implementation simpler and the logic easier to test.
//!
//! # Usage
//!
//! 1. Allocate an instance of the [`VotingModel`] class.
//! 2. Call [`Start()`] to start voting with required policies.
//! 3. Use [`UpdatePotentialVoters()`] to specify which users are allowed to vote.
//! You can change this set at any time before the voting has concluded.
//! The method used to recount the votes will depend on the policies set
//! during the previous [`Initialize()`] call.
//! 4. Use [`CastVote()`] to add a vote from a user.
//! 5. After calling either [`UpdatePotentialVoters()`] or [`CastVote()`],
//! check [`GetStatus()`] to see if the voting has concluded.
//! Once voting has concluded, the result cannot be changed, so you can
//! release the reference to the [`VotingModel`] object.
//! 6. Alternatively, before voting has concluded naturally, you can use
//! [`ForceEnding()`] method to immediately end voting with result being
//! determined by provided [`ForceEndingType`] argument.
/// Current state of voting for this model.
enum VotingModelStatus {
/// 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
};
/// 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
};
/// Types of possible outcomes when forcing a voting to end
enum ForceEndingType {
/// Result will be decided by the votes that already have been cast
FET_CurrentLeader,
/// Voting will end in success
FET_Success,
/// Voting will end in failure
FET_Failure
};
var private VotingModelStatus status;
/// Specifies whether draw would count as a victory for corresponding voting.
var private bool policyDrawWinsVoting;
var private array<UserID> votesFor, votesAgainst;
/// Votes of people that voted before, but then were forbidden to vote
/// (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;
}
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.
///
/// The only available policy is configuring whether draw means victory or loss
/// in voting.
///
/// Can only be called once, after that will do nothing.
public final function Start(bool drawWinsVoting) {
if (status != VPM_Uninitialized) {
return;
}
policyDrawWinsVoting = drawWinsVoting;
status = VPM_InProgress;
}
/// Returns whether voting has already concluded.
///
/// This method should be checked after both [`CastVote()`] and
/// [`UpdatePotentialVoters()`] to check whether either of them was enough to
/// conclude the voting result.
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.
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 = GetVote(voter);
votesSameWay = (voteForSuccess && currentVote == PVS_VoteFor)
|| (!voteForSuccess && currentVote == PVS_VoteAgainst);
if (votesSameWay) {
return VFR_AlreadyVoted;
}
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()`].
///
/// 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 won't count.
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;
}
}
return PVS_NoVote;
}
/// Returns amount of current valid votes for the success of this voting.
public final function int GetVotesFor() {
return votesFor.length;
}
/// Returns amount of current valid votes against the success of this voting.
public final function int GetVotesAgainst() {
return votesAgainst.length;
}
/// Returns amount of users that are currently allowed to vote in this voting.
public final function int GetTotalPossibleVotes() {
return allowedVoters.length;
}
/// Checks whether, if stopped now, voting will win.
public final function bool IsVotingWinning() {
if (status == VPM_Success) return true;
if (status == VPM_Failure) return false;
if (GetVotesFor() > GetVotesAgainst()) return true;
if (GetVotesFor() < GetVotesAgainst()) return false;
return policyDrawWinsVoting;
}
/// Forcibly ends the voting, deciding winner depending on the argument.
///
/// Only does anything if voting is currently in progress
/// (in `VPM_InProgress` state).
///
/// By default decides result by the votes that already have been cast.
///
/// Returns `true` only if voting was actually ended with this call.
public final function bool ForceEnding(optional ForceEndingType type) {
if (status != VPM_InProgress) {
return false;
}
switch (type) {
case FET_CurrentLeader:
if (IsVotingWinning()) {
status = VPM_Success;
} else {
status = VPM_Failure;
}
break;
case FET_Success:
status = VPM_Success;
break;
case FET_Failure:
default:
status = VPM_Failure;
break;
}
return true;
}
private final function RecountVotes() {
local MathApi.IntegerDivisionResult divisionResult;
local int winningScore, losingScore;
local int totalPossibleVotes;
if (status != VPM_InProgress) {
return;
}
totalPossibleVotes = GetTotalPossibleVotes();
divisionResult = _.math.IntegerDivision(totalPossibleVotes, 2);
if (divisionResult.remainder == 1) {
// For odd amount of voters winning is simply majority
winningScore = divisionResult.quotient + 1;
} else {
if (policyDrawWinsVoting) {
// For even amount of voters, exactly half is enough if draw means victory
winningScore = divisionResult.quotient;
} else {
// Otherwise - majority
winningScore = divisionResult.quotient + 1;
}
}
// The `winningScore` represents the number of votes required for a mean victory.
// If the number of votes against the mean is less than or equal to
// `totalPossibleVotes - winningScore`, then victory is still possible.
// However, if there is even one additional vote against, then victory is no longer achievable
// and a loss is inevitable.
losingScore = (totalPossibleVotes - winningScore) + 1;
// `totalPossibleVotes < losingScore + winningScore`, so only one of these inequalities
// can be satisfied at a time
if (GetVotesFor() >= winningScore) {
status = VPM_Success;
} else if (GetVotesAgainst() >= losingScore) {
status = VPM_Failure;
}
}
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 {
}

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

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

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

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

277
sources/BaseAPI/API/SideEffects/SideEffect.uc

@ -0,0 +1,277 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2022-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 SideEffect extends AcediaObject;
//! Defines the concept of "side effects" in the context of the Acedia and its derivative mods.
//!
//! Side effects are changes that are not part of the mod's main functionality, but rather something
//! necessary to enable that functionality, while also possibly affecting how other mods work.
//! Documenting these side effects helps developers and server admins understand changes performed
//! by Acedia or mods based on it, and anticipate any potential conflicts or issues that may arise.
//!
//! It should be noted that what constitutes a side effect is loosely defined, and it is simply
//! a tool to inform others that something unexpected has happened, possibly breaking other mods.
//! AcediaCore aims to leave a minimal footprint, but still needs to make some changes
//! (e.g., adding GameRules, patching code of some functions), and [`SideEffects`] can be used to
//! document them.
//! Similarly, [`SideEffect`]s can be used to document changes made by AcediaFixes, a package meant
//! only for fixing bugs that inevitably needs to make many under-the-hood changes to achieve
//! that goal.
//!
//! On the other hand gameplay mods like Futility or Ire can make a lot of changes, but they can all
//! be just expected part of its direct functionality: we expect feature that shares dosh of leavers
//! to alter players' dosh values, so this is not a side effect.
//! Such mods are likely not going to have to specify any side effects whatsoever.
var private Text name;
var private Text description;
var private Text package;
var private Text source;
var private Text status;
var private bool initialized;
protected function Finalizer() {
_.memory.Free(name);
_.memory.Free(description);
_.memory.Free(package);
_.memory.Free(source);
_.memory.Free(status);
name = none;
description = none;
package = none;
source = none;
status = none;
initialized = false;
}
/// Checks whether caller [`SideEffect`] was initialized.
///
/// Initialization must happen directly after creation and only initialized instances should
/// ever be used.
public final function bool IsInitialized() {
return initialized;
}
/// This function is used to set the initial values of the [`SideEffect`] object properties when it
/// is first created.
///
/// All arguments must be not `none`.
///
/// Returns `true` if the initialization was successful, `false` otherwise (including the case where
/// the [`SideEffect`] object has already been initialized).
public final function bool Initialize(
BaseText sideEffectName,
BaseText sideEffectDescription,
BaseText sideEffectPackage,
BaseText sideEffectSource,
BaseText sideEffectStatus
) {
if (initialized) return false;
if (sideEffectName == none) return false;
if (sideEffectDescription == none) return false;
if (sideEffectPackage == none) return false;
if (sideEffectSource == none) return false;
if (sideEffectStatus == none) return false;
name = sideEffectName.Copy();
description = sideEffectDescription.Copy();
package = sideEffectPackage.Copy();
source = sideEffectSource.Copy();
status = sideEffectStatus.Copy();
initialized = true;
return true;
}
/// This function is used to set the initial values of the [`SideEffect`] object properties when it
/// is first created.
///
/// Returns `true` if the initialization was successful, `false` otherwise (including the case where
/// the [`SideEffect`] object has already been initialized).
public final function bool Initialize_S(
string sideEffectName,
string sideEffectDescription,
string sideEffectPackage,
string sideEffectSource,
string sideEffectStatus
) {
name = _.text.FromString(sideEffectName);
description = _.text.FromString(sideEffectDescription);
package = _.text.FromString(sideEffectPackage);
source = _.text.FromString(sideEffectSource);
status = _.text.FromString(sideEffectStatus);
initialized = true;
return true;
}
/// Returns a brief summary that conveys the purpose of the caller [SideEffect] to the user in
/// a clear and concise manner.
///
/// While there is no hard limit on the length of this value, it is recommended to keep it under 80
/// characters for readability.
///
/// Returned value for initialized [`SideEffect`] is guaranteed to not be `none`, as it is
/// a required property of the [`SideEffect`] object.
public final function Text GetName() {
if (initialized) {
return name.Copy();
}
return none;
}
/// Returns a brief summary that conveys the purpose of the caller [SideEffect] to the user in
/// a clear and concise manner.
///
/// While there is no hard limit on the length of this value, it is recommended to keep it under 80
/// characters for readability.
public final function string GetName_S() {
if (initialized && name != none) {
return name.ToString();
}
return "";
}
/// Returns the detailed description of the caller [`SideEffect`], which describes what was done
/// and why the relevant change was necessary.
///
/// Returned value for initialized [`SideEffect`] is guaranteed to not be `none`, as it is
/// a required property of the [`SideEffect`] object.
public final function Text GetDescription() {
if (initialized) {
return description.Copy();
}
return none;
}
/// Returns the detailed description of the caller [`SideEffect`], which describes what was done
/// and why the relevant change was necessary.
public final function string GetDescription_S() {
if (initialized && description != none) {
return description.ToString();
}
return "";
}
/// Returns the name of the package ("*.u" file) that introduced the changes
/// represented by the caller `SideEffect`.
///
/// It should be noted that even if a different package requested the functionality that led to
/// the changes being made, the package responsible for the side effect is the one that performed
/// the changes.
///
/// Returned value for initialized [`SideEffect`] is guaranteed to not be `none`, as it is
/// a required property of the [`SideEffect`] object.
public final function Text GetPackage() {
if (initialized) {
return package.Copy();
}
return none;
}
/// Returns the name of the package ("*.u" file) that introduced the changes
/// represented by the caller `SideEffect`.
///
/// It should be noted that even if a different package requested the functionality that led to
/// the changes being made, the package responsible for the side effect is the one that performed
/// the changes.
public final function string GetPackage_S() {
if (initialized && package != none) {
return package.ToString();
}
return "";
}
/// The origin of this change within the package is specified, and for larger packages, additional
/// details can be provided to clarify the cause of the change.
///
/// Returned value for initialized [`SideEffect`] is guaranteed to not be `none`, as it is
/// a required property of the [`SideEffect`] object.
public final function Text GetSource() {
if (initialized) {
return source.Copy();
}
return none;
}
/// The origin of this change within the package is specified, and for larger packages, additional
/// details can be provided to clarify the cause of the change.
public final function string GetSource_S() {
if (initialized && source != none) {
return source.ToString();
}
return "";
}
/// The status of the caller [`SideEffect`], that is used to differentiate between different ways
/// that a side effect may have been introduced, allowing for better tracking and management of
/// the effect.
///
/// Returned value for initialized [`SideEffect`] is guaranteed to not be `none`, as it is
/// a required property of the [`SideEffect`] object.
public final function Text GetStatus() {
if (initialized) {
return status.Copy();
}
return none;
}
/// The status of the caller [`SideEffect`], that is used to differentiate between different ways
/// that a side effect may have been introduced, allowing for better tracking and management of
/// the effect.
public final function string GetStatus_S() {
if (initialized && status != none) {
return status.ToString();
}
return "";
}
public function bool IsEqual(Object other) {
local SideEffect otherSideEffect;
if (self == other) return true;
otherSideEffect = SideEffect(other);
if (otherSideEffect == none) return false;
if (!otherSideEffect.initialized) return false;
if (GetHashCode() != otherSideEffect.GetHashCode()) return false;
if (!name.Compare(otherSideEffect.name,, SFORM_SENSITIVE)) return false;
if (!package.Compare(otherSideEffect.package,, SFORM_SENSITIVE)) return false;
if (!source.Compare(otherSideEffect.source,, SFORM_SENSITIVE)) return false;
if (!status.Compare(otherSideEffect.status,, SFORM_SENSITIVE)) return false;
if (!description.Compare(otherSideEffect.description,, SFORM_SENSITIVE)) return false;
return true;
}
protected function int CalculateHashCode() {
local int result;
if (initialized) {
result = name.GetHashCode();
result = CombineHash(result, description.GetHashCode());
result = CombineHash(result, package.GetHashCode());
result = CombineHash(result, source.GetHashCode());
result = CombineHash(result, status.GetHashCode());
return result;
}
}
defaultproperties {
}

212
sources/BaseAPI/API/SideEffects/SideEffectAPI.uc

@ -0,0 +1,212 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2022-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 SideEffectAPI extends AcediaObject;
var private array<SideEffect> activeSideEffects;
/// Returns an array containing all SideEffect objects that have been registered up to this point.
///
/// The order of the elements in the array is not guaranteed.
public function array<SideEffect> GetAll() {
local int i;
for (i = 0; i < activeSideEffects.length; i += 1) {
activeSideEffects[i].NewRef();
}
return activeSideEffects;
}
/// Returns all registered [`SideEffects`] that are associated with the specified package name
/// (case-insensitive).
public function array<SideEffect> GetFromPackage(BaseText packageName) {
local int i;
local Text nextPackage;
local array<SideEffect> result;
if (packageName == none) {
return result;
}
for (i = 0; i < activeSideEffects.length; i += 1) {
nextPackage = activeSideEffects[i].GetPackage();
if (packageName.Compare(nextPackage, SCASE_INSENSITIVE)) {
activeSideEffects[i].NewRef();
result[result.length] = activeSideEffects[i];
}
_.memory.Free(nextPackage);
}
return result;
}
/// Adds a new side effect to the list of active side effects.
///
/// This method will fail if any of its arguments are `none` or a side effect with that exact
/// contents was already added.
public function SideEffect Add(
BaseText sideEffectName,
BaseText sideEffectDescription,
BaseText sideEffectPackage,
BaseText sideEffectSource,
BaseText sideEffectStatus
) {
local bool initialized;
local SideEffect newSideEffect;
newSideEffect = SideEffect(_.memory.Allocate(class'SideEffect'));
initialized = newSideEffect.Initialize(
sideEffectName,
sideEffectDescription,
sideEffectPackage,
sideEffectSource,
sideEffectStatus);
if (initialized) {
if (!AddInstance(newSideEffect)) {
_.memory.Free(newSideEffect);
return none;
}
} else {
_.memory.Free(newSideEffect);
return none;
}
return newSideEffect;
}
/// Adds a new side effect to the list of active side effects.
///
/// This method will fail if a side effect with that exact contents was already added.
public function SideEffect Add_S(
string sideEffectName,
string sideEffectDescription,
string sideEffectPackage,
string sideEffectSource,
string sideEffectStatus
) {
local bool initialized;
local SideEffect newSideEffect;
newSideEffect = SideEffect(_.memory.Allocate(class'SideEffect'));
initialized = newSideEffect.Initialize_S(
sideEffectName,
sideEffectDescription,
sideEffectPackage,
sideEffectSource,
sideEffectStatus);
if (initialized) {
if (!AddInstance(newSideEffect)) {
_.memory.Free(newSideEffect);
return none;
}
} else {
return none;
}
return newSideEffect;
}
/// Checks whether specified [`SideEffect`] is currently active.
///
/// Check is done via contents and not instance equality.
/// Returns `true` if specified [`SideEffect`] is currently active and `false` otherwise.
public function bool IsRegistered(SideEffect sideEffectToCheck) {
local int i;
if (sideEffectToCheck == none) return false;
if (!sideEffectToCheck.IsInitialized()) return false;
for (i = 0; i < activeSideEffects.length; i += 1) {
if (activeSideEffects[i].IsEqual(sideEffectToCheck)) {
return true;
}
}
return false;
}
/// Adds a new side effect to the list of active side effects.
///
/// This method will fail if its argument is `none`, non-initialized or a side effect with that
/// exact contents was already added.
public function bool AddInstance(SideEffect newSideEffect) {
local int i;
if (newSideEffect == none) return false;
if (!newSideEffect.IsInitialized()) return false;
for (i = 0; i < activeSideEffects.length; i += 1) {
if (activeSideEffects[i].IsEqual(newSideEffect)) {
return false;
}
}
newSideEffect.NewRef();
activeSideEffects[activeSideEffects.length] = newSideEffect;
LogAddingSideEffectChange(newSideEffect, true);
return true;
}
/// Removes a side effect from the list of active side effects.
///
/// This method will fail if its argument is `none`, non-initialized or a side effect with its
/// contents isn't in the records.
public function bool RemoveInstance(SideEffect inactiveSideEffect) {
local int i;
local bool foundInstance;
if (inactiveSideEffect == none) {
return false;
}
for (i = 0; i < activeSideEffects.length; i += 1) {
if (activeSideEffects[i].IsEqual(inactiveSideEffect)) {
LogAddingSideEffectChange(activeSideEffects[i], false);
_.memory.Free(activeSideEffects[i]);
activeSideEffects.Remove(i, 1);
foundInstance = true;
}
}
return foundInstance;
}
private function LogAddingSideEffectChange(SideEffect effect, bool added) {
local MutableText builder;
local Text sideEffectData;
if (effect == none) {
return;
}
builder = _.text.Empty();
if (added) {
builder.Append(P("NEW SIDE EFFECT: "));
} else {
builder.Append(P("REMOVED SIDE EFFECT: "));
}
sideEffectData = effect.GetName();
builder.Append(sideEffectData);
_.memory.Free(sideEffectData);
sideEffectData = effect.GetStatus();
if (sideEffectData != none) {
builder.Append(P(" {"));
builder.Append(sideEffectData);
_.memory.Free(sideEffectData);
builder.Append(P("}"));
}
_.logger.Info(builder);
builder.FreeSelf();
}
defaultproperties {
}

76
sources/BaseAPI/API/Unflect/FunctionReplacement.uc

@ -0,0 +1,76 @@
/**
* Config class for storing map lists.
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class FunctionReplacement extends AcediaObject;
var private Text replaced;
var private Text replacer;
var private SideEffect effect;
protected function Finalizer() {
_.memory.Free(replaced);
_.memory.Free(replacer);
_.memory.Free(effect);
replaced = none;
replacer = none;
effect = none;
}
public static final function FunctionReplacement Make(
BaseText oldFunction,
BaseText newFunction,
SideEffect sideEffect
) {
local FunctionReplacement newReplacement;
if (oldFunction == none) return none;
if (newFunction == none) return none;
if (sideEffect == none) return none;
newReplacement = FunctionReplacement(__().memory.Allocate(class'FunctionReplacement'));
newReplacement.replaced = oldFunction.Copy();
newReplacement.replacer = newFunction.Copy();
sideEffect.NewRef();
newReplacement.effect = sideEffect;
return newReplacement;
}
public final function Text GetReplacedFunctionName() {
return replaced.Copy();
}
public final function string GetReplacedFunctionName_S() {
return replaced.ToString();
}
public final function Text GetReplacerFunctionName() {
return replacer.Copy();
}
public final function string GetReplacerFunctionName_S() {
return replacer.ToString();
}
public final function SideEffect GetSideEffect() {
effect.NewRef();
return effect;
}
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/Tests/MockInitialClass.uc

@ -0,0 +1,30 @@
/**
* Config class for storing map lists.
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class MockInitialClass extends AcediaObject;
var public int counter;
public final function int DoIt() {
counter += 1;
return counter;
}
defaultproperties {
}

33
sources/BaseAPI/API/Unflect/Tests/MockReplacerClass.uc

@ -0,0 +1,33 @@
/**
* Config class for storing map lists.
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class MockReplacerClass extends MockInitialClass;
public final function int DoIt2() {
counter += 2;
return -counter;
}
public final function int DoIt3() {
counter -= 1;
return 7;
}
defaultproperties {
}

87
sources/BaseAPI/API/Unflect/Tests/TEST_Unflect.uc

@ -0,0 +1,87 @@
/**
* Set of tests for `Command` class.
* 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_Unflect extends TestCase
abstract;
protected static function TESTS() {
local MockInitialClass obj;
obj = MockInitialClass(__().memory.Allocate(class'MockInitialClass'));
Context("Replacing functions with `UnflectApi`");
Test_InitialReplacement(obj);
Test_SecondReplacement(obj);
Test_ReplacementWithSelf(obj);
Test_RevertingReplacement(obj);
}
protected static function Test_InitialReplacement(MockInitialClass obj) {
Issue("Functions aren't being replaced correctly the first time.");
TEST_ExpectTrue(__().unflect.ReplaceFunction_S(
"AcediaCore.MockInitialClass.DoIt",
"AcediaCore.MockReplacerClass.DoIt2",
"testing"));
TEST_ExpectTrue(obj.DoIt() == -2);
TEST_ExpectTrue(obj.counter == 2);
TEST_ExpectTrue(obj.DoIt() == -4);
TEST_ExpectTrue(obj.counter == 4);
}
protected static function Test_SecondReplacement(MockInitialClass obj) {
Issue("Functions aren't being replaced correctly in case they were already replaced.");
TEST_ExpectTrue(__().unflect.ReplaceFunction_S(
"AcediaCore.MockInitialClass.DoIt",
"AcediaCore.MockReplacerClass.DoIt3",
"testing"));
TEST_ExpectTrue(obj.DoIt() == 7);
TEST_ExpectTrue(obj.counter == 3);
TEST_ExpectTrue(obj.DoIt() == 7);
TEST_ExpectTrue(obj.counter == 2);
}
protected static function Test_ReplacementWithSelf(MockInitialClass obj) {
Issue("Attempting to replacing function with itself makes unexpected change.");
TEST_ExpectFalse(__().unflect.ReplaceFunction_S(
"AcediaCore.MockInitialClass.DoIt",
"AcediaCore.MockInitialClass.DoIt",
"testing"));
TEST_ExpectTrue(obj.DoIt() == 7);
TEST_ExpectTrue(obj.counter == 1);
}
protected static function Test_RevertingReplacement(MockInitialClass obj) {
Issue("Reverting replaced function doesn't work.");
TEST_ExpectTrue(__().unflect.RevertFunction_S("AcediaCore.MockInitialClass.DoIt"));
TEST_ExpectTrue(obj.DoIt() == 2);
TEST_ExpectTrue(obj.counter == 2);
TEST_ExpectTrue(obj.DoIt() == 3);
TEST_ExpectTrue(obj.counter == 3);
Issue("Reverting already reverted function ends in success.");
TEST_ExpectFalse(__().unflect.RevertFunction_S("AcediaCore.MockInitialClass.DoIt"));
Issue("Reverting already reverted function leads to unexpected results.");
TEST_ExpectTrue(obj.DoIt() == 4);
TEST_ExpectTrue(obj.counter == 4);
}
defaultproperties {
caseName = "Function replacement"
caseGroup = "Unflect"
}

29
sources/BaseAPI/API/Unflect/TypeCast.uc

@ -0,0 +1,29 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 TypeCast extends Object;
var Object nativeType;
final function NativeCast(Object type) {
nativeType = type;
}
defaultproperties {
}

49
sources/BaseAPI/API/Unflect/UClass.uc

@ -0,0 +1,49 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UClass extends UState within Package;
var int classFlags;
var int classUnique;
var Guid classGuid;
var UClass classWithin;
var name classConfigName;
var array<struct RepRecord {
var UProperty property;
var int index;
}> classReps;
var array<UField> netFields;
var array<struct Dependency {
var UClass class;
var int deep;
var int scriptTextCRC;
}> dependencies;
var array<name> packageImports;
var array<byte> defaults;
var array<name> hideCategories;
var array<name> dependentOn;
var string defaultPropText;
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/UClassCast.uc

@ -0,0 +1,30 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UClassCast extends Object;
var UClass nativeType;
final function UClass Cast(Class type) {
super(TypeCast).NativeCast(type);
return nativeType;
}
defaultproperties {
}

28
sources/BaseAPI/API/Unflect/UField.uc

@ -0,0 +1,28 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UField extends Object
abstract;
var UField superField;
var UField next;
var UField hashNext;
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/UFieldCast.uc

@ -0,0 +1,30 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UFieldCast extends Object;
var UField nativeType;
final function UField Cast(Field type) {
super(TypeCast).NativeCast(type);
return nativeType;
}
defaultproperties {
}

35
sources/BaseAPI/API/Unflect/UFunction.uc

@ -0,0 +1,35 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UFunction extends UStruct within UState
dependson(Unflect);
var byte functionMD5Digest[16];
var int functionFlags;
var Unflect.Int16 nativeIndex;
var Unflect.Int16 repOffset;
var byte operPrecedence;
var byte numParms;
var Unflect.Int16 parmsSize;
var Unflect.Int16 returnValueOffset;
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/UFunctionCast.uc

@ -0,0 +1,30 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UFunctionCast extends Object;
var UFunction nativeType;
final function UFunction Cast(Function type) {
super(TypeCast).NativeCast(type);
return nativeType;
}
defaultproperties {
}

41
sources/BaseAPI/API/Unflect/UProperty.uc

@ -0,0 +1,41 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UProperty extends UField within UField
abstract;
var int arrayDim;
var int elementSize;
var int propertyFlags;
var name category;
var byte repOffset[2];
var byte repIndex[2];
var transient int offset;
var transient UProperty propertyLinkNext;
var transient UProperty configLinkNext;
var transient UProperty constructorLinkNext;
var transient UProperty nextRef;
var transient UProperty repOwner;
var string commentString;
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/UPropertyCast.uc

@ -0,0 +1,30 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UPropertyCast extends Object;
var UProperty nativeType;
final function UProperty Cast(Property type) {
super(TypeCast).NativeCast(type);
return nativeType;
}
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/UState.uc

@ -0,0 +1,30 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UState extends UStruct
dependson(Unflect);
var Unflect.Int64 probeMask;
var Unflect.Int64 ignoreMask;
var int stateFlags;
var Unflect.Int16 labelTableOffset;
var UField vfHash[256];
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/UStateCast.uc

@ -0,0 +1,30 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UStateCast extends Object;
var UState nativeType;
final function UState Cast(State type) {
super(TypeCast).NativeCast(type);
return nativeType;
}
defaultproperties {
}

48
sources/BaseAPI/API/Unflect/UStruct.uc

@ -0,0 +1,48 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UStruct extends UField;
var UTextBuffer scriptText;
var UTextBuffer cppText;
var UField children;
var int propertiesSize;
var name friendlyName;
var array<byte> script;
var int textPos;
var int line;
var struct EStructFlags {
var bool native;
var bool export;
var bool long;
var bool init;
var bool unused1;
var bool unused2;
var bool unused3;
var bool unused4;
} StructFlags;
var Property refLink;
var Property propertyLink;
var Property configLink;
var Property constructorLink;
defaultproperties {
}

30
sources/BaseAPI/API/Unflect/UStructCast.uc

@ -0,0 +1,30 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UStructCast extends Object;
var UStruct nativeType;
final function UStruct Cast(/*Core.Struct*/ Object type) {
super(TypeCast).NativeCast(type);
return nativeType;
}
defaultproperties {
}

28
sources/BaseAPI/API/Unflect/UTextBuffer.uc

@ -0,0 +1,28 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 UTextBuffer extends Object;
var private native const pointer outputDeviceVtbl;
var int pos, top;
var string text;
defaultproperties {
}

32
sources/BaseAPI/API/Unflect/Unflect.uc

@ -0,0 +1,32 @@
/**
* One of the original Unflect files.
* Copyright 2022-2023 EliotVU
*------------------------------------------------------------------------------
* 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 Unflect extends Object
abstract;
struct Int16 {
var byte h, l;
};
struct Int64 {
var int h, l;
};
defaultproperties {
}

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

@ -0,0 +1,432 @@
/**
* Config class for storing map lists.
* Copyright 2020 bibibi
* 2020-2023 Shtoyan
* 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 UnflectApi extends AcediaObject;
//! This API offers advanced reflection capabilities for Unreal Script.
//!
//! Currently, the API supports the ability to replace the code of existing functions with
//! custom code.
//! This can greatly simplify the process of bug fixing and hooking into game events.
/// A variable responsible for replacing function code in real-time.
/// This variable is used to support dynamic function replacement/patching and event interception
/// at runtime.
var private UFunctionCast functionCaster;
/// Maps lower case function name (specifies by the full path "package.class.functionName")
/// to a `FunctionRule` that completely describes how it was replaced
var private HashTable completedReplacements;
/// Maps lower case function name (specifies by the full path "package.class.functionName")
/// to the `ByteArrayBox` with that function's original code.
var private HashTable originalScriptCodes;
var private LoggerAPI.Definition warnSameFunction;
var private LoggerAPI.Definition warnOverridingReplacement, errFailedToFindFunction;
var private LoggerAPI.Definition errReplacementWithoutSources, errCannotCreateReplacementRule;
protected function Constructor() {
functionCaster = new class'UFunctionCast';
completedReplacements = _.collections.EmptyHashTable();
originalScriptCodes = _.collections.EmptyHashTable();
}
protected function Finalizer() {
_drop();
_.memory.Free(completedReplacements);
_.memory.Free(originalScriptCodes);
completedReplacements = none;
originalScriptCodes = none;
functionCaster = none;
}
public final function _drop() {
local UFunction nextFunctionInstance;
local Text nextFunctionName;
local HashTableIterator iter;
local ByteArrayBox nextSources;
// Drop is called when Acedia is shutting down, so releasing references isn't necessary
iter = HashTableIterator(completedReplacements.Iterate());
while (!iter.HasFinished()) {
nextFunctionInstance = none;
nextFunctionName = Text(iter.GetKey());
nextSources = ByteArrayBox(originalScriptCodes.GetItem(nextFunctionName));
if (nextSources != none ) {
nextFunctionInstance = FindFunction(nextFunctionName);
}
if (nextFunctionInstance != none) {
nextFunctionInstance.script = nextSources.Get();
}
iter.Next();
}
}
/// Reverts the replacement of the function's code, restoring its original behavior.
///
/// The function to be reverted should be specified using its full path, in the format
/// "package.class.functionName" (e.g. "KFMod.KFPawn.TossCash").
///
/// It's worth noting that several function replacements do not stack.
/// Even if [`ReplaceFunction()`] was called multiple times in a row to replace the same function,
/// this method will cancel all the changes at once.
///
/// This method returns true if the specified function was previously replaced and has now been
/// successfully reverted.
///
/// # Errors
///
/// If the specified function cannot be found (but [`functionName`] isn't `none`), or if
/// UnflectApi has not yet replaced it with any other function, this method will log an error.
public final function bool RevertFunction(BaseText functionName) {
local bool result;
local FunctionReplacement storedReplacement;
local ByteArrayBox storedSources;
local Text functionNameLowerCase;
local UFunction functionInstance;
local SideEffect sideEffect;
if (functionName == none) {
return false;
}
functionNameLowerCase = functionName.LowerCopy();
storedReplacement = FunctionReplacement(completedReplacements.GetItem(functionNameLowerCase));
if (storedReplacement != none) {
storedSources = ByteArrayBox(originalScriptCodes.GetItem(functionNameLowerCase));
if (storedSources == none) {
_.logger.Auto(errReplacementWithoutSources).Arg(functionNameLowerCase.Copy());
} else {
functionInstance = FindFunction(functionNameLowerCase);
if (functionInstance != none) {
functionInstance.script = storedSources.Get();
completedReplacements.RemoveItem(functionNameLowerCase);
sideEffect = storedReplacement.GetSideEffect();
_.sideEffects.RemoveInstance(sideEffect);
result = true;
} else {
_.logger.Auto(errFailedToFindFunction).Arg(functionNameLowerCase.Copy());
}
}
}
_.memory.Free4(storedReplacement, functionNameLowerCase, storedSources, sideEffect);
return result;
}
/// Reverts the replacement of the function's code, restoring its original behavior.
///
/// The function to be reverted should be specified using its full path, in the format
/// "package.class.functionName" (e.g. "KFMod.KFPawn.TossCash").
///
/// It's worth noting that several function replacements do not stack.
/// Even if [`ReplaceFunction()`] was called multiple times in a row to replace the same function,
/// this method will cancel all the changes at once.
///
/// This method returns true if the specified function was previously replaced and has now been
/// successfully reverted.
///
/// # Errors
///
/// If the specified function cannot be found (but [`functionName`] isn't `none`), or if
/// UnflectApi has not yet replaced it with any other function, this method will log an error.
public final function bool RevertFunction_S(string functionName) {
local bool result;
local MutableText wrapper;
wrapper = _.text.FromStringM(functionName);
result = RevertFunction(wrapper);
_.memory.Free(wrapper);
return result;
}
/// Determines whether the specified function has been replaced by UnflectApi.
///
/// The function to be checked should be specified using its full path, in the format
/// "package.class.functionName" (e.g. "KFMod.KFPawn.TossCash").
///
/// If the function has been replaced, this method will return `true`;
/// otherwise, it will return `false`.
public final function bool IsFunctionReplaced(BaseText functionName) {
local bool result;
local Text functionNameLowerCase;
if (functionName == none) {
return false;
}
functionNameLowerCase = functionName.LowerCopy();
result = completedReplacements.HasKey(functionNameLowerCase);
_.memory.Free(functionNameLowerCase);
return result;
}
/// Determines whether the specified function has been replaced by UnflectApi.
///
/// The function to be checked should be specified using its full path, in the format
/// "package.class.functionName" (e.g. "KFMod.KFPawn.TossCash").
///
/// If the function has been replaced, this method will return `true`;
/// otherwise, it will return `false`.
public final function bool IsFunctionReplaced_S(string functionName) {
local bool result;
local MutableText wrapper;
wrapper = _.text.FromStringM(functionName);
result = IsFunctionReplaced(wrapper);
_.memory.Free(wrapper);
return result;
}
/// Replaces one function with another by modifying its script code in real-time.
///
/// The reason for replacement must be specified and will serve as a human-readable explanation
/// for a [SideEffect] associated with the replacement.
///
/// If you need to replace a function in a class, follow these steps:
///
/// 1. Create a new class that extends the class in which the function you want to replace is
/// located.
/// 2. Declare that function in the created class.
/// 3. **DO NOT** change the function declaration and argument types/amount.
/// 4. **DO NOT** create new local variables, as this can cause random crashes.
/// If you need additional variables, make them global and access them using the
/// `class'myNewClass'.default.myNewVariable` syntax.
/// 5. If you want to call or override parent code, make sure to always specify the desired parent
/// class name.
/// For example, use `super(TargetClass).PostBeginPlay()` instead of `super.PostBeginPlay()`.
/// This will prevent runaway loop crashes.
/// 6. Make your edits to the function's code, and then call the replacement function:
/// ```unrealscript
/// _.unflect.ReplaceFunction(
/// "package.class.targetFunction",
/// "myNewPackage.myNewClass.newFunction");
/// ```
///
/// Following these steps will help ensure that your code changes are compatible with the rest of
/// the codebase and do not cause unexpected crashes.
///
/// # Errors
///
/// This method can log error messages in cases where:
///
/// * The specified function(s) cannot be found.
/// * An attempt is made to replace a function with itself.
/// * An attempt is made to replace a function that has already been replaced.
public final function bool ReplaceFunction(
BaseText oldFunction,
BaseText newFunction,
BaseText replacementReason
) {
local bool result;
local Text oldFunctionLowerCase, newFunctionLowerCase;
if (oldFunction == none) return false;
if (newFunction == none) return false;
oldFunctionLowerCase = oldFunction.LowerCopy();
newFunctionLowerCase = newFunction.LowerCopy();
result = _replaceFunction(oldFunctionLowerCase, newFunctionLowerCase);
if (result) {
RecordNewReplacement(oldFunctionLowerCase, newFunctionLowerCase, replacementReason);
}
_.memory.Free2(oldFunctionLowerCase, newFunctionLowerCase);
return result;
}
/// Replaces one function with another by modifying its script code in real-time.
///
/// The reason for replacement must be specified and will serve as a human-readable explanation
/// for a [SideEffect] associated with the replacement.
///
/// If you need to replace a function in a class, follow these steps:
///
/// 1. Create a new class that extends the class in which the function you want to replace is
/// located.
/// 2. Declare that function in the created class.
/// 3. **DO NOT** change the function declaration and argument types/amount.
/// 4. **DO NOT** create new local variables, as this can cause random crashes.
/// If you need additional variables, make them global and access them using the
/// `class'myNewClass'.default.myNewVariable` syntax.
/// 5. If you want to call or override parent code, make sure to always specify the desired parent
/// class name.
/// For example, use `super(TargetClass).PostBeginPlay()` instead of `super.PostBeginPlay()`.
/// This will prevent runaway loop crashes.
/// 6. Make your edits to the function's code, and then call the replacement function:
/// ```unrealscript
/// _.unflect.ReplaceFunction(
/// "package.class.targetFunction",
/// "myNewPackage.myNewClass.newFunction");
/// ```
///
/// Following these steps will help ensure that your code changes are compatible with the rest of
/// the codebase and do not cause unexpected crashes.
///
/// # Errors
///
/// This method can log error messages in cases where:
///
/// * The specified function(s) cannot be found.
/// * An attempt is made to replace a function with itself.
/// * An attempt is made to replace a function that has already been replaced.
public final function bool ReplaceFunction_S(
string oldFunction,
string newFunction,
string replacementReason
) {
local Text oldWrapper, newWrapper, reasonWrapper;
local bool result;
oldWrapper = _.text.FromString(oldFunction);
newWrapper = _.text.FromString(newFunction);
reasonWrapper = _.text.FromString(replacementReason);
result = ReplaceFunction(oldWrapper, newWrapper, reasonWrapper);
_.memory.Free3(oldWrapper, newWrapper, reasonWrapper);
return result;
}
// Does actual work for function replacement.
// Arguments are assumed to be not `none` and in lower case.
private final function bool _replaceFunction(Text oldFunctionLowerCase, Text newFunctionLowerCase) {
local ByteArrayBox initialCode;
local UFunction replace, with;
replace = FindFunction(oldFunctionLowerCase);
if (replace == none) {
_.logger.Auto(errFailedToFindFunction).Arg(oldFunctionLowerCase.Copy());
return false;
}
with = FindFunction(newFunctionLowerCase);
if (with == none) {
_.logger.Auto(errFailedToFindFunction).Arg(newFunctionLowerCase.Copy());
return false;
}
if (replace == with) {
_.logger.Auto(warnSameFunction).Arg(oldFunctionLowerCase.Copy());
return false;
}
// Remember old code, if haven't done so yet.
// Since we attempt it on each replacement, the first recorded `script` value will be
// the initial code.
if (!originalScriptCodes.HasKey(oldFunctionLowerCase)) {
initialCode = _.box.ByteArray(replace.script);
originalScriptCodes.SetItem(oldFunctionLowerCase, initialCode);
_.memory.Free(initialCode);
}
replace.script = with.script;
return true;
}
// Arguments assumed to be not `none` and in lower case.
private final function UFunction FindFunction(Text functionNameLowerCase) {
local string stringName;
stringName = functionNameLowerCase.ToString();
// We need to make sure functions are loaded before performing the replacement.
DynamicLoadObject(GetClassName(stringName), class'class', true);
return functionCaster.Cast(function(FindObject(stringName, class'Function')));
}
// Arguments are assumed to be not `none`.
// `oldFunctionLowerCase` and `newFunctionLowerCase` are assumed to be in lower case.
private final function RecordNewReplacement(
Text oldFunctionLowerCase,
Text newFunctionLowerCase,
BaseText replacementReason
) {
local SideEffect oldSideEffect, newSideEffect;
local FunctionReplacement oldRule, newRule;
// Remove old `FunctionReplacement`, if there is any
oldRule = FunctionReplacement(completedReplacements.GetItem(oldFunctionLowerCase));
if (oldRule != none) {
_.logger
.Auto(warnOverridingReplacement)
.Arg(oldFunctionLowerCase.Copy())
.Arg(oldRule.GetReplacerFunctionName())
.Arg(newFunctionLowerCase.Copy());
oldSideEffect = oldRule.GetSideEffect();
_.sideEffects.RemoveInstance(oldSideEffect);
_.memory.Free2(oldRule, oldSideEffect);
}
// Create replacement instance
newSideEffect = MakeSideEffect(oldFunctionLowerCase, newFunctionLowerCase, replacementReason);
newRule = class'FunctionReplacement'.static
.Make(oldFunctionLowerCase, newFunctionLowerCase, newSideEffect);
completedReplacements.SetItem(oldFunctionLowerCase, newRule);
if (newRule == none) {
_.logger
.Auto(errCannotCreateReplacementRule)
.Arg(oldFunctionLowerCase.Copy())
.Arg(newFunctionLowerCase.Copy());
}
}
// Arguments are assumed to be not `none`.
// `oldFunctionLowerCase` and `newFunctionLowerCase` are assumed to be in lower case.
private final function SideEffect MakeSideEffect(
Text oldFunctionLowerCase,
Text newFunctionLowerCase,
BaseText replacementReason
) {
local SideEffect sideEffect;
local MutableText status;
// Add side effect
status = oldFunctionLowerCase.MutableCopy();
status.Append(P(" -> "));
status.Append(newFunctionLowerCase);
sideEffect = _.sideEffects.Add(
P("Changed function's code"),
replacementReason,
P("AcediaCore"),
P("UnflectAPI"),
status
);
_.memory.Free(status);
return sideEffect;
}
// Get the "package + dot + class" string for DynamicLoadObject()
private final static function string GetClassName(string input) {
local array<string> parts;
// create an array
Split(input, ".", parts);
// state functions
if (parts.length < 3) {
return "";
}
if (parts.length == 4) {
ReplaceText(input, "." $ parts[2], "");
ReplaceText(input, "." $ parts[3], "");
} else {
ReplaceText(input, "." $ parts[2], "");
}
return input;
}
defaultproperties {
warnOverridingReplacement = (l=LOG_Error,m="Attempt to replace a function `%1` with function `%3` after it has already been replaced with `%2`.")
warnSameFunction = (l=LOG_Error,m="Attempt to replace a function `%1` with itself.")
errFailedToFindFunction = (l=LOG_Error,m="`UnflectApi` has failed to find function `%1`.")
errReplacementWithoutSources = (l=LOG_Error,m="Cannot restore function `%1` - its initial source code wasn't preserved. This most likely means that it wasn't yet replaced.")
errCannotCreateReplacementRule = (l=LOG_Error,m="`Cannot create new rule for replacing function `%1` with `%2` even though code was successfully replaces. This should happen, please report this.")
}

15
sources/BaseAPI/AcediaEnvironment/AcediaEnvironment.uc

@ -19,7 +19,8 @@
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class AcediaEnvironment extends AcediaObject;
class AcediaEnvironment extends AcediaObject
config(AcediaSystem);
//! API for management of running `Feature`s and loaded packages.
//!
@ -39,6 +40,8 @@ var private array<int> enabledFeaturesLifeVersions;
var private string manifestSuffix;
var private const config bool debugMode;
var private LoggerAPI.Definition infoRegisteringPackage, infoAlreadyRegistered;
var private LoggerAPI.Definition errNotRegistered, errFeatureAlreadyEnabled;
var private LoggerAPI.Definition warnFeatureAlreadyEnabled;
@ -214,6 +217,15 @@ private final function ReadManifest(class<_manifest> manifestClass) {
}
}
/// Returns `true` iff AcediaCore is running in the debug mode.
///
/// AcediaCore's debug mode allows features to enable functionality that is only useful during
/// development.
/// Whether AcediaCore is running in a debug mode is decided at launch and cannot be changed.
public final function bool IsDebugging() {
return debugMode;
}
/// Returns all packages registered in the caller [`AcediaEnvironment`].
public final function array< class<_manifest> > GetAvailablePackages() {
return availablePackages;
@ -390,6 +402,7 @@ public final function DisableAllFeatures() {
defaultproperties
{
manifestSuffix = ".Manifest"
debugMode = false
infoRegisteringPackage = (l=LOG_Info,m="Registering package \"%1\".")
infoAlreadyRegistered = (l=LOG_Info,m="Package \"%1\" is already registered.")
errNotRegistered = (l=LOG_Error,m="Package \"%1\" has failed to be registered.")

14
sources/BaseAPI/Global.uc

@ -35,6 +35,8 @@ class Global extends Object;
// For getting instance of [`Global`] from any object.
var protected Global myself;
var public UnflectApi unflect;
var public SideEffectAPI sideEffects;
var public RefAPI ref;
var public BoxAPI box;
var public MathAPI math;
@ -50,6 +52,7 @@ var public UserAPI users;
var public PlayersAPI players;
var public JsonAPI json;
var public SchedulerAPI scheduler;
var public CommandAPI commands;
var public AvariceAPI avarice;
var public AcediaEnvironment environment;
@ -74,14 +77,16 @@ public final static function Global GetInstance() {
protected function Initialize() {
// Special case that we cannot spawn with memory API since it obviously
// does not exist yet!
memory = new class'MemoryAPI';
memory = new class'MemoryAPI';
memory._constructor();
// `TextAPI` and `CollectionsAPI` need to be loaded before `LoggerAPI`
sideEffects = SideEffectAPI(memory.Allocate(class'SideEffectAPI'));
ref = RefAPI(memory.Allocate(class'RefAPI'));
box = BoxAPI(memory.Allocate(class'BoxAPI'));
text = TextAPI(memory.Allocate(class'TextAPI'));
math = MathAPI(memory.Allocate(class'MathAPI'));
collections = CollectionsAPI(memory.Allocate(class'CollectionsAPI'));
unflect = UnflectAPI(memory.Allocate(class'UnflectAPI'));
json = JsonAPI(memory.Allocate(class'JsonAPI'));
logger = LoggerAPI(memory.Allocate(class'LoggerAPI'));
color = ColorAPI(memory.Allocate(class'ColorAPI'));
@ -92,16 +97,21 @@ protected function Initialize() {
players = PlayersAPI(memory.Allocate(class'PlayersAPI'));
scheduler = SchedulerAPI(memory.Allocate(class'SchedulerAPI'));
avarice = AvariceAPI(memory.Allocate(class'AvariceAPI'));
commands = CommandAPI(memory.Allocate(class'CommandAPI'));
environment = AcediaEnvironment(memory.Allocate(class'AcediaEnvironment'));
}
/// Drops references to all API from base realm, including self-reference, previously returned by
/// [`Global::GetInstance()`] method.
public function DropCoreAPI() {
unflect._drop();
memory = none;
unflect = none;
sideEffects = none;
ref = none;
box = none;
text = none;
math = none;
collections = none;
logger = none;
alias = none;
@ -112,7 +122,9 @@ public function DropCoreAPI() {
players = none;
json = none;
scheduler = none;
commands = none;
avarice = none;
environment = none;
default.myself = none;
}

439
sources/Chat/ChatAPI.uc

@ -1,6 +1,8 @@
/**
* API that provides functions for working with chat.
* Copyright 2022 Anton Tarasenko
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2022-2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
@ -17,112 +19,342 @@
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class ChatAPI extends AcediaObject;
class ChatApi extends AcediaObject;
///! API for accessing chat-related events.
///!
///! # Implementation
///!
///! Signal functions that track text chat messages `OnMessage()` and `OnMessageFor()` simply
///! hook into [`BroadcastApi`] the first time such signal is requested.
///!
///! Signal function [`OnVoiceMessage()`] for tracking voice replaces a function in
///! [`KFPlayerController`] to track when they are replicated.
///! Then replaced function informs [`ChatApi`] about new voice message transmissions via
///! internal [`_EmitOnVoiceMessage()`] method.
/// Lists voice messages built-in in the game.
enum BuiltInVoiceMessage {
// Support
BIVM_SupportMedic,
BIVM_SupportHelp,
BIVM_SupportAskForMoney,
BIVM_SupportAskForWeapon,
// Acknowledgements
BIVM_AckYes,
BIVM_AckNo,
BIVM_AckThanks,
BIVM_AckSorry,
// Alert
BIVM_AlretLookOut,
BIVM_AlretRun,
BIVM_AlretWaitForMe,
BIVM_AlretWeldTheDoors,
BIVM_AlretLetsHoleUpHere,
BIVM_AlretFollowMe,
// Direction
BIVM_DirectionGetToTheTrader,
BIVM_DirectionGoUpstairs,
BIVM_DirectionGoDownstairs,
BIVM_DirectionGetOutside,
BIVM_DirectionGetInside,
// Insult
BIVM_InsultSpecimens,
BIVM_InsultPlayers,
// Trader
BIVM_TraderCheckWhereTheShopIs,
BIVM_TraderGetClose,
BIVM_TraderShopOpen,
BIVM_TraderShopOpenFinal,
BIVM_Trader30SecondsUntilShopCloses,
BIVM_TraderShopClosed,
BIVM_TraderCompliment,
BIVM_TraderNotEnoughMoney,
BIVM_TraderCannotCarry,
BIVM_TraderHurryUp1,
BIVM_TraderHurryUp2,
// Auto
BIVM_AutoWelding,
BIVM_AutoUnwelding,
BIVM_AutoReload,
BIVM_AutoOutOfAmmo,
BIVM_AutoDosh,
BIVM_AutoStandStillTryingToHealYou,
BIVM_AutoLowOnHealth,
BIVM_AutoBloatAcid,
BIVM_AutoPatriarchCloack,
BIVM_AutoPatriarchMinigun,
BIVM_AutoPatriarchRocketLauncher,
BIVM_AutoGrabbedByClot,
BIVM_AutoFleshpoundSpotted,
BIVM_AutoGorefastSpotted,
BIVM_AutoScrakeSpotted,
BIVM_AutoSirenSpotten,
BIVM_AutoSirenScream,
BIVM_AutoStalkerSpotted,
BIVM_AutoCrawlertSpotted,
BIVM_AutoMeleeKilledAStalker,
BIVM_AutoUsingFlamethrower,
BIVM_AutoEquipHuntingShotgun,
BIVM_AutoEquipHandcannons,
BIVM_AutoEquipLAW,
BIVM_AutoEquipFireaxe,
// Fallback
BIVM_Unknown
};
/// Killing Floor's native voice message is defined by `name` and `byte` pair.
/// This struct is added to allow returning them as a pair.
struct NativeVoiceMessage {
var name type;
var byte index;
};
/// Tracks whether we've already connected to broadcast signals.
var protected bool connectedToBroadcastAPI;
/// Tracks whether we've already replaced a function that allows us to catch voice messages.
var private bool replacedSendVoiceMessage;
var protected ChatAPI_OnMessage_Signal onMessageSignal;
var protected ChatAPI_OnMessageFor_Signal onMessageForSignal;
/// Auxiliary constants that store amount of values in [`BuiltInVoiceMessage`] before
/// a certain group.
/// Used for conversion between native voice messages and [`BuiltInVoiceMessage`]
var private const int VOICE_MESSAGES_BEFORE_ACKNOWLEDGEMENTS;
var private const int VOICE_MESSAGES_BEFORE_ALERTS;
var private const int VOICE_MESSAGES_BEFORE_DIRECTIONS;
var private const int VOICE_MESSAGES_BEFORE_INSULTS;
var private const int VOICE_MESSAGES_BEFORE_TRADER;
var private const int VOICE_MESSAGES_BEFORE_AUTO;
var private const int VOICE_MESSAGES_TOTAL;
protected function Constructor()
{
onMessageSignal = ChatAPI_OnMessage_Signal(
_.memory.Allocate(class'ChatAPI_OnMessage_Signal'));
onMessageForSignal = ChatAPI_OnMessageFor_Signal(
_.memory.Allocate(class'ChatAPI_OnMessageFor_Signal'));
var protected ChatAPI_OnMessage_Signal onMessageSignal;
var protected ChatAPI_OnMessageFor_Signal onMessageForSignal;
var protected ChatAPI_OnVoiceMessage_Signal onVoiceMessageSignal;
protected function Constructor() {
onMessageSignal = ChatAPI_OnMessage_Signal(_.memory.Allocate(class'ChatAPI_OnMessage_Signal'));
onMessageForSignal =
ChatAPI_OnMessageFor_Signal(_.memory.Allocate(class'ChatAPI_OnMessageFor_Signal'));
onVoiceMessageSignal =
ChatAPI_OnVoiceMessage_Signal(_.memory.Allocate(class'ChatAPI_OnVoiceMessage_Signal'));
}
protected function Finalizer()
{
protected function Finalizer() {
_.memory.Free(onMessageSignal);
_.memory.Free(onMessageForSignal);
onMessageSignal = none;
onMessageForSignal = none;
_.memory.Free(onVoiceMessageSignal);
onMessageSignal = none;
onMessageForSignal = none;
onVoiceMessageSignal = none;
_server.unreal.broadcasts.OnHandleText(self).Disconnect();
_server.unreal.broadcasts.OnHandleTextFor(self).Disconnect();
connectedToBroadcastAPI = false;
}
private final function TryConnectingBroadcastSignals()
{
if (connectedToBroadcastAPI) {
return;
}
connectedToBroadcastAPI = true;
_server.unreal.broadcasts.OnHandleText(self).connect = HandleText;
_server.unreal.broadcasts.OnHandleTextFor(self).connect = HandleTextFor;
}
/**
* Signal that will be emitted when a player sends a message into the chat.
* Allows to modify message before sending it, as well as prevent it from
* being sent at all.
*
* Return `false` to prevent message from being sent.
* If `false` is returned, signal propagation to the remaining handlers will
* also be interrupted.
*
* [Signature]
* bool <slot>(EPlayer sender, MutableText message, bool teamMessage)
*
* @param sender `EPlayer` that has sent the message.
* @param message Message that `sender` has sent. This is a mutable
* variable and can be modified from message will be sent.
* @param teamMessage Is this a team message
* (to be sent only to players on the same team)?
* @return Return `false` to prevent this message from being sent at all
* and `true` otherwise. Message will be sent only if all handlers will
* return `true`.
*/
/* SIGNAL */
public function ChatAPI_OnMessage_Slot OnMessage(
AcediaObject receiver)
{
/// Signal that will be emitted when a player sends a message into the chat.
///
/// Allows to modify message before sending it, as well as prevent it from being sent at all.
///
/// Return `false` to prevent message from being sent.
/// If `false` is returned, signal propagation to the remaining handlers will also be interrupted.
///
/// # Slot description
///
/// bool <slot>(EPlayer sender, MutableText message, bool teamMessage)
///
/// ## Parameters
///
/// * [`sender`]: `EPlayer` that has sent the message.
/// * [`message`]: Message that `sender` has sent.
/// This is a mutable variable and can be modified from message will be sent.
/// * [`teamMessage`]: Is this a team message (to be sent only to players on the same team)?
///
/// ## Returns
///
/// Return `false` to prevent this message from being sent at all and `true` otherwise.
/// Message will be sent only if all handlers will return `true`.
public /*signal*/ function ChatAPI_OnMessage_Slot OnMessage(AcediaObject receiver) {
TryConnectingBroadcastSignals();
return ChatAPI_OnMessage_Slot(onMessageSignal.NewSlot(receiver));
}
/**
* Signal that will be emitted when a player sends a message into the chat.
* Allows to modify message before sending it, as well as prevent it from
* being sent at all.
*
* Return `false` to prevent message from being sent to a specific player.
* If `false` is returned, signal propagation to the remaining handlers will
* also be interrupted.
*
* [Signature]
* bool <slot>(EPlayer receiver, EPlayer sender, BaseText message)
*
* @param receiver `EPlayer` that will receive the message.
* @param sender `EPlayer` that has sent the message.
* @param message Message that `sender` has sent. This is an immutable
* variable and cannot be changed at this point. Use `OnMessage()`
* signal function for that.
* @return Return `false` to prevent this message from being sent to
* a particular player and `true` otherwise. Message will be sent only if
* all handlers will return `true`.
* However decision whether to send message or not is made for
* every player separately.
*/
/* SIGNAL */
public function ChatAPI_OnMessageFor_Slot OnMessageFor(
AcediaObject receiver)
{
/// Signal that will be emitted when a player sends a message into the chat.
///
/// Allows to modify message before sending it, as well as prevent it from being sent at all.
///
/// Return `false` to prevent message from being sent to a specific player.
/// If `false` is returned, signal propagation to the remaining handlers will also be interrupted.
///
/// # Slot description
///
/// bool <slot>(EPlayer receiver, EPlayer sender, BaseText message)
///
/// ## Parameters
///
/// * [`receiver`]: `EPlayer` that will receive the message.
/// * [`sender`]: `EPlayer` that has sent the message.
/// * [`message`]: Message that `sender` has sent. This is an immutable variable and cannot
/// be changed at this point. Use `OnMessage()` signal function for that.
///
/// ## Returns
///
/// Return `false` to prevent this message from being sent to a particular player and
/// `true` otherwise.
/// Message will be sent only if all handlers will return `true`.
/// However decision whether to send message or not is made for every player separately.
public /*signal*/ function ChatAPI_OnMessageFor_Slot OnMessageFor(AcediaObject receiver) {
TryConnectingBroadcastSignals();
return ChatAPI_OnMessageFor_Slot(onMessageForSignal.NewSlot(receiver));
}
/// Signal that will be emitted when a player sends a voice message.
///
/// # Slot description
///
/// void <slot>(EPlayer sender, ChatApi.BuiltInVoiceMessage message)
///
/// ## Parameters
///
/// * [`sender`]: `EPlayer` that has sent the voice message.
/// * [`message`]: Message that `sender` has sent.
public /*signal*/ function ChatAPI_OnVoiceMessage_Slot OnVoiceMessage(AcediaObject receiver) {
if (!replacedSendVoiceMessage) {
_.unflect.ReplaceFunction_S(
"KFMod.KFPlayerController.SendVoiceMessage",
"AcediaCore.Unflect_ChatApi_Controller.SendVoiceMessage",
"`ChatApi` was required to catch voice messages");
replacedSendVoiceMessage = true;
}
return ChatAPI_OnVoiceMessage_Slot(onVoiceMessageSignal.NewSlot(receiver));
}
public /*internal*/ function NativeVoiceMessage _enumIntoNativeVoiceMessage(
BuiltInVoiceMessage voiceMessage
) {
local int enumValue;
local NativeVoiceMessage result;
enumValue = int(voiceMessage);
if (enumValue < VOICE_MESSAGES_BEFORE_ACKNOWLEDGEMENTS) {
result.type = 'SUPPORT';
result.index = enumValue;
} else if (enumValue < VOICE_MESSAGES_BEFORE_ALERTS) {
result.type = 'ACK';
result.index = enumValue - VOICE_MESSAGES_BEFORE_ACKNOWLEDGEMENTS;
} else if (enumValue < VOICE_MESSAGES_BEFORE_DIRECTIONS) {
result.type = 'ALERT';
result.index = enumValue - VOICE_MESSAGES_BEFORE_ALERTS;
} else if (enumValue < VOICE_MESSAGES_BEFORE_INSULTS) {
result.type = 'DIRECTION';
result.index = enumValue - VOICE_MESSAGES_BEFORE_DIRECTIONS;
} else if (enumValue < VOICE_MESSAGES_BEFORE_TRADER) {
result.type = 'INSULT';
result.index = enumValue - VOICE_MESSAGES_BEFORE_INSULTS;
} else if (enumValue < VOICE_MESSAGES_BEFORE_AUTO) {
result.type = 'TRADER';
result.index = enumValue - VOICE_MESSAGES_BEFORE_TRADER;
if (result.index >= 5) {
result.index += 1;
}
} else if (enumValue < VOICE_MESSAGES_TOTAL) {
result.type = 'AUTO';
result.index = enumValue - VOICE_MESSAGES_BEFORE_AUTO;
}
return result;
}
public /*internal*/ function BuiltInVoiceMessage _nativeVoiceMessageIntoEnum(
NativeVoiceMessage voiceMessage
) {
switch (voiceMessage.type) {
case 'SUPPORT':
if (voiceMessage.index < 4) {
return BuiltInVoiceMessage(voiceMessage.index);
}
break;
case 'ACK':
if (voiceMessage.index < 4) {
return BuiltInVoiceMessage(
VOICE_MESSAGES_BEFORE_ACKNOWLEDGEMENTS + voiceMessage.index);
}
break;
case 'ALERT':
if (voiceMessage.index < 6) {
return BuiltInVoiceMessage(VOICE_MESSAGES_BEFORE_ALERTS + voiceMessage.index);
}
break;
case 'DIRECTION':
if (voiceMessage.index < 5) {
return BuiltInVoiceMessage(VOICE_MESSAGES_BEFORE_DIRECTIONS + voiceMessage.index);
}
break;
case 'INSULT':
if (voiceMessage.index < 2) {
return BuiltInVoiceMessage(VOICE_MESSAGES_BEFORE_INSULTS + voiceMessage.index);
}
break;
case 'TRADER':
if (voiceMessage.index < 12) {
if (voiceMessage.index < 5) {
return BuiltInVoiceMessage(VOICE_MESSAGES_BEFORE_TRADER + voiceMessage.index);
} else if (voiceMessage.index > 5) {
return BuiltInVoiceMessage(
VOICE_MESSAGES_BEFORE_TRADER + voiceMessage.index - 1);
}
}
break;
case 'AUTO':
if (voiceMessage.index < 25) {
return BuiltInVoiceMessage(VOICE_MESSAGES_BEFORE_AUTO + voiceMessage.index);
}
break;
default:
return BIVM_Unknown;
}
return BIVM_Unknown;
}
public final /*internal*/ /*native*/ function _EmitOnVoiceMessage(
PlayerController sender,
name messageType,
byte messageID
) {
local EPlayer wrapper;
local NativeVoiceMessage nativeVoiceMessage;
local BuiltInVoiceMessage builtInVoiceMessage;
if (sender == none) return;
wrapper = _.players.FromController(sender);
if (wrapper == none) return;
nativeVoiceMessage.type = messageType;
nativeVoiceMessage.index = messageID;
builtInVoiceMessage = _nativeVoiceMessageIntoEnum(nativeVoiceMessage);
if (builtInVoiceMessage != BIVM_Unknown) {
onVoiceMessageSignal.Emit(wrapper, builtInVoiceMessage);
}
_.memory.Free(wrapper);
}
private final function TryConnectingBroadcastSignals() {
if (connectedToBroadcastAPI) {
return;
}
connectedToBroadcastAPI = true;
_server.unreal.broadcasts.OnHandleText(self).connect = HandleText;
_server.unreal.broadcasts.OnHandleTextFor(self).connect = HandleTextFor;
}
private function bool HandleText(
Actor sender,
out string message,
name messageType,
bool teamMessage)
{
local bool result;
local MutableText messageAsText;
local EPlayer senderPlayer;
// We only want to catch chat messages from a player
Actor sender,
out string message,
name messageType,
bool teamMessage
) {
local bool result;
local MutableText messageAsText;
local EPlayer senderPlayer;
// We only want to catch chat messages from a player
if (messageType != 'Say' && messageType != 'TeamSay') return true;
senderPlayer = _.players.FromController(PlayerController(sender));
if (senderPlayer == none) return true;
@ -145,34 +377,39 @@ private function bool HandleText(
}
private function bool HandleTextFor(
PlayerController receiver,
Actor sender,
out string message,
name messageType)
{
local bool result;
local Text messageAsText;
local EPlayer senderPlayer, receiverPlayer;
// We only want to catch chat messages from another player
PlayerController receiver,
Actor sender,
out string message,
name messageType
) {
local bool result;
local Text messageAsText;
local EPlayer senderPlayer, receiverPlayer;
// We only want to catch chat messages from another player
if (messageType != 'Say' && messageType != 'TeamSay') return true;
senderPlayer = _.players.FromController(PlayerController(sender));
if (senderPlayer == none) return true;
receiverPlayer = _.players.FromController(receiver);
if (receiverPlayer == none)
{
if (receiverPlayer == none) {
_.memory.Free(senderPlayer);
return true;
}
messageAsText = __().text.FromColoredString(message);
result = onMessageForSignal.Emit( receiverPlayer, senderPlayer,
messageAsText);
result = onMessageForSignal.Emit(receiverPlayer, senderPlayer, messageAsText);
_.memory.Free(messageAsText);
_.memory.Free(senderPlayer);
_.memory.Free(receiverPlayer);
return result;
}
defaultproperties
{
defaultproperties {
VOICE_MESSAGES_BEFORE_ACKNOWLEDGEMENTS = 4
VOICE_MESSAGES_BEFORE_ALERTS = 8
VOICE_MESSAGES_BEFORE_DIRECTIONS = 14
VOICE_MESSAGES_BEFORE_INSULTS = 19
VOICE_MESSAGES_BEFORE_TRADER = 21
VOICE_MESSAGES_BEFORE_AUTO = 32
VOICE_MESSAGES_TOTAL = 57
}

39
sources/Chat/Events/ChatAPI_OnVoiceMessage_Signal.uc

@ -0,0 +1,39 @@
/**
* Author: dkanus
* Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
* License: GPL
* Copyright 2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
* Acedia is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License, or
* (at your option) any later version.
*
* Acedia is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class ChatAPI_OnVoiceMessage_Signal extends Signal
dependsOn(ChatApi);
public final function Emit(EPlayer sender, ChatApi.BuiltInVoiceMessage message) {
local Slot nextSlot;
StartIterating();
nextSlot = GetNextSlot();
while (nextSlot != none) {
ChatAPI_OnVoiceMessage_Slot(nextSlot).connect(sender, message);
nextSlot = GetNextSlot();
}
CleanEmptySlots();
}
defaultproperties {
relatedSlotClass = class'ChatAPI_OnVoiceMessage_Slot'
}

41
sources/Chat/Events/ChatAPI_OnVoiceMessage_Slot.uc

@ -0,0 +1,41 @@
/**
* 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 ChatAPI_OnVoiceMessage_Slot extends Slot;
delegate connect(
EPlayer sender,
ChatApi.BuiltInVoiceMessage message
) {
DummyCall();
}
protected function Constructor() {
connect = none;
}
protected function Finalizer() {
super.Finalizer();
connect = none;
}
defaultproperties {
}

81
sources/Chat/Tests/TEST_VoiceMessages.uc

@ -0,0 +1,81 @@
/**
* Set of tests for `Command` class.
* 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_VoiceMessages extends TestCase
abstract
dependsOn(ChatApi);
private static function ChatApi.NativeVoiceMessage Make(name type, int index) {
local ChatApi.NativeVoiceMessage result;
result.type = type;
result.index = index;
return result;
}
private static function TestEquality(name type, int index, ChatApi.BuiltInVoiceMessage builtIn) {
local ChatApi.NativeVoiceMessage nativeMessage;
nativeMessage.type = type;
nativeMessage.index = index;
Issue("Native voice messages are incorrectly converted into `BuiltInVoiceMessage`.");
TEST_ExpectTrue(__().chat._nativeVoiceMessageIntoEnum(nativeMessage) == builtIn);
Issue("`BuiltInVoiceMessage`s are incorrectly converted into native voice messages.");
TEST_ExpectTrue(__().chat._enumIntoNativeVoiceMessage(builtIn).type == type);
TEST_ExpectTrue(__().chat._enumIntoNativeVoiceMessage(builtIn).index == index);
}
protected static function TESTS() {
Context("Testing internal conversion methods between voice messages.");
Test_VoiceMessageConversion();
}
protected static function Test_VoiceMessageConversion() {
local ChatApi.NativeVoiceMessage nothingMessage;
nothingMessage.type = 'TRADER';
nothingMessage.index = 5;
TestEquality('SUPPORT', 2, BIVM_SupportAskForMoney);
TestEquality('ACK', 0, BIVM_AckYes);
TestEquality('ALERT', 5, BIVM_AlretFollowMe);
TestEquality('DIRECTION', 2, BIVM_DirectionGoDownstairs);
TestEquality('INSULT', 1, BIVM_InsultPlayers);
TestEquality('TRADER', 2, BIVM_TraderShopOpen);
TestEquality('TRADER', 4, BIVM_Trader30SecondsUntilShopCloses);
TestEquality('TRADER', 6, BIVM_TraderShopClosed);
TestEquality('TRADER', 7, BIVM_TraderCompliment);
TestEquality('TRADER', 11, BIVM_TraderHurryUp2);
TestEquality('AUTO', 0, BIVM_AutoWelding);
TestEquality('AUTO', 16, BIVM_AutoSirenScream);
TestEquality('AUTO', 24, BIVM_AutoEquipFireaxe);
// Test unknown separately
Issue("Native voice messages are incorrectly converted into `BuiltInVoiceMessage`.");
TEST_ExpectTrue(__().chat._nativeVoiceMessageIntoEnum(nothingMessage) == BIVM_Unknown);
Issue("`BuiltInVoiceMessage`s are incorrectly converted into native voice messages.");
TEST_ExpectTrue(__().chat._enumIntoNativeVoiceMessage(BIVM_Unknown).type == '');
TEST_ExpectTrue(__().chat._enumIntoNativeVoiceMessage(BIVM_Unknown).index == 0);
}
defaultproperties {
caseName = "Voice messages"
caseGroup = "ChatAPI"
}

59
sources/Chat/Unflect/Unflect_ChatApi_Controller.uc

@ -0,0 +1,59 @@
/**
* 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 Unflect_ChatApi_Controller extends KFPlayerController
dependsOn(ChatAPI);
function SendVoiceMessage(
PlayerReplicationInfo sender,
PlayerReplicationInfo statedRecipient,
name messageType,
byte messageID,
name broadcastType,
optional Pawn soundSender,
optional vector senderLocation
) {
local Controller recepient;
local KFPlayerController kfPlayerRecepient;
// Don't allow dead people to talk
if (pawn == none) return;
if (!AllowVoiceMessage(messageType)) return;
class'Global'.static.GetInstance().chat._EmitOnVoiceMessage(self, messageType, messageID);
recepient = level.controllerList;
while (recepient != none) {
kfPlayerRecepient = KFPlayerController(recepient);
if (kfPlayerRecepient != none) {
kfPlayerRecepient.ClientLocationalVoiceMessage(
sender,
statedRecipient,
messagetype,
messageID,
soundSender,
senderLocation);
}
recepient = recepient.nextController;
}
}
defaultproperties {
}

1
sources/ClientAPI/ClientAcediaAdapter.uc

@ -27,7 +27,6 @@ var public const class<InteractionAPI> clientInteractionAPIClass;
defaultproperties
{
sideEffectAPIClass = class'KF1_SideEffectAPI'
timeAPIClass = class'KF1_TimeAPI'
dbAPIClass = class'DBAPI'
clientUnrealAPIClass = class'KF1_ClientUnrealAPI'

4
sources/Color/ColorAPI.uc

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

24
sources/Data/Collections/Collection.uc

@ -97,7 +97,7 @@ public function Empty() {}
/**
* Returns stored `AcediaObject` from the caller storage
* (or from it's sub-storages) via given `JSONPointer` path.
* (or from it's sub-storages) via given `BaseJSONPointer` path.
*
* Acedia provides two collections:
* 1. `ArrayList` is treated as a JSON array in the context of
@ -119,7 +119,7 @@ public function Empty() {}
* @return An item `jsonPointer` is referring to (according to the above
* stated rules). `none` if such item does not exist.
*/
public final function AcediaObject GetItemByJSON(JSONPointer jsonPointer)
public final function AcediaObject GetItemByJSON(BaseJSONPointer jsonPointer)
{
local int segmentIndex;
local Text nextSegment;
@ -648,7 +648,7 @@ public final function ArrayList GetArrayListBy(BaseText jsonPointerAsText)
* is missing or has a different type.
*/
public final function bool GetBoolByJSON(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
optional bool defaultValue)
{
local bool result;
@ -689,7 +689,7 @@ public final function bool GetBoolByJSON(
* is missing or has a different type.
*/
public final function byte GetByteByJSON(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
optional byte defaultValue)
{
local byte result;
@ -734,7 +734,7 @@ public final function byte GetByteByJSON(
* is missing or has a different type.
*/
public final function int GetIntByJSON(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
optional int defaultValue)
{
local int result;
@ -789,7 +789,7 @@ public final function int GetIntByJSON(
* is missing or has a different type.
*/
public final function float GetFloatByJSON(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
optional float defaultValue)
{
local float result;
@ -840,7 +840,7 @@ public final function float GetFloatByJSON(
* is missing or has a different type.
*/
public final function Vector GetVectorByJSON(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
optional Vector defaultValue)
{
local Vector result;
@ -881,7 +881,7 @@ public final function Vector GetVectorByJSON(
* if it is missing or has a different type.
*/
public final function string GetStringByJSON(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
optional string defaultValue)
{
local AcediaObject result;
@ -915,7 +915,7 @@ public final function string GetStringByJSON(
* `defaultValue` if it is missing or has a different type.
*/
public final function string GetFormattedStringByJSON(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
optional string defaultValue)
{
local AcediaObject result;
@ -972,7 +972,7 @@ public final function Text GetTextByJSON(JSONPointer jsonPointer)
* `none` if it is missing or has a different type.
*/
public final function Collection GetCollectionByJSON(
JSONPointer jsonPointer)
BaseJSONPointer jsonPointer)
{
local AcediaObject result;
local Collection asCollection;
@ -999,7 +999,7 @@ public final function Collection GetCollectionByJSON(
* `none` if it is missing or has a different type.
*/
public final function HashTable GetHashTableByJSON(
JSONPointer jsonPointer)
BaseJSONPointer jsonPointer)
{
local AcediaObject result;
local HashTable asHashTable;
@ -1026,7 +1026,7 @@ public final function HashTable GetHashTableByJSON(
* `none` if it is missing or has a different type.
*/
public final function ArrayList GetArrayListByJSON(
JSONPointer jsonPointer)
BaseJSONPointer jsonPointer)
{
local AcediaObject result;
local ArrayList asArrayList;

65
sources/Data/Database/Connection/DBCache.uc

@ -32,8 +32,8 @@ class DBCache extends AcediaObject;
*
* ## Usage
*
* You can simply read and write JSON data with `Read(JSONPointer)` and
* `Write(JSONPointer, AcediaObject)` right after `DBCache`'s creation.
* You can simply read and write JSON data with `Read(BaseJSONPointer)` and
* `Write(BaseJSONPointer, AcediaObject)` right after `DBCache`'s creation.
* Once real database's data has arrived, you can set it with `SetRealData()`.
* Data recorded before the `SetRealData()` call is an *approximation* and
* might not function as a real JSON value/database. Because `DBCache` doesn't
@ -47,7 +47,7 @@ class DBCache extends AcediaObject;
*
* ```unrealscript
* local DBCache cache;
* local JSONPointer dataLocation,;
* local JSONPointer dataLocation;
* local HashTable emptyObject;
*
* cache = DBCache(_.memory.Allocate(class'DBCache'));
@ -214,7 +214,7 @@ protected function Finalizer()
* @return Data stored at location given by `location`, `none` if nothing is
* stored there.
*/
public final function AcediaObject Read(JSONPointer location)
public final function AcediaObject Read(BaseJSONPointer location)
{
local Collection cachedCollection;
@ -262,7 +262,7 @@ public final function AcediaObject Read(JSONPointer location)
* but then rejected after the real data is set if they're incompatible
* with its structure (@see `SetRealData()` for more information).
*/
public final function bool Write(JSONPointer location, AcediaObject data)
public final function bool Write(BaseJSONPointer location, AcediaObject data)
{
local Collection cachedCollection;
@ -317,7 +317,7 @@ public final function bool Write(JSONPointer location, AcediaObject data)
* but then rejected after the real data is set if they're incompatible
* with its structure (@see `SetRealData()` for more information).
*/
public final function bool Remove(JSONPointer location)
public final function bool Remove(BaseJSONPointer location)
{
local Collection cachedCollection;
@ -377,7 +377,7 @@ public final function bool Remove(JSONPointer location)
* but then rejected after the real data is set if they're incompatible
* with its structure (@see `SetRealData()` for more information).
*/
public final function bool Increment(JSONPointer location, AcediaObject data)
public final function bool Increment(BaseJSONPointer location, AcediaObject data)
{
local AcediaObject incrementedRoot;
local Collection cachedCollection;
@ -405,14 +405,7 @@ public final function bool Increment(JSONPointer location, AcediaObject data)
}
return false;
}
/*INC 2
Cache inc 1 {} /test True
Cache inc 2
Cache inc 3
Cache inc 4
INC 5
WriteDataByJSON #1
WriteDataByJSON #2 */
/**
* Checks whether `SetRealData()` was called.
*
@ -494,7 +487,7 @@ public final function array<PendingEdit> SetRealData(AcediaObject realData)
}
// For reading data when before the `SetRealData()` call.
private final function AcediaObject ReadPending(JSONPointer location)
private final function AcediaObject ReadPending(BaseJSONPointer location)
{
local int nextEditIndex;
local int newestOverridingEdit;
@ -534,8 +527,8 @@ private final function AcediaObject ReadPending(JSONPointer location)
// Assumes `location` is not `none`.
// Assumes `pendingEdits[editIndex].location` is prefix of `location`.
private final function AcediaObject ReconstructFromEdit(
JSONPointer location,
int editIndex)
BaseJSONPointer location,
int editIndex)
{
local int startIndex;
local AcediaObject result;
@ -584,12 +577,12 @@ private final function AcediaObject ReconstructFromEdit(
// Assumes `locationToCorrect` in not `none`.
private final function ApplyCorrectingWrites(
out AcediaObject target,
JSONPointer locationToCorrect,
BaseJSONPointer locationToCorrect,
int startIndex)
{
local int i;
local Collection targetAsCollection;
local JSONPointer subLocation, nextLocation;;
local JSONPointer subLocation, nextLocation;
if (target == none) {
return;
@ -660,17 +653,17 @@ private final function bool EditJSONSimpleValue(
// Assumes that `target` isn't `none`.
private final function bool EditJSONCollection(
Collection target,
JSONPointer location,
BaseJSONPointer location,
AcediaObject value,
DBCacheEditType editType)
{
local bool success;
local Text key;
local ArrayList arrayCollection;
local HashTable objectCollection;
local Collection innerCollection;
local JSONPointer poppedLocation;
local AcediaObject valueCopy;
local bool success;
local Text key;
local ArrayList arrayCollection;
local HashTable objectCollection;
local Collection innerCollection;
local MutableJSONPointer poppedLocation;
local AcediaObject valueCopy;
// Empty pointer is only allowed if we're incrementing;
if (location.IsEmpty())
@ -682,7 +675,7 @@ private final function bool EditJSONCollection(
// (which is data pointed by `location` without the last segment).
// Last segment will serve as a key in that `Collection`, so also
// keep it.
poppedLocation = location.Copy();
poppedLocation = location.MutableCopy();
key = poppedLocation.Pop();
innerCollection = target.GetCollectionByJSON(poppedLocation);
// Then, depending on the collection, get the actual data
@ -696,7 +689,7 @@ private final function bool EditJSONCollection(
key,
value,
editType,
location.PopNumeric(true));
location.PeekNumeric());
}
if (objectCollection != none) {
success = EditHashTable(objectCollection, key, value, editType);
@ -887,7 +880,7 @@ private final function bool IncrementCollection(
// For writing data when before the `SetRealData()` call.
// Assumes `location` isn't `none`.
private final function bool AddPendingEdit(
JSONPointer location,
BaseJSONPointer location,
AcediaObject data,
DBCacheEditType type)
{
@ -983,7 +976,7 @@ private final function bool AddPendingEdit(
// Checks if `pointer`'s last component is "-", which denotes appending
// new item to the JSON array.
// Assumes `pointer != none`.
private final function bool IsPointerAppendingToArray(JSONPointer pointer)
private final function bool IsPointerAppendingToArray(BaseJSONPointer pointer)
{
local int lastComponentIndex;
@ -998,9 +991,9 @@ private final function bool IsPointerAppendingToArray(JSONPointer pointer)
// To avoid unnecessary copying the key is specified as a component of
// JSON pointer `path` with index `keyIndex`.
private final function bool IsKeyAcceptable(
Collection target,
JSONPointer path,
int keyIndex)
Collection target,
BaseJSONPointer path,
int keyIndex)
{
local ArrayList arrayCollection;
@ -1022,7 +1015,7 @@ private final function bool IsKeyAcceptable(
// `from` will contain index `2` of the component "c".
private final function AcediaObject ApplyPointer(
AcediaObject data,
JSONPointer pointer,
BaseJSONPointer pointer,
out int from,
int to)
{

33
sources/Data/Database/Connection/DBConnection.uc

@ -277,8 +277,8 @@ protected function Finalizer()
* @return `true` if initialization was successful and `false` otherwise.
*/
public final function bool Initialize(
Database initDatabase,
optional JSONPointer initRootPointer)
Database initDatabase,
optional BaseJSONPointer initRootPointer)
{
if (IsInitialized()) return false;
if (initDatabase == none) return false;
@ -310,7 +310,7 @@ public final function bool Initialize(
* @param pointer Location from which to read the data.
* @return Data recorded for the given `JSONPointer`. `none` if it is missing.
*/
public final function AcediaObject ReadDataByJSON(JSONPointer pointer)
public final function AcediaObject ReadDataByJSON(BaseJSONPointer pointer)
{
return localCache.Read(pointer);
}
@ -342,7 +342,7 @@ public final function AcediaObject ReadDataByJSON(JSONPointer pointer)
* the writing database request to be made.
*/
public final function bool WriteDataByJSON(
JSONPointer pointer,
BaseJSONPointer pointer,
AcediaObject data)
{
if (pointer == none) {
@ -383,7 +383,7 @@ public final function bool WriteDataByJSON(
* the incrementing database request to be made.
*/
public final function bool IncrementDataByJSON(
JSONPointer pointer,
BaseJSONPointer pointer,
AcediaObject data)
{
if (pointer == none) {
@ -420,7 +420,7 @@ public final function bool IncrementDataByJSON(
* @return `true` on success and `false` on failure. `true` is required for
* the removal database request to be made.
*/
public final function bool RemoveDataByJSON(JSONPointer pointer)
public final function bool RemoveDataByJSON(BaseJSONPointer pointer)
{
if (pointer == none) {
return false;
@ -434,16 +434,16 @@ public final function bool RemoveDataByJSON(JSONPointer pointer)
}
private final function ModifyDataInDatabase(
JSONPointer pointer,
BaseJSONPointer pointer,
AcediaObject data,
bool increment)
{
local JSONPointer dataPointer;
local MutableJSONPointer dataPointer;
if (currentState != DBCS_Connected) {
return;
}
dataPointer = rootPointer.Copy();
dataPointer = rootPointer.MutableCopy();
dataPointer.Append(pointer);
// `dataPointer` is consumed by `RegisterNextRequestID()` method
if (increment)
@ -452,30 +452,33 @@ private final function ModifyDataInDatabase(
.IncrementData(
dataPointer,
data,
RegisterNextRequestID(dataPointer))
RegisterNextRequestID(/*take*/ dataPointer.Copy()))
.connect = EditDataHandler;
_.memory.Free(dataPointer);
}
else
{
dbInstance
.WriteData(dataPointer, data, RegisterNextRequestID(dataPointer))
.WriteData(dataPointer, data, RegisterNextRequestID(/*take*/ dataPointer.Copy()))
.connect = EditDataHandler;
_.memory.Free(dataPointer);
}
}
private final function RemoveDataInDatabase(JSONPointer pointer)
private final function RemoveDataInDatabase(BaseJSONPointer pointer)
{
local JSONPointer dataPointer;
local MutableJSONPointer dataPointer;
if (currentState != DBCS_Connected) {
return;
}
dataPointer = rootPointer.Copy();
dataPointer = rootPointer.MutableCopy();
dataPointer.Append(pointer);
// `dataPointer` is consumed by `RegisterNextRequestID()` method
dbInstance
.RemoveData(dataPointer, RegisterNextRequestID(dataPointer))
.RemoveData(dataPointer, RegisterNextRequestID(/*take*/ dataPointer.Copy()))
.connect = EditDataHandler;
_.memory.Free(dataPointer);
}
/**

60
sources/Data/Database/DBAPI.uc

@ -76,6 +76,41 @@ public final function Database Load(BaseText databaseLink)
return result;
}
/**
* Extracts `MutableJSONPointer` from the database path, given by `databaseLink`.
*
* Links have the form of "<db_name>:" (or, optionally, "[<type>]<db_name>:"),
* followed by the JSON pointer (possibly empty one) to the object inside it.
* "<type>" can be either "local" or "remote" and is necessary only when both
* local and remote database have the same name (which should be avoided).
* "<db_name>" refers to the database that we are expected
* to load, it has to consist of numbers and latin letters only.
* This method returns `MutableJSONPointer` that comes after type-name pair.
*
* @param Link from which to extract `MutableJSONPointer`.
* @return `MutableJSONPointer` from the database link.
* Guaranteed to not be `none` if provided argument `databaseLink`
* is not `none`.
*/
public final function MutableJsonPointer GetMutablePointer(BaseText databaseLink)
{
local int slashIndex;
local Text textPointer;
local MutableJsonPointer result;
if (databaseLink == none) {
return none;
}
slashIndex = databaseLink.IndexOf(P(":"));
if (slashIndex < 0) {
return MutableJsonPointer(_.memory.Allocate(class'MutableJsonPointer'));
}
textPointer = databaseLink.Copy(slashIndex + 1);
result = _.json.MutablePointer(textPointer);
textPointer.FreeSelf();
return result;
}
/**
* Extracts `JSONPointer` from the database path, given by `databaseLink`.
*
@ -215,48 +250,49 @@ public final function LocalDatabaseInstance LoadLocal(BaseText databaseName)
local DBRecord rootRecord;
local Text rootRecordName;
local LocalDatabase newConfig;
local LocalDatabaseInstance newLocalDBInstance;
local LocalDatabaseInstance newLocalDBInstance, result;
local Text dbKey;
if (databaseName == none) {
return none;
}
CreateLocalDBMapIfMissing();
if (loadedLocalDatabases.HasKey(databaseName))
dbKey = databaseName.Copy();
if (loadedLocalDatabases.HasKey(dbKey))
{
return LocalDatabaseInstance(loadedLocalDatabases
.GetItem(databaseName));
result = LocalDatabaseInstance(loadedLocalDatabases.GetItem(dbKey));
_.memory.Free(dbKey);
return result;
}
// No need to check `databaseName` for being valid,
// No need to check `dbKey` for being valid,
// since `Load()` will just return `none` if it is not.
newConfig = class'LocalDatabase'.static.Load(databaseName);
newConfig = class'LocalDatabase'.static.Load(dbKey);
if (newConfig == none) {
_.memory.Free(dbKey);
return none;
}
if (!newConfig.HasDefinedRoot() && !newConfig.ShouldCreateIfMissing()) {
_.memory.Free(dbKey);
return none;
}
newLocalDBInstance = LocalDatabaseInstance(_.memory.Allocate(localDBClass));
dbKey = databaseName.Copy();
loadedLocalDatabases.SetItem(dbKey, newLocalDBInstance);
dbKey.FreeSelf();
if (newConfig.HasDefinedRoot())
{
rootRecordName = newConfig.GetRootName();
rootRecord = class'DBRecord'.static
.LoadRecord(rootRecordName, databaseName);
rootRecord = class'DBRecord'.static.LoadRecord(rootRecordName, dbKey);
}
else
{
rootRecord = class'DBRecord'.static.NewRecord(databaseName);
rootRecord = class'DBRecord'.static.NewRecord(dbKey);
rootRecordName = _.text.FromString(string(rootRecord.name));
newConfig.SetRootName(rootRecordName);
newConfig.Save();
}
newLocalDBInstance.Initialize(newConfig, rootRecord);
_.logger.Auto(infoLocalDatabaseLoaded).Arg(databaseName.Copy());
_.logger.Auto(infoLocalDatabaseLoaded).Arg(dbKey);
_.memory.Free(rootRecordName);
_.memory.Free(newLocalDBInstance);
return newLocalDBInstance;
}

14
sources/Data/Database/Database.uc

@ -101,7 +101,7 @@ enum DBQueryResult
* it does not point at any existing value inside the caller database.
*/
public function DBReadTask ReadData(
JSONPointer pointer,
BaseJSONPointer pointer,
optional bool makeMutable,
optional int requestID)
{
@ -148,7 +148,7 @@ public function DBReadTask ReadData(
* "sub-object" does not exist.
*/
public function DBWriteTask WriteData(
JSONPointer pointer,
BaseJSONPointer pointer,
AcediaObject data,
optional int requestID)
{
@ -183,7 +183,7 @@ public function DBWriteTask WriteData(
* it does not point at any existing value inside the caller database.
*/
public function DBRemoveTask RemoveData(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
return none;
@ -224,7 +224,7 @@ public function DBRemoveTask RemoveData(
* `result == DBR_Success`.
*/
public function DBCheckTask CheckDataType(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
return none;
@ -268,7 +268,7 @@ public function DBCheckTask CheckDataType(
* caller database.
*/
public function DBSizeTask GetDataSize(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
return none;
@ -314,7 +314,7 @@ public function DBSizeTask GetDataSize(
* (value can either not exist at all or have some other type).
*/
public function DBKeysTask GetDataKeys(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
return none;
@ -378,7 +378,7 @@ public function DBKeysTask GetDataKeys(
* operation) with `increment` parameter.
*/
public function DBIncrementTask IncrementData(
JSONPointer pointer,
BaseJSONPointer pointer,
AcediaObject increment,
optional int requestID)
{

37
sources/Data/Database/Local/DBRecord.uc

@ -185,7 +185,7 @@ private final function DBRecordPointer MakeRecordPointer(
}
// Converts `JSONPointer` into our internal representation.
private final function DBRecordPointer ConvertPointer(JSONPointer jsonPointer)
private final function DBRecordPointer ConvertPointer(BaseJSONPointer jsonPointer)
{
if (jsonPointer == none) {
return MakeRecordPointer(none);
@ -195,8 +195,7 @@ private final function DBRecordPointer ConvertPointer(JSONPointer jsonPointer)
// Produced out internal pointer representation `DBRecordPointer` to
// the container that stores object, referred to by a given `JSONPointer`.
private final function DBRecordPointer ConvertContainerPointer(
JSONPointer jsonPointer)
private final function DBRecordPointer ConvertContainerPointer(BaseJSONPointer jsonPointer)
{
local DBRecordPointer pointer;
if (jsonPointer == none) {
@ -212,9 +211,9 @@ private final function DBRecordPointer ConvertContainerPointer(
// Converts `JSONPointer` into internal `DBRecordPointer`.
// Only uses sub-pointer: components from `startIndex` to `endIndex`.
private final function DBRecordPointer ConvertPointerPath(
JSONPointer pointer,
int startIndex,
int endIndex)
BaseJSONPointer pointer,
int startIndex,
int endIndex)
{
local int index;
local StorageItem nextElement;
@ -371,7 +370,7 @@ private final static function string GetRandomLetter()
* (either does not point at any existing value or is equal to `none`).
*/
public final function bool LoadObject(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
out AcediaObject result,
bool makeMutable)
{
@ -409,7 +408,7 @@ public final function bool LoadObject(
* (either missing some necessary segments or is equal to `none`).
*/
public final function bool SaveObject(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
AcediaObject newItem)
{
local int index;
@ -437,10 +436,10 @@ public final function bool SaveObject(
return false;
}
directContainer = pointer.record;
itemKey = __().text.IntoString(jsonPointer.Pop(true));
itemKey = __().text.IntoString(jsonPointer.Peek());
if (directContainer.isJSONArray)
{
index = jsonPointer.PopNumeric(true);
index = jsonPointer.PeekNumeric();
if (index < 0 && itemKey == JSONPOINTER_NEW_ARRAY_ELEMENT) {
index = directContainer.GetStorageLength();
}
@ -465,7 +464,7 @@ public final function bool SaveObject(
* `false` otherwise. Failure can happen if passed `pointer` is invalid
* (either does not point at any existing value or equal to `none`).
*/
public final function bool RemoveObject(JSONPointer jsonPointer)
public final function bool RemoveObject(BaseJSONPointer jsonPointer)
{
local int itemIndex;
local string itemKey;
@ -477,11 +476,11 @@ public final function bool RemoveObject(JSONPointer jsonPointer)
directContainer = containerPointer.record;
if (directContainer.isJSONArray) {
itemIndex = jsonPointer.PopNumeric(true);
itemIndex = jsonPointer.PeekNumeric();
}
else
{
itemKey = __().text.IntoString(jsonPointer.Pop(true));
itemKey = __().text.IntoString(jsonPointer.Peek());
itemIndex = directContainer.FindItem(itemKey);
}
if (itemIndex >= 0)
@ -502,7 +501,7 @@ public final function bool RemoveObject(JSONPointer jsonPointer)
* `JSON_Undefined` if value is missing or passed pointer is invalid.
*/
public final function LocalDatabaseInstance.DataType GetObjectType(
JSONPointer jsonPointer)
BaseJSONPointer jsonPointer)
{
local DBRecord directContainer;
local DBRecordPointer pointer;
@ -550,7 +549,7 @@ public final function LocalDatabaseInstance.DataType GetObjectType(
* @return If `pointer` refers to the JSON array or object - amount of it's
* elements is returned. Otherwise returns `-1`.
*/
public final function int GetObjectSize(JSONPointer jsonPointer)
public final function int GetObjectSize(BaseJSONPointer jsonPointer)
{
local DBRecordPointer pointer;
if (jsonPointer == none) {
@ -572,7 +571,7 @@ public final function int GetObjectSize(JSONPointer jsonPointer)
* @return If `pointer` refers to the JSON object - all available keys.
* `none` otherwise (including case of JSON arrays).
*/
public final function ArrayList GetObjectKeys(JSONPointer jsonPointer)
public final function ArrayList GetObjectKeys(BaseJSONPointer jsonPointer)
{
local int i;
local ArrayList resultKeys;
@ -610,7 +609,7 @@ public final function ArrayList GetObjectKeys(JSONPointer jsonPointer)
* according to `Database.IncrementData()` specification.
*/
public final function Database.DBQueryResult IncrementObject(
JSONPointer jsonPointer,
BaseJSONPointer jsonPointer,
AcediaObject object)
{
local int index;
@ -639,10 +638,10 @@ public final function Database.DBQueryResult IncrementObject(
return DBR_InvalidPointer;
}
directContainer = pointer.record;
itemKey = __().text.IntoString(jsonPointer.Pop(true));
itemKey = __().text.IntoString(jsonPointer.Peek());
if (directContainer.isJSONArray)
{
index = jsonPointer.PopNumeric(true);
index = jsonPointer.PeekNumeric();
if (index < 0 && itemKey == JSONPOINTER_NEW_ARRAY_ELEMENT) {
index = directContainer.GetStorageLength();
}

20
sources/Data/Database/Local/LocalDatabaseInstance.uc

@ -150,9 +150,9 @@ private final function DBTask MakeNewTask(class<DBTask> newTaskClass)
}
private function bool ValidatePointer(
JSONPointer pointer,
DBTask relevantTask,
int requestID)
BaseJSONPointer pointer,
DBTask relevantTask,
int requestID)
{
if (pointer != none) {
return true;
@ -171,7 +171,7 @@ private function bool ValidateRootRecord(DBTask relevantTask, int requestID)
}
public function DBReadTask ReadData(
JSONPointer pointer,
BaseJSONPointer pointer,
optional bool makeMutable,
optional int requestID)
{
@ -195,7 +195,7 @@ public function DBReadTask ReadData(
}
public function DBWriteTask WriteData(
JSONPointer pointer,
BaseJSONPointer pointer,
AcediaObject data,
optional int requestID)
{
@ -229,7 +229,7 @@ public function DBWriteTask WriteData(
}
public function DBRemoveTask RemoveData(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
local DBRemoveTask removeTask;
@ -255,7 +255,7 @@ public function DBRemoveTask RemoveData(
}
public function DBCheckTask CheckDataType(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
local DBCheckTask checkTask;
@ -269,7 +269,7 @@ public function DBCheckTask CheckDataType(
}
public function DBSizeTask GetDataSize(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
local DBSizeTask sizeTask;
@ -283,7 +283,7 @@ public function DBSizeTask GetDataSize(
}
public function DBKeysTask GetDataKeys(
JSONPointer pointer,
BaseJSONPointer pointer,
optional int requestID)
{
local ArrayList keys;
@ -304,7 +304,7 @@ public function DBKeysTask GetDataKeys(
}
public function DBIncrementTask IncrementData(
JSONPointer pointer,
BaseJSONPointer pointer,
AcediaObject increment,
optional int requestID)
{

28
sources/Features/Feature.uc

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

5
sources/Features/FeatureConfig.uc

@ -134,7 +134,8 @@ public static function bool SetAutoEnabledConfig(BaseText autoEnabledConfigName)
defaultproperties
{
usesObjectPool = false
autoEnable = false
usesObjectPool = false
autoEnable = false
supportsDataConversion = true
warningMultipleFeaturesAutoEnabled = (l=LOG_Warning,m="Multiple configs for `%1` were marked as \"auto enabled\". This is likely caused by an erroneous config. \"%2\" config will be used.")
}

2
sources/Gameplay/KF1Frontend/Health/KF1_HealthComponent.uc

@ -67,7 +67,7 @@ private final function TryReplaceDamageTypes()
return;
}
triedToReplaceDamageTypes = true;
if (!class'SideEffects'.default.allowReplacingDamageTypes)
if (!class'ServerSideEffects'.default.allowReplacingDamageTypes)
{
_.logger.Auto(warnReplacingDamageTypesForbidden);
return;

37
sources/InfoQueryHandler/InfoQueryHandler.uc

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

115
sources/KFImplementation/Core/KF1_SideEffectAPI.uc

@ -1,115 +0,0 @@
/**
* Standard implementation for simple API for managing a list of
* `SideEffect` info objects: can add, remove, return all and by package.
* Copyright 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 KF1_SideEffectAPI extends SideEffectAPI;
var private array<SideEffect> activeSideEffects;
public function array<SideEffect> GetAll()
{
local int i;
for (i = 0; i < activeSideEffects.length; i += 1) {
activeSideEffects[i].NewRef();
}
return activeSideEffects;
}
public function SideEffect GetClass(class<SideEffect> sideEffectClass)
{
local int i;
if (sideEffectClass == none) {
return none;
}
for (i = 0; i < activeSideEffects.length; i += 1)
{
if (activeSideEffects[i].class == sideEffectClass)
{
activeSideEffects[i].NewRef();
return activeSideEffects[i];
}
}
return none;
}
public function array<SideEffect> GetFromPackage(BaseText packageName)
{
local int i;
local Text nextPackage;
local array<SideEffect> result;
if (packageName == none) {
return result;
}
for (i = 0; i < activeSideEffects.length; i += 1)
{
nextPackage = activeSideEffects[i].GetPackage();
if (nextPackage.Compare(packageName, SCASE_INSENSITIVE))
{
activeSideEffects[i].NewRef();
result[result.length] = activeSideEffects[i];
}
_.memory.Free(nextPackage);
}
return result;
}
public function bool Add(SideEffect newSideEffect)
{
local int i;
if (newSideEffect == none) {
return false;
}
for (i = 0; i < activeSideEffects.length; i += 1)
{
if (activeSideEffects[i].class == newSideEffect.class) {
return false;
}
}
newSideEffect.NewRef();
activeSideEffects[activeSideEffects.length] = newSideEffect;
return true;
}
public function bool RemoveClass(
class<SideEffect> sideEffectClass)
{
local int i;
if (sideEffectClass == none) {
return false;
}
for (i = 0; i < activeSideEffects.length; i += 1)
{
if (activeSideEffects[i].class == sideEffectClass)
{
_.memory.Free(activeSideEffects[i]);
activeSideEffects.Remove(i, 1);
return true;
}
}
return false;
}
defaultproperties
{
}

2
sources/LevelAPI/SideEffects.uc → sources/KFImplementation/Server/ServerSideEffects.uc

@ -19,7 +19,7 @@
* You should have received a copy of the GNU General Public License
* along with Acedia. If not, see <https://www.gnu.org/licenses/>.
*/
class SideEffects extends AcediaObject
class ServerSideEffects extends AcediaObject
dependson(BroadcastAPI)
abstract
config(AcediaSystem);

2
sources/KFImplementation/Server/Unreal/BroadcastsAPI/BroadcastEventsObserver.uc

@ -207,7 +207,7 @@ var private Broadcast_OnHandleTextFor_Signal onHandleTextFor;
public final function Initialize(ServerUnrealService service)
{
usedInjectionLevel =
class'SideEffects'.default.broadcastHandlerInjectionLevel;
class'ServerSideEffects'.default.broadcastHandlerInjectionLevel;
if (usedInjectionLevel == BHIJ_Root) {
Disable('Tick');
}

65
sources/KFImplementation/Server/Unreal/BroadcastsAPI/BroadcastSideEffect.uc

@ -1,65 +0,0 @@
/**
* Object representing a side effect introduced into the game/server.
* Side effects in Acedia refer to changes that aren't a part of mod's main
* functionality, but rather something necessary to make that functionality
* possible that might also affect how other mods work.
* This is a simple data container that is meant to describe relevant
* changes to the human user.
* Copyright 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 BroadcastSideEffect extends SideEffect
dependson(BroadcastAPI);
public final function Initialize(BroadcastAPI.InjectionLevel usedInjectionLevel)
{
sideEffectName =
_.text.FromString("AcediaCore's `BroadcastHandler` injected");
sideEffectDescription =
_.text.FromString("Handling text and localized messages between server"
@ "and clients requires AcediaCore to add its own `BroadcastHandler`"
@ "into their linked list."
@ "This is normal, since `BroadcastHandler` class was designed to allow"
@ "mods to do that, however, for full functionality Acedia requires to"
@ "inject it as the very first element (`BHIJ_Root` level injection),"
@ "since some of the events become otherwise inaccessible."
@ "This can result in incompatibility with other mods that are trying"
@ "to do the same."
@ "For that reason AcediaCore can also inject its `BroadcastHandler` as"
@ "`BHIJ_Registered`.");
sideEffectPackage = _.text.FromString("AcediaCore");
sideEffectSource = _.text.FromString("UnrealAPI");
if (usedInjectionLevel == BHIJ_Root)
{
sideEffectStatus =
_.text.FromFormattedString("{$TextPositive BHIJ_Root}");
}
else if (usedInjectionLevel == BHIJ_Registered)
{
sideEffectStatus =
_.text.FromFormattedString("{$TextNetutral BHIJ_Registered}");
}
else
{
sideEffectStatus =
_.text.FromFormattedString("{$TextNegative BHIJ_None (???)}");
}
}
defaultproperties
{
}

51
sources/KFImplementation/Server/Unreal/BroadcastsAPI/KF1_BroadcastAPI.uc

@ -1,6 +1,6 @@
/**
* Acedia's default implementation for `BroadcastAPI`.
* Copyright 2021-2022 Anton Tarasenko
* Copyright 2021-2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
@ -23,7 +23,6 @@ class KF1_BroadcastAPI extends BroadcastAPI;
// wasting resources/spamming errors in the log about our inability to do so
var private bool triedToInjectBroadcastHandler;
var private LoggerAPI.Definition infoInjectedBroadcastEventsObserver;
var private LoggerAPI.Definition errBroadcasthandlerForbidden;
var private LoggerAPI.Definition errBroadcasthandlerUnknown;
@ -94,7 +93,7 @@ public function Broadcast_OnHandleLocalizedFor_Slot OnHandleLocalizedFor(
/**
* Method that attempts to inject Acedia's `BroadcastEventObserver`, while
* respecting settings inside `class'SideEffects'`.
* respecting settings inside `class'ServerSideEffects'`.
*
* @param service Reference to `ServerUnrealService` to exchange signal and
* slots classes with.
@ -102,27 +101,19 @@ public function Broadcast_OnHandleLocalizedFor_Slot OnHandleLocalizedFor(
protected final function TryInjectBroadcastHandler(ServerUnrealService service)
{
local InjectionLevel usedLevel;
local BroadcastSideEffect sideEffect;
local BroadcastEventsObserver broadcastObserver;
if (triedToInjectBroadcasthandler) {
return;
}
triedToInjectBroadcasthandler = true;
usedLevel = class'SideEffects'.default.broadcastHandlerInjectionLevel;
usedLevel = class'ServerSideEffects'.default.broadcastHandlerInjectionLevel;
broadcastObserver = BroadcastEventsObserver(_server.unreal.broadcasts.Add(
class'BroadcastEventsObserver', usedLevel));
if (broadcastObserver != none)
{
broadcastObserver.Initialize(service);
sideEffect =
BroadcastSideEffect(_.memory.Allocate(class'BroadcastSideEffect'));
sideEffect.Initialize(usedLevel);
_server.sideEffects.Add(sideEffect);
_.memory.Free(sideEffect);
_.logger
.Auto(infoInjectedBroadcastEventsObserver)
.Arg(InjectionLevelIntoText(usedLevel));
AddSideEffect();
return;
}
// We are here if we have failed
@ -137,6 +128,35 @@ protected final function TryInjectBroadcastHandler(ServerUnrealService service)
}
}
private final static function AddSideEffect() {
local InjectionLevel usedLevel;
local Text sideEffectStatus;
local SideEffect newSideEffect;
usedLevel = class'ServerSideEffects'.default.broadcastHandlerInjectionLevel;
if (usedLevel == BHIJ_Root) {
sideEffectStatus = __().text.FromFormattedString("{$TextPositive BHIJ_Root}");
} else if (usedLevel == BHIJ_Registered) {
sideEffectStatus = __().text.FromFormattedString("{$TextNeutral BHIJ_Registered}");
} else {
sideEffectStatus = __().text.FromFormattedString("{$TextNegative BHIJ_None (???)}");
}
newSideEffect = __().sideEffects.Add(
P("AcediaCore's `BroadcastHandler` injected"),
P("Handling text and localized messages between server and clients requires AcediaCore to"
@ "add its own `BroadcastHandler` into their linked list. This is normal, since"
@ "`BroadcastHandler` class was designed to allow mods to do that, however, for full"
@ "functionality Acedia requires to inject it as the very first element"
@ "(`BHIJ_Root` level injection), since some of the events become otherwise"
@ "inaccessible. This can result in incompatibility with other mods that are trying to"
@ "do the same. For that reason AcediaCore can also inject its `BroadcastHandler` as"
@ "`BHIJ_Registered`."),
P("AcediaCore"),
P("UnrealAPI"),
sideEffectStatus);
__().memory.Free(newSideEffect);
}
private final function Text InjectionLevelIntoText(
InjectionLevel injectionLevel)
{
@ -257,7 +277,6 @@ public function bool IsAdded(class<BroadcastHandler> BHClassToFind)
defaultproperties
{
infoInjectedBroadcastEventsObserver = (l=LOG_Info,m="Injected AcediaCore's `BroadcastEventsObserver` with level `%1`.")
errBroadcastHandlerForbidden = (l=LOG_Error,m="Injected AcediaCore's `BroadcastEventsObserver` is required, but forbidden by AcediaCore's settings: in file \"AcediaSystem.ini\", section [AcediaCore.SideEffects], variable `broadcastHandlerInjectionLevel`.")
errBroadcastHandlerUnknown = (l=LOG_Error,m="Injected AcediaCore's `BroadcastEventsObserver` failed to be injected with level `%1` for unknown reason.")
errBroadcastHandlerForbidden = (l=LOG_Error,m="Injected AcediaCore's `BroadcastEventsObserver` is required, but forbidden by AcediaCore's settings: in file \"AcediaSystem.ini\", section [AcediaCore.SideEffects], variable `broadcastHandlerInjectionLevel`.")
errBroadcastHandlerUnknown = (l=LOG_Error,m="Injected AcediaCore's `BroadcastEventsObserver` failed to be injected with level `%1` for unknown reason.")
}

43
sources/KFImplementation/Server/Unreal/GameRulesAPI/GameRulesSideEffect.uc

@ -1,43 +0,0 @@
/**
* Object representing a side effect introduced into the game/server.
* Side effects in Acedia refer to changes that aren't a part of mod's main
* functionality, but rather something necessary to make that functionality
* possible that might also affect how other mods work.
* This is a simple data container that is meant to describe relevant
* changes to the human user.
* Copyright 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 GameRulesSideEffect extends SideEffect;
public final function Initialize()
{
sideEffectName =
_.text.FromString("AcediaCore's `AcediaGameRules` added");
sideEffectDescription =
_.text.FromString("`GameRule`s is one of the main ways to get notified"
@ "about various gameplay-related events in Unreal Engine."
@ "Of course AcediaCore would require handling some of those events,"
@ "depending on how it's used.");
sideEffectPackage = _.text.FromString("AcediaCore");
sideEffectSource = _.text.FromString("UnrealAPI");
sideEffectStatus = _.text.FromFormattedString("{$TextPositive active}");
}
defaultproperties
{
}

36
sources/KFImplementation/Server/Unreal/GameRulesAPI/KF1_GameRulesAPI.uc

@ -1,6 +1,6 @@
/**
* Acedia's default implementation for `GameRulesAPI` API.
* Copyright 2021-2022 Anton Tarasenko
* Copyright 2021-2023 Anton Tarasenko
*------------------------------------------------------------------------------
* This file is part of Acedia.
*
@ -23,7 +23,6 @@ class KF1_GameRulesAPI extends GameRulesAPI;
// wasting resources/spamming errors in the log about our inability to do so
var private bool triedToInjectGameRules;
var private LoggerAPI.Definition infoAddedGameRules;
var private LoggerAPI.Definition errGameRulesForbidden;
var private LoggerAPI.Definition errGameRulesUnknown;
@ -132,21 +131,20 @@ public function GameRules_OnScoreKill_Slot OnScoreKill(
/**
* Method that attempts to inject Acedia's `AcediaGameRules`, while
* respecting settings inside `class'SideEffects'`.
* respecting settings inside `class'ServerSideEffects'`.
*
* @param service Reference to `ServerUnrealService` to exchange signal and
* slots classes with.
*/
protected function TryAddingGameRules(ServerUnrealService service)
{
local AcediaGameRules gameRules;
local GameRulesSideEffect sideEffect;
local AcediaGameRules gameRules;
if (triedToInjectGameRules) {
return;
}
triedToInjectGameRules = true;
if (!class'SideEffects'.default.allowAddingGameRules)
if (!class'ServerSideEffects'.default.allowAddingGameRules)
{
_.logger.Auto(errGameRulesForbidden);
return;
@ -155,18 +153,27 @@ protected function TryAddingGameRules(ServerUnrealService service)
if (gameRules != none)
{
gameRules.Initialize(service);
sideEffect =
GameRulesSideEffect(_.memory.Allocate(class'GameRulesSideEffect'));
sideEffect.Initialize();
_server.sideEffects.Add(sideEffect);
_.memory.Free(sideEffect);
_.logger.Auto(infoAddedGameRules);
AddSideEffect();
}
else {
_.logger.Auto(errGameRulesUnknown);
}
}
private final static function AddSideEffect() {
local SideEffect newSideEffect;
newSideEffect = __().sideEffects.Add(
P("AcediaCore's `AcediaGameRules` added"),
P("`GameRule`s is one of the main ways to get notified about various gameplay-related"
@ "events in Unreal Engine. Of course AcediaCore would require handling some of those"
@ "events, depending on how it's used."),
P("AcediaCore"),
P("UnrealAPI"),
F("{$TextPositive active}"));
__().memory.Free(newSideEffect);
}
public function GameRules Add(class<GameRules> newRulesClass)
{
local GameRules newGameRules;
@ -236,7 +243,6 @@ public function bool AreAdded(class<GameRules> rulesClassToCheck)
defaultproperties
{
infoAddedGameRules = (l=LOG_Info,m="Added AcediaCore's `AcediaGameRules`.")
errGameRulesForbidden = (l=LOG_Error,m="Adding AcediaCore's `AcediaGameRules` is required, but forbidden by AcediaCore's settings: in file \"AcediaSystem.ini\", section [AcediaCore.SideEffects], variable `allowAddingGameRules`.")
errGameRulesUnknown = (l=LOG_Error,m="Adding AcediaCore's `AcediaGameRules` failed to be injected with level for unknown reason.")
errGameRulesForbidden = (l=LOG_Error,m="Adding AcediaCore's `AcediaGameRules` is required, but forbidden by AcediaCore's settings: in file \"AcediaSystem.ini\", section [AcediaCore.SideEffects], variable `allowAddingGameRules`.")
errGameRulesUnknown = (l=LOG_Error,m="Adding AcediaCore's `AcediaGameRules` failed to be injected with level for unknown reason.")
}

164
sources/LevelAPI/API/SideEffects/SideEffect.uc

@ -1,164 +0,0 @@
/**
* Object representing a side effect introduced into the game/server.
* Side effects in Acedia refer to changes that aren't a part of mod's main
* functionality, but rather something necessary to make that functionality
* possible that might also affect how other mods work.
* This is a simple data container that is meant to describe relevant
* changes to the human user.
* Copyright 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 SideEffect extends AcediaObject
abstract;
/**
* # Side effects
*
* In Acedia "side effect" refers to changes that aren't a part of mod's
* main functionality, but rather something necessary to make that
* functionality possible that might also affect how other mods work. Their
* purpose is to help developers and server admins figure out what changes were
* performed by Acedia or mods based on it.
* What needs to be considered a side effect is loosely defined and they
* are simply a tool to inform others that something they might not have
* expected has happened, that can possibly break other (their) mods.
* AcediaCore, for example, tried to leave a minimal footprint, avoiding
* making any changes to the game classes unless requested, but it still has to
* do some changes (adding `GameRules`, replacing damage types for some zeds,
* etc.) and `SideEffect`s can be used to document these changes. They can be
* used in a similar way for AcediaFixes - a package that is only meant for
* fixing bugs, but inevitably has to make a lot of under the hood changes to
* achieve that.
* On the other hand gameplay mods like Futility can make a lot of changes,
* but they can all be just expected part of its direct functionality: we
* expect feature that shares dosh of leavers to alter players' dosh values, so
* this is not a side effect. Such mods are likely not going to have to specify
* any side effects whatsoever.
*
* ## Implementing your own `SideEffect`s
*
* Simply make a non-abstract child class based on `SideEffect`, create its
* instance filled with necessary data and register it in `SideEffectAPI` once
* side effect is introduced. If you revert introduced side effect, you should
* remove registered object through the same API.
* Each class of the `SideEffect` is supposed to represent a particular
* side effect introduced into the game engine. Whether side effect is active
* is decided by whether it is currently registered in `SideEffectAPI`.
*
* NOTE: `SideEffect` should not have any logic and should serve as
* an immutable data container.
*/
var protected Text sideEffectName;
var protected Text sideEffectDescription;
var protected Text sideEffectPackage;
var protected Text sideEffectSource;
var protected Text sideEffectStatus;
/**
* Returns name (short description) of the caller `SideEffect`. User need to
* be able to tell what this `SideEffect` is generally about from the glance at
* this name.
*
* Guideline is for this value to not exceed `80` characters, but this is not
* enforced.
*
* Must not be `none`.
*
* @return Name (short description) of the caller `SideEffect`.
* Guaranteed to not be `none`.
*/
public function Text GetName()
{
if (sideEffectName != none) {
return sideEffectName.Copy();
}
return none;
}
/**
* Returns description of the caller `SideEffect`. This should describe what
* was done and why relevant change was necessary.
*
* Must not be `none`.
*
* @return Description of the caller `SideEffect`.
* Guaranteed to not be `none`.
*/
public function Text GetDescription()
{
if (sideEffectDescription != none) {
return sideEffectDescription.Copy();
}
return none;
}
/**
* Returns name of the package ("*.u" file) that introduced this change.
*
* Note that if package "A" actually performed the change because another
* package "B" requested certain functionality, it is still package "A" that is
* responsible for the side effect.
*
* Must not be `none`.
*
* @return Name of the package ("*.u" file) that introduced this change.
* Guaranteed to not be `none`.
*/
public function Text GetPackage()
{
if (sideEffectPackage != none) {
return sideEffectPackage.Copy();
}
return none;
}
/**
* What part of package caused this change. For huge packages can be used to
* further specify what introduced the change.
*
* Returned value can be `none` (e.g. when it is unnecessary for small
* packages).
*
* @return Name (short description) of the part of the package that caused
* caller `SideEffect`.
*/
public function Text GetSource()
{
if (sideEffectSource != none) {
return sideEffectSource.Copy();
}
return none;
}
/**
* Status of the caller `SideEffect`. Some side effects can be introduced in
* several different ways - this value needs to help distinguish between them.
*
* @return Status of the caller `SideEffect`.
*/
public function Text GetStatus()
{
if (sideEffectStatus != none) {
return sideEffectStatus.Copy();
}
return none;
}
defaultproperties
{
}

76
sources/LevelAPI/API/SideEffects/SideEffectAPI.uc

@ -1,76 +0,0 @@
/**
* Base class for simple API for managing a list of `SideEffect` info
* objects: can add, remove, return all and by package.
* Copyright 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 SideEffectAPI extends AcediaObject
abstract;
/**
* Returns all so far registered `SideEffect`s.
*
* @return Array of all registered `SideEffect`s.
*/
public function array<SideEffect> GetAll();
/**
* Returns active `SideEffect` instance of the specified class
* `sideEffectClass`.
*
* @param sideEffectClass Class of side effect to return active instance of.
* @return Active `SideEffect` instance of the specified class
* `sideEffectClass`.
* `none` if either `sideEffectClass` is `none` or side effect of such
* class is not currently active.
*/
public function SideEffect GetClass(class<SideEffect> sideEffectClass);
/**
* Returns all so far registered `SideEffect`s from a package `packageName`
* (case insensitive).
*
* @param packageName Name of the package, in `SideEffect`s from which we are
* interested. Must be not `none`.
* @return Array of all registered `SideEffect`s from a package `packageName`.
* If `none`, returns an empty array.
*/
public function array<SideEffect> GetFromPackage(BaseText packageName);
/**
* Registers a new `SideEffect` object as active.
*
* @param newSideEffect Instance of some `SideEffect` class to register as
* active side effect. Must not be `none`.
* @return `true` if new side effect was added and `false` otherwise.
*/
public function bool Add(SideEffect newSideEffect);
/**
* Removes `SideEffect` of the specified sub-class from the list of active
* side effects.
*
* @param sideEffectClass Class of the side effect to remove.
* @return `true` if some side effect was removed as a result of this operation
* and `false` otherwise (even if there was no side effect of specified
* class to begin with).
*/
public function bool RemoveClass(class<SideEffect> sideEffectClass);
defaultproperties
{
}

1
sources/LevelAPI/API/Time/Timer.uc

@ -78,6 +78,7 @@ public function float GetInterval();
*
* Setting this value while the caller `Timer` is running resets it (same as
* calling `StopMe().Start()`).
* But the already inactive timer won't start anew.
*
* @param newInterval How many seconds should separate two `OnElapsed()`
* signals (or starting a timer and next `OnElapsed()` event)?

5
sources/LevelAPI/AcediaAdapter.uc

@ -35,9 +35,8 @@ class AcediaAdapter extends AcediaObject
* specify desired `AcediaAdapter` before loading server/client core.
*/
var public const class<SideEffectAPI> sideEffectAPIClass;
var public const class<TimeAPI> timeAPIClass;
var public const class<DBAPI> dbAPIClass;
var public const class<TimeAPI> timeAPIClass;
var public const class<DBAPI> dbAPIClass;
defaultproperties
{

8
sources/LevelAPI/CoreGlobal.uc

@ -87,11 +87,9 @@ protected function Initialize()
.ArgClass(self.class);
return;
}
api = class'Global'.static.GetInstance().memory;
sideEffects =
SideEffectAPI(api.Allocate(adapterClass.default.sideEffectAPIClass));
time = TimeAPI(api.Allocate(adapterClass.default.timeAPIClass));
db = DBAPI(api.Allocate(adapterClass.default.dbAPIClass));
api = class'Global'.static.GetInstance().memory;
time = TimeAPI(api.Allocate(adapterClass.default.timeAPIClass));
db = DBAPI(api.Allocate(adapterClass.default.dbAPIClass));
}
/**

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

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save