diff --git a/config/AcediaAliases_Colors.ini b/config/AcediaAliases_Colors.ini
index 913130d..ed48d09 100644
--- a/config/AcediaAliases_Colors.ini
+++ b/config/AcediaAliases_Colors.ini
@@ -8,7 +8,7 @@ record=(alias="TextSubHeader",value="rgb(147,112,219)")
record=(alias="TextPositive",value="rgb(60,220,20)")
record=(alias="TextNeutral",value="rgb(255,255,0)")
record=(alias="TextNegative",value="rgb(220,20,60)")
-record=(alias="TextSubtle",value="rgb(128,128,128)")
+record=(alias="TextSubtle",value="rgb(211,211,211)")
record=(alias="TextEmphasis",value="rgb(0,128,255)")
record=(alias="TextOk",value="rgb(0,255,0)")
record=(alias="TextWarning",value="rgb(255,128,0)")
diff --git a/config/AcediaAliases_Commands.ini b/config/AcediaAliases_Commands.ini
index e69de29..012eb51 100644
--- a/config/AcediaAliases_Commands.ini
+++ b/config/AcediaAliases_Commands.ini
@@ -0,0 +1,8 @@
+; This config file allows you to configure command aliases.
+; Remember that aliases are case-insensitive.
+[AcediaCore.CommandAliasSource]
+record=(alias="yes",value="vote yes")
+record=(alias="no",value="vote no")
+
+[help CommandAliases]
+Alias="hlp"
\ No newline at end of file
diff --git a/config/AcediaSystem.ini b/config/AcediaSystem.ini
index 5bfd2d6..a42cf4e 100644
--- a/config/AcediaSystem.ini
+++ b/config/AcediaSystem.ini
@@ -1,5 +1,8 @@
; Every single option in this config should be considered [ADVANCED].
; DO NOT CHANGE THEM unless you are sure you know what you're doing.
+[AcediaCore.AcediaEnvironment]
+debugMode=false
+
[AcediaCore.SideEffects]
; Acedia requires adding its own `GameRules` to listen to many different
; game events.
@@ -150,7 +153,7 @@ maximumNotifyTime=20
TextDefault=(R=255,G=255,B=255,A=255)
TextHeader=(R=128,G=0,B=128,A=255)
TextSubHeader=(R=147,G=112,B=219,A=255)
-TextSubtle=(R=128,G=128,B=128,A=255)
+TextSubtle=(R=211,G=211,B=211,A=255)
TextEmphasis=(R=0,G=128,B=255,A=255)
TextPositive=(R=0,G=128,B=0,A=255)
TextNeutral=(R=255,G=255,B=0,A=255)
diff --git a/config/AcediaVoting.ini b/config/AcediaVoting.ini
new file mode 100644
index 0000000..1326e6d
--- /dev/null
+++ b/config/AcediaVoting.ini
@@ -0,0 +1,29 @@
+[default VotingSettings]
+;= Determines the duration of the voting period, specified in seconds.
+votingTime=30
+;= Minimal amount of rounds player has to play before being allowed to vote.
+;= If every player played the same amount of rounds, then voting is allowed even if they played
+;= less that specified value
+;= `0` to disable.
+minimalRoundsToVote=1
+;= 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
+minimalRoundsToVote=0
+allowSpectatorVoting=true
+allowedToSeeVotesGroup="admin"
+allowedToVoteGroup="moderator"
+allowedToVoteGroup="admin"
+
+[admin VotingSettings]
+votingTime=30
+minimalRoundsToVote=0
+allowSpectatorVoting=true
+allowedToVoteGroup="admin"
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc
new file mode 100644
index 0000000..9e146bd
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandFakers.uc
@@ -0,0 +1,166 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class ACommandFakers extends Command
+ dependsOn(VotingModel);
+
+var private array fakers;
+
+protected function BuildData(CommandDataBuilder builder) {
+ builder.Name(P("fakers"));
+ builder.Group(P("debug"));
+ builder.Summary(P("Adds fake voters for testing \"vote\" command."));
+ builder.Describe(P("Displays current fake voters."));
+
+ builder.SubCommand(P("amount"));
+ builder.Describe(P("Specify amount of faker that are allowed to vote."));
+ builder.ParamInteger(P("fakers_amount"));
+
+ builder.SubCommand(P("vote"));
+ builder.Describe(P("Make a vote as a faker."));
+ builder.ParamInteger(P("faker_number"));
+ builder.ParamBoolean(P("vote_for"));
+}
+
+protected function Executed(CallData arguments, EPlayer instigator) {
+ if (arguments.subCommandName.IsEmpty()) {
+ DisplayCurrentFakers();
+ } else if (arguments.subCommandName.Compare(P("amount"), SCASE_INSENSITIVE)) {
+ ChangeAmount(arguments.parameters.GetInt(P("fakers_amount")));
+ } else if (arguments.subCommandName.Compare(P("vote"), SCASE_INSENSITIVE)) {
+ CastVote(
+ arguments.parameters.GetInt(P("faker_number")),
+ arguments.parameters.GetBool(P("vote_for")));
+ }
+}
+
+public final function UpdateFakersForVoting() {
+ local Voting currentVoting;
+
+ currentVoting = GetCurrentVoting();
+ if (currentVoting != none) {
+ currentVoting.SetDebugVoters(fakers);
+ }
+ _.memory.Free(currentVoting);
+}
+
+private final function CastVote(int fakerID, bool voteFor) {
+ local Voting currentVoting;
+
+ if (fakerID < 0 || fakerID >= fakers.length) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("Faker number is out of bounds."));
+ return;
+ }
+ currentVoting = GetCurrentVoting();
+ if (currentVoting == none) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("There is no voting active right now."));
+ return;
+ }
+ currentVoting.CastVoteByID(fakers[fakerID], voteFor);
+ _.memory.Free(currentVoting);
+}
+
+private final function ChangeAmount(int newAmount) {
+ local int i;
+ local Text nextIDName;
+ local UserID nextID;
+
+ if (newAmount < 0) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("Cannot specify negative amount."));
+ }
+ if (newAmount == fakers.length) {
+ callerConsole
+ .UseColor(_.color.TextNeutral)
+ .WriteLine(P("Specified same amount of fakers."));
+ } else if (newAmount > fakers.length) {
+ for (i = fakers.length; i < newAmount; i += 1) {
+ nextIDName = _.text.FromString("DEBUG:FAKER:" $ i);
+ nextID = UserID(__().memory.Allocate(class'UserID'));
+ nextID.Initialize(nextIDName);
+ _.memory.Free(nextIDName);
+ fakers[fakers.length] = nextID;
+ }
+ } else {
+ for (i = fakers.length - 1; i >= newAmount; i -= 1) {
+ _.memory.Free(fakers[i]);
+ }
+ fakers.length = newAmount;
+ }
+ UpdateFakersForVoting();
+}
+
+private function Voting GetCurrentVoting() {
+ local Commands_Feature feature;
+ local Voting result;
+
+ feature = Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
+ if (feature != none) {
+ result = feature.GetCurrentVoting();
+ feature.FreeSelf();
+ }
+ return result;
+}
+
+private function DisplayCurrentFakers() {
+ local int i;
+ local VotingModel.PlayerVoteStatus nextVoteStatus;
+ local MutableText nextNumber;
+ local Voting currentVoting;
+
+ if (fakers.length <= 0) {
+ callerConsole.WriteLine(P("No fakers!"));
+ return;
+ }
+ currentVoting = GetCurrentVoting();
+ for (i = 0; i < fakers.length; i += 1) {
+ nextNumber = _.text.FromIntM(i);
+ callerConsole
+ .Write(P("Faker #"))
+ .Write(nextNumber)
+ .Write(P(": "));
+ if (currentVoting != none) {
+ nextVoteStatus = currentVoting.GetVote(fakers[i]);
+ }
+ switch (nextVoteStatus) {
+ case PVS_NoVote:
+ callerConsole.WriteLine(P("no vote"));
+ break;
+ case PVS_VoteFor:
+ callerConsole.UseColorOnce(_.color.TextPositive).WriteLine(P("vote for"));
+ break;
+ case PVS_VoteAgainst:
+ callerConsole.UseColorOnce(_.color.TextNegative).WriteLine(P("vote against"));
+ break;
+ default:
+ callerConsole.UseColorOnce(_.color.TextFailure).WriteLine(P("vote !ERROR!"));
+ }
+ _.memory.Free(nextNumber);
+ }
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc
new file mode 100644
index 0000000..d11f7e5
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandVote.uc
@@ -0,0 +1,159 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class ACommandVote extends Command;
+
+var private CommandDataBuilder dataBuilder;
+
+protected function Constructor() {
+ ResetVotingInfo();
+}
+
+protected function Finalizer() {
+ super.Finalizer();
+ _.memory.Free(dataBuilder);
+ dataBuilder = none;
+}
+
+protected function BuildData(CommandDataBuilder builder) {
+ builder.Name(P("vote"));
+ builder.Group(P("core"));
+ builder.Summary(P("Allows players to initiate any available voting."
+ @ "Votings themselves are added as sub-commands."));
+ builder.Describe(P("Default command simply displaces information about current vote."));
+
+ dataBuilder.SubCommand(P("yes"));
+ builder.Describe(P("Vote `yes` on the current vote."));
+ dataBuilder.SubCommand(P("no"));
+ builder.Describe(P("Vote `no` on the current vote."));
+}
+
+protected function Executed(CallData arguments, EPlayer instigator) {
+ local Voting currentVoting;
+ local Commands_Feature feature;
+
+ feature = Commands_Feature(class'Commands_Feature'.static.GetEnabledInstance());
+ if (feature == none) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("Feature responsible for commands and voting isn't enabled."
+ @ "This is unexpected, something broke terribly."));
+ return;
+ } else {
+ currentVoting = feature.GetCurrentVoting();
+ }
+ if (arguments.subCommandName.IsEmpty()) {
+ DisplayInfoAboutVoting(instigator, currentVoting);
+ } else if (arguments.subCommandName.Compare(P("yes"), SCASE_INSENSITIVE)) {
+ CastVote(currentVoting, instigator, true);
+ } else if (arguments.subCommandName.Compare(P("no"), SCASE_INSENSITIVE)) {
+ CastVote(currentVoting, instigator, false);
+ } else {
+ StartVoting(arguments.subCommandName, feature, currentVoting, instigator);
+ }
+ _.memory.Free(feature);
+ _.memory.Free(currentVoting);
+}
+
+/// Adds sub-command information about given voting with a given name.
+public final function AddVotingInfo(BaseText processName, class processClass) {
+ if (processName == none) return;
+ if (processClass == none) return;
+ if (dataBuilder == none) return;
+
+ dataBuilder.SubCommand(processName);
+ processClass.static.AddInfo(dataBuilder);
+ commandData = dataBuilder.BorrowData();
+}
+
+/// Clears all sub-command information added from [`Voting`]s.
+public final function ResetVotingInfo() {
+ _.memory.Free(dataBuilder);
+ dataBuilder = CommandDataBuilder(_.memory.Allocate(class'CommandDataBuilder'));
+ BuildData(dataBuilder);
+ commandData = dataBuilder.BorrowData();
+}
+
+private final function DisplayInfoAboutVoting(EPlayer instigator, Voting currentVoting) {
+ if (currentVoting == none) {
+ callerConsole.WriteLine(P("No voting is active right now."));
+ } else {
+ currentVoting.PrintVotingInfoFor(instigator);
+ }
+}
+
+private final function CastVote(Voting currentVoting, EPlayer voter, bool voteForSuccess) {
+ if (currentVoting != none) {
+ currentVoting.CastVote(voter, voteForSuccess);
+ } else {
+ callerConsole.UseColor(_.color.TextWarning).WriteLine(P("No voting is active right now."));
+ }
+}
+
+// Assumes all arguments aren't `none`.
+private final function StartVoting(
+ BaseText votingName,
+ Commands_Feature feature,
+ Voting currentVoting,
+ EPlayer instigator
+) {
+ local Command fakersCommand;
+ local Voting newVoting;
+ local Commands_Feature.StartVotingResult result;
+
+ result = feature.StartVoting(votingName);
+ // Handle errors
+ if (result == SVR_UnknownVoting) {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .Write(P("Unknown voting option \""))
+ .Write(votingName)
+ .WriteLine(P("\""));
+ return;
+ } else if (result == SVR_AlreadyInProgress) {
+ callerConsole
+ .UseColor(_.color.TextWarning)
+ .WriteLine(P("Another voting is already in progress!"));
+ return;
+ }
+ // Inform new voting about fake voters, in case we're debugging
+ if (currentVoting == none && _.environment.IsDebugging()) {
+ fakersCommand = feature.GetCommand(P("fakers"));
+ if (fakersCommand != none && fakersCommand.class == class'ACommandFakers') {
+ ACommandFakers(fakersCommand).UpdateFakersForVoting();
+ }
+ _.memory.Free(fakersCommand);
+ }
+ // Cast a vote from instigator
+ newVoting = feature.GetCurrentVoting();
+ if (newVoting != none) {
+ newVoting.CastVote(instigator, true);
+ } else {
+ callerConsole
+ .UseColor(_.color.TextFailure)
+ .WriteLine(P("Voting should be available, but it isn't."
+ @ "This is unexpected, something broke terribly."));
+ }
+ _.memory.Free(newVoting);
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Command.uc b/sources/BaseAPI/API/Commands/Command.uc
index 0c60987..c46c831 100644
--- a/sources/BaseAPI/API/Commands/Command.uc
+++ b/sources/BaseAPI/API/Commands/Command.uc
@@ -209,7 +209,7 @@ struct Data {
var protected array