diff --git a/sources/BaseAPI/API/Commands/Voting/Voting.uc b/sources/BaseAPI/API/Commands/Voting/Voting.uc index 0393430..d6b85f4 100644 --- a/sources/BaseAPI/API/Commands/Voting/Voting.uc +++ b/sources/BaseAPI/API/Commands/Voting/Voting.uc @@ -20,102 +20,260 @@ * along with Acedia. If not, see . */ class Voting extends AcediaObject - dependsOn(VotingModel); + 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. +//! [`Voting`] is created to be heavily integrated with [`Commands_Feature`] and +//! shouldn't be used separately from it. //! You shouldn't allocate its instances directly unless you're working on //! the [`Commands_Feature`]'s or related code. //! -//! # Usage +//! 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. //! -//! This class takes care of the voting process by itself, one only needs to call [`Start()`] method. -//! If you wish to prematurely end voting (e.g. forcing it to end), then call [`Stop()`] method. +//! ## Usage //! -//! Note that there is no method to handle ending of [`Voting`], since all necessary logic is -//! expected to be performed internally. -//! [`Commands_Feature`] does this check lazily (only when user wants to start another voting) -//! via [`HasEnded()`] method. - -/// Records whether end-of-the voting methods were already called. -var private bool endingHandled; -/// Records whether voting was forcefully ended -var private bool forcefullyEnded; -/// Underlying voting model that does actual vote calculations. -var private VotingModel model; - -/// Fake voters that are only used in debug mode to allow for simpler vote testing. -var private array debugVoters; - -/// Text that serves as a template for announcing current vote counts. Don't change this. -var private const string votesDistributionLine; -/// Text that serves as a template for announcing player making a new vote. Don't change this. -var private const string playerVotedLine; -// [`TextTemplate`]s made from the above `string` templates. -var private TextTemplate summaryTemplate, playerVotedTemplate; - -// TODO: check these limitations -/// Name of this voting. +//! 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. /// -/// Has to satisfy limitations described in the `BaseText::IsValidName()` -var private const string preferredName; -/// Text that should be displayed when voting info is displayed mid-voting. +/// 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. /// -/// There isn't any hard limitations, but for the sake of uniformity try to mimic -/// "Voting to end trader currently active." line, avoid adding formatting and don't -/// add comma/exclamation mark at the end. -var private const string infoLine; +/// 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 private const string votingStartedLine; +/// 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 private const string votingSucceededLine; +/// 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 private const string votingFailedLine; +/// 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 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 policyAllowedToVoteGroups; +var private array policyAllowedToSeeVotingGroups; +var private array policyAllowedToForceVoting; +var private bool policySpectatorsCanVote; + +/// Fake voters that are only used in debug mode to allow for simpler vote +/// testing. +var private array debugVoters; + +// Timings at which to announce how much time is left for this voting +var private const array 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() { - summaryTemplate = _.text.MakeTemplate_S(votesDistributionLine); - playerVotedTemplate = _.text.MakeTemplate_S(playerVotedLine); + 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() { - forcefullyEnded = false; - endingHandled = false; _.memory.Free(model); - _.memory.Free(summaryTemplate); - _.memory.Free(playerVotedTemplate); model = none; - summaryTemplate = 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; } -/// Override this to perform necessary logic after voting has succeeded. -protected function Execute() {} +/// Signal that will be emitted when voting ends. +/// +/// # Slot description +/// +/// bool (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. +/// DO NOT call [`CommandDataBuilder::SubCommand()`] or +/// [`CommandDataBuilder::Option()`] methods, otherwise you'll cause unexpected +/// behavior for your mod's users. public static function AddInfo(CommandDataBuilder builder) { - builder.OptionalParams(); - builder.ParamText(P("message")); +} + +/// 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; @@ -127,81 +285,118 @@ public final static function Text GetPreferredName() { return none; } -/// Starts caller [`Voting`] using policies, loaded from the given config. -public final function Start(BaseText votingConfigName) { +/// 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 MutableText howToVoteHint; - local array voters; - - if (model != none) { - return; + 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; + } } - model = VotingModel(_.memory.Allocate(class'VotingModel')); - model.Initialize(VP_CanChangeVote); - voters = UpdateVoters(); - howToVoteHint = MakeHowToVoteHint(); - for (i = 0; i < voters.length; i += 1) { - voters[i].Notify(F(votingStartedLine), howToVoteHint,, P("voting")); - voters[i].BorrowConsole().WriteLine(F(votingStartedLine)); - voters[i].BorrowConsole().WriteLine(howToVoteHint); + if (canForce) { + if (model.ForceEnding(type)) { + TryEnding(instigator); + return FEO_Success; + } + return FEO_NotApplicable; } - _.memory.FreeMany(voters); - _.memory.Free(howToVoteHint); + _.memory.Free(id); + return FEO_Forbidden; } -// 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; +/// 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 voters; - 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)); + 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; } - _.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)); + + // 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 { - result.Append(P("!vote no"), _.text.FormattingFromColor(_.color.TextNegative)); + _.memory.FreeMany(voters); + return SVR_Rejected; } - _.memory.Free(resolvedAlias); - result.Append(P(" to vote")); - return result; -} -/// Forcefully stops [`Voting`]. -/// -/// Only works if [`Voting`] has already started, but didn't yet ended (see [`HasEnded()`]). -public final function Stop() { - if (model != none) { - forcefullyEnded = true; + // 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; } -/// Determines whether the [`Voting`] process has concluded. +/// Checks if the [`Voting`] process has reached its conclusion. /// -/// Note that this is different from the voting being active, as voting that has not yet started is -/// also not concluded. +/// 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 (forcefullyEnded || model.HasEnded()); + return model.HasEnded(); } -/// Returns the current voting status for the specified voter. +/// 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 count, and this method will return [`PVS_NoVote`]. +/// 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`]. /// -/// However, if the player regains their voting rights while the voting process is still ongoing, -/// their previous vote will be automatically restored by the caller [`Voting`]. +/// 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); @@ -209,12 +404,13 @@ public final function VotingModel.PlayerVoteStatus GetVote(UserID voter) { return PVS_NoVote; } -/// Specifies the [`UserID`]s that will be added as additional voters in debug mode. +/// Adds specified [`UserID`]s as additional voters in debug mode. /// -/// This method should only be used for debugging purposes and will only function if the game is -/// running in debug mode. +/// This method is intended for debugging purposes and only functions when +/// the game is running in debug mode. public final function SetDebugVoters(array newDebugVoters) { local int i; + local array realVoters; if(!_.environment.IsDebugging()) { return; @@ -227,108 +423,221 @@ public final function SetDebugVoters(array newDebugVoters) { newDebugVoters[i].NewRef(); } } - _.memory.FreeMany(UpdateVoters()); + 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. +/// NOTE: this method is intended for use only in debug mode, and is will not do +/// anything otherwise. This method silently adds a vote using the provided +/// [`UserID`], without any prompt or notification of updated voting status. +/// It was added to facilitate testing with fake [`UserID`]s, and is limited +/// to debug mode to prevent misuse and unintended behavior in production code. public final function VotingModel.VotingResult CastVoteByID(UserID voter, bool voteForSuccess) { - local array allVoters; + local array realVoters; local VotingModel.VotingResult result; if (model == none) return VFR_NotAllowed; if (voter == none) return VFR_NotAllowed; if (!_.environment.IsDebugging()) return VFR_NotAllowed; - allVoters = UpdateVoters(); + realVoters = FindAllVotingPlayers(); + UpdateVoters(realVoters); result = model.CastVote(voter, voteForSuccess); - if (!TryEnding() && result == VFR_Success) { - AnnounceNewVote(none, allVoters, voteForSuccess); + if (result == VFR_Success) { + AnnounceNewVote(none, voteForSuccess); } - _.memory.FreeMany(allVoters); + TryEnding(); + _.memory.FreeMany(realVoters); return result; } -/// Casts a vote for the specified player. +/// Registers a vote on behalf of the specified player. /// -/// This method will update the voting status for the specified player and may trigger the end of -/// the voting process. -/// After a vote is cast, the updated voting status will be broadcast to all players. +/// 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 bool votingContinues; local UserID voterID; - local array allVoters; + local array realVoters; local VotingModel.VotingResult result; if (model == none) return VFR_NotAllowed; if (voter == none) return VFR_NotAllowed; voterID = voter.GetUserID(); - allVoters = UpdateVoters(); + realVoters = FindAllVotingPlayers(); + UpdateVoters(realVoters); result = model.CastVote(voterID, voteForSuccess); - votingContinues = !TryEnding(); - if (votingContinues) { - switch (result) { - case VFR_Success: - AnnounceNewVote(voter, allVoters, voteForSuccess); - break; - case VFR_NotAllowed: - voter - .BorrowConsole() - .WriteLine(F("You are {$TextNegative not allowed} to vote right now.")); - break; - case VFR_CannotChangeVote: - voter.BorrowConsole().WriteLine(F("Changing vote is {$TextNegative forbidden}.")); - break; - case VFR_VotingEnded: - voter.BorrowConsole().WriteLine(F("Voting has already {$TextNegative ended}!")); - break; - default: - } + 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(allVoters); + _.memory.FreeMany(realVoters); return result; } /// Prints information about caller [`Voting`] to the given player. public final function PrintVotingInfoFor(EPlayer requester) { - local MutableText summaryPart; + local ConsoleWriter writer; + local MutableText summaryPart, timeRemaining; if (requester == none) { return; } - summaryTemplate.Reset(); - summaryTemplate.ArgInt(model.GetVotesFor()); - summaryTemplate.ArgInt(model.GetVotesAgainst()); - summaryPart = summaryTemplate.CollectFormattedM(); - requester - .BorrowConsole() - .Write(F(infoLine)) - .Write(P(". ")) - .Write(summaryPart) - .WriteLine(P(".")); + 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); } -/// Outputs message about new vote being submitted to all relevant voters. -private final function AnnounceNewVote(EPlayer voter, array voters, bool voteForSuccess) { +/// Override this to perform necessary logic after voting has succeeded. +protected function Execute(HashTable arguments) {} + +/// Fill `currentAnnouncements` here!!! +/// 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 MutableText playerVotedPart, summaryPart; + local array allPlayers; + local UserID nextID; + local MutableText playerVotedPart, playerVotedAnonymousPart, summaryPart; - summaryTemplate.Reset(); - summaryTemplate.ArgInt(model.GetVotesFor()); - summaryTemplate.ArgInt(model.GetVotesAgainst()); - summaryPart = summaryTemplate.CollectFormattedM(); + voteSummaryTemplate.Reset(); + voteSummaryTemplate.ArgInt(model.GetVotesFor()); + voteSummaryTemplate.ArgInt(model.GetVotesAgainst()); + summaryPart = voteSummaryTemplate.CollectFormattedM(); playerVotedTemplate.Reset(); + playerVotedAnonymousTemplate.Reset(); if (voter != none) { voterName = voter.GetName(); } else { @@ -338,27 +647,41 @@ private final function AnnounceNewVote(EPlayer voter, array voters, boo _.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(); - for (i = 0; i < voters.length; i += 1) { - voters[i] - .BorrowConsole() - .Write(playerVotedPart) - .Write(P(". ")) - .Write(summaryPart) - .WriteLine(P(".")); - } - _.memory.Free(playerVotedPart); - _.memory.Free(summaryPart); + 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() { - local MutableText outcomeMessage; +/// 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; @@ -366,72 +689,175 @@ private final function bool TryEnding() { endingHandled = true; if (model.GetStatus() == VPM_Success) { - outcomeMessage = _.text.FromFormattedStringM(votingSucceededLine); + outcomeMessage = currentAnnouncements.succeeded; } else { - outcomeMessage = _.text.FromFormattedStringM(votingFailedLine); + outcomeMessage = currentAnnouncements.failed; } - AnnounceOutcome(outcomeMessage); + onVotingEndedSignal.Emit(model.GetStatus() == VPM_Success, usedArguments); + AnnounceOutcome(outcomeMessage, forcedBy); if (model.GetStatus() == VPM_Success) { - Execute(); + Execute(usedArguments); } - _.memory.Free(outcomeMessage); + _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 FindAllVotingPlayers() { + local int i, j; + local bool userAllowedToVote; + local UserID nextID; + local array 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 array UpdateVoters() { +private final function UpdateVoters(array votingPlayers) { local int i; - local UserID nextID; - local array currentPlayers; - local array potentialVoters; + local array votersIDs; if (model == none) { - return currentPlayers; + 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(); - potentialVoters[potentialVoters.length] = debugVoters[i]; + 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 currentPlayers; + + howToVoteHint = MakeHowToVoteHint(); currentPlayers = _.players.GetAll(); for (i = 0; i < currentPlayers.length; i += 1) { nextID = currentPlayers[i].GetUserID(); - potentialVoters[potentialVoters.length] = nextID; + 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); + } } - model.UpdatePotentialVoters(potentialVoters); - _.memory.FreeMany(potentialVoters); - return currentPlayers; + _.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) { +/// 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 MutableText summaryLine; + local Text playerName; + local MutableText editedOutcomeMessage, summaryLine; + local ConsoleWriter writer; local array currentPlayers; if (model == none) { return; } - summaryTemplate.Reset(); - summaryTemplate.ArgInt(model.GetVotesFor()); - summaryTemplate.ArgInt(model.GetVotesAgainst()); - summaryLine = summaryTemplate.CollectFormattedM(); + 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) { - currentPlayers[i].BorrowConsole().WriteLine(outcomeMessage); - currentPlayers[i].BorrowConsole().WriteLine(summaryLine); - currentPlayers[i].Notify(outcomeMessage, summaryLine,, P("voting")); + writer = currentPlayers[i].BorrowConsole(); + writer.WriteLine(editedOutcomeMessage); + writer.WriteLine(summaryLine); + currentPlayers[i].Notify(editedOutcomeMessage, summaryLine,, P("voting")); } _.memory.FreeMany(currentPlayers); _.memory.Free(summaryLine); } defaultproperties { + // You can override these preferredName = "test" - infoLine = "Voting for test" + votingInfoLine = "Debug voting is running" votingStartedLine = "Test voting has started" - votingSucceededLine = "{$TextPositive Test voting passed!}" - votingFailedLine = "{$TextNegative Test voting has failed...}" - playerVotedLine = "Player {$TextSubtle %%player_name%%} has voted %%vote_type%% passing test voting" - votesDistributionLine = "Vote tally: {$TextPositive %1} vs {$TextNegative %2}" + 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 } \ No newline at end of file diff --git a/sources/BaseAPI/API/Commands/Voting/VotingModel.uc b/sources/BaseAPI/API/Commands/Voting/VotingModel.uc index 4af8f00..4e5b3eb 100644 --- a/sources/BaseAPI/API/Commands/Voting/VotingModel.uc +++ b/sources/BaseAPI/API/Commands/Voting/VotingModel.uc @@ -19,50 +19,30 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class VotingModel extends AcediaObject; +class VotingModel extends AcediaObject + dependsOn(MathApi); //! This class counts votes according to the configured voting policies. //! -//! Its main purpose is to separate the voting logic from the voting interface, making -//! the implementation simpler and the logic easier to test. +//! Its main purpose is to separate the voting logic from the voting interface, +//! making the implementation simpler and the logic easier to test. //! //! # Usage //! //! 1. Allocate an instance of the [`VotingModel`] class. -//! 2. Call [`Initialize()`] to set the required policies. +//! 2. Call [`Start()`] to start voting with required policies. //! 3. Use [`UpdatePotentialVoters()`] to specify which users are allowed to vote. -//! You can change this set at any time. The method used to recount the votes will depend on -//! the policies set during the previous [`Initialize()`] call. +//! You can change this set at any time before the voting has concluded. +//! The method used to recount the votes will depend on the policies set +//! during the previous [`Initialize()`] call. //! 4. Use [`CastVote()`] to add a vote from a user. -//! 5. After calling either [`UpdatePotentialVoters()`] or [`CastVote()`], check [`GetStatus()`] to -//! see if the voting has concluded. -//! Once voting has concluded, the result cannot be changed, so you can release the reference -//! to the [`VotingModel`] object. - -/// Describes how [`VotingModel`] should react when a user performs potentially illegal actions. -/// -/// Illegal here means that either corresponding operation won't be permitted or any vote made -/// would be considered invalid. -/// -/// Leaving means simply no longer being in a potential pool of voters, which includes actually -/// leaving the game and simply losing rights to vote. -enum VotingPolicies { - /// Anything that can be considered illegal actions is prohibited. - /// - /// Leaving (or losing rights to vote) during voting will make a vote invalid. - /// - /// Changing vote is forbidden. - VP_Restrictive, - /// Leaving during voting is allowed. Changing a vote is not allowed. - VP_CanLeave, - /// Changing one's vote is allowed. If a user leaves during voting, their vote will be invalid. - VP_CanChangeVote, - /// Leaving during voting and changing a vote is allowed. Leaving means losing rights to vote. - /// - /// Currently, this policy allows all available options, but this may change in the future if - /// more options are added. - VP_CanLeaveAndChangeVote -}; +//! 5. After calling either [`UpdatePotentialVoters()`] or [`CastVote()`], +//! check [`GetStatus()`] to see if the voting has concluded. +//! Once voting has concluded, the result cannot be changed, so you can +//! release the reference to the [`VotingModel`] object. +//! 6. Alternatively, before voting has concluded naturally, you can use +//! [`ForceEnding()`] method to immediately end voting with result being +//! determined by provided [`ForceEndingType`] argument. /// Current state of voting for this model. enum VotingModelStatus { @@ -73,9 +53,7 @@ enum VotingModelStatus { /// Voting has ended with majority for its success VPM_Success, /// Voting has ended with majority for its failure - VPM_Failure, - /// Voting has ended in a draw - VPM_Draw + VPM_Failure }; /// A result of user trying to make a vote @@ -102,9 +80,20 @@ enum PlayerVoteStatus { PVS_VoteAgainst }; +/// Types of possible outcomes when forcing a voting to end +enum ForceEndingType { + /// Result will be decided by the votes that already have been cast + FET_CurrentLeader, + /// Voting will end in success + FET_Success, + /// Voting will end in failure + FET_Failure +}; + var private VotingModelStatus status; -var private bool policyCanLeave, policyCanChangeVote; +/// Specifies whether draw would count as a victory for corresponding voting. +var private bool policyDrawWinsVoting; var private array votesFor, votesAgainst; /// Votes of people that voted before, but then were forbidden to vote @@ -115,8 +104,6 @@ var private array allowedVoters; protected function Constructor() { status = VPM_Uninitialized; - policyCanLeave = false; - policyCanChangeVote = false; } protected function Finalizer() { @@ -133,35 +120,42 @@ protected function Finalizer() { } /// Initializes voting by providing it with a set of policies to follow. -public final function Initialize(VotingPolicies policies) { - if (status == VPM_Uninitialized) { - policyCanLeave = (policies == VP_CanLeave) || (policies == VP_CanLeaveAndChangeVote); - policyCanChangeVote = - (policies == VP_CanChangeVote) || (policies == VP_CanLeaveAndChangeVote); +/// +/// The only available policy is configuring whether draw means victory or loss +/// in voting. +/// +/// Can only be called once, after that will do nothing. +public final function Start(bool drawWinsVoting) { + if (status != VPM_Uninitialized) { + return; } + policyDrawWinsVoting = drawWinsVoting; status = VPM_InProgress; } /// Returns whether voting has already concluded. /// -/// This method should be checked after both [`CastVote()`] and [`UpdatePotentialVoters()`] to check -/// whether either of them was enough to conclude the voting result. +/// This method should be checked after both [`CastVote()`] and +/// [`UpdatePotentialVoters()`] to check whether either of them was enough to +/// conclude the voting result. public final function bool HasEnded() { return (status != VPM_Uninitialized && status != VPM_InProgress); } /// Returns current status of voting. /// -/// This method should be checked after both [`CastVote()`] and [`UpdatePotentialVoters()`] to check -/// whether either of them was enough to conclude the voting result. +/// This method should be checked after both [`CastVote()`] and +/// [`UpdatePotentialVoters()`] to check whether either of them was enough to +/// conclude the voting result. public final function VotingModelStatus GetStatus() { return status; } /// Changes set of [`User`]s that are allowed to vote. /// -/// Generally you want to provide this method with a list of current players, optionally filtered -/// from spectators, users not in priviledged group or any other relevant criteria. +/// Generally you want to provide this method with a list of current players, +/// optionally filtered from spectators, users not in priviledged group or any +/// other relevant criteria. public final function UpdatePotentialVoters(array potentialVoters) { local int i; @@ -178,8 +172,7 @@ public final function UpdatePotentialVoters(array potentialVoters) { /// Attempts to add a vote from specified user. /// -/// Adding a vote can fail if [`voter`] isn't allowed to vote or has already voted and policies -/// forbid changing that vote. +/// Adding a vote can fail if [`voter`] isn't allowed to vote. public final function VotingResult CastVote(UserID voter, bool voteForSuccess) { local bool votesSameWay; local PlayerVoteStatus currentVote; @@ -190,15 +183,12 @@ public final function VotingResult CastVote(UserID voter, bool voteForSuccess) { if (!IsVotingAllowedFor(voter)) { return VFR_NotAllowed; } - currentVote = HasVoted(voter); + currentVote = GetVote(voter); votesSameWay = (voteForSuccess && currentVote == PVS_VoteFor) || (!voteForSuccess && currentVote == PVS_VoteAgainst); if (votesSameWay) { return VFR_AlreadyVoted; } - if (!policyCanChangeVote && currentVote != PVS_NoVote) { - return VFR_CannotChangeVote; - } EraseVote(voter); voter.NewRef(); if (voteForSuccess) { @@ -210,12 +200,11 @@ public final function VotingResult CastVote(UserID voter, bool voteForSuccess) { return VFR_Success; } -/// Checks if the provided user is allowed to vote based on the current list of potential voters. +/// Checks if the provided user is allowed to vote based on the current list of +/// potential voters. /// -/// The right to vote is decided solely by the list of potential voters set using -/// [`UpdatePotentialVoters()`]. -/// However, even if a user is on the list of potential voters, they may not be allowed to vote if -/// they have already cast a vote and the voting policies do not allow vote changes. +/// The right to vote is decided solely by the list of potential voters set +/// using [`UpdatePotentialVoters()`]. /// /// Returns true if the user is allowed to vote, false otherwise. public final function bool IsVotingAllowedFor(UserID voter) { @@ -234,9 +223,8 @@ public final function bool IsVotingAllowedFor(UserID voter) { /// Returns the current vote status for the given voter. /// -/// If the voter was previously allowed to vote, voted, and had their right to vote revoked, their -/// vote will only count if policies allow voters to leave mid-vote. -/// Otherwise, the method will return [`PVS_NoVote`]. +/// If the voter was previously allowed to vote, voted, and had their right to +/// vote revoked, their vote won't count. public final function PlayerVoteStatus GetVote(UserID voter) { local int i; @@ -253,95 +241,100 @@ public final function PlayerVoteStatus GetVote(UserID voter) { return PVS_VoteAgainst; } } - if (policyCanLeave) { - for (i = 0; i < storedVotesFor.length; i += 1) { - if (voter.IsEqual(storedVotesFor[i])) { - return PVS_VoteFor; - } - } - for (i = 0; i < storedVotesAgainst.length; i += 1) { - if (voter.IsEqual(storedVotesAgainst[i])) { - return PVS_VoteAgainst; - } - } - } return PVS_NoVote; } /// Returns amount of current valid votes for the success of this voting. public final function int GetVotesFor() { - if (policyCanLeave) { - return votesFor.length + storedVotesFor.length; - } else { - return votesFor.length; - } + return votesFor.length; } /// Returns amount of current valid votes against the success of this voting. public final function int GetVotesAgainst() { - if (policyCanLeave) { - return votesAgainst.length + storedVotesAgainst.length; - } else { - return votesAgainst.length; - } + return votesAgainst.length; } /// Returns amount of users that are currently allowed to vote in this voting. public final function int GetTotalPossibleVotes() { - if (policyCanLeave) { - return allowedVoters.length + storedVotesFor.length + storedVotesAgainst.length; - } else { - return allowedVoters.length; - } + return allowedVoters.length; } -// Checks if provided user has already voted. -// Only checks among users that are currently allowed to vote, even if their past vote still counts. -private final function PlayerVoteStatus HasVoted(UserID voter) { - local int i; +/// Checks whether, if stopped now, voting will win. +public final function bool IsVotingWinning() { + if (status == VPM_Success) return true; + if (status == VPM_Failure) return false; + if (GetVotesFor() > GetVotesAgainst()) return true; + if (GetVotesFor() < GetVotesAgainst()) return false; - if (voter == none) { - return PVS_NoVote; - } - for (i = 0; i < votesFor.length; i += 1) { - if (voter.IsEqual(votesFor[i])) { - return PVS_VoteFor; - } + return policyDrawWinsVoting; +} + +/// Forcibly ends the voting, deciding winner depending on the argument. +/// +/// Only does anything if voting is currently in progress +/// (in `VPM_InProgress` state). +/// +/// By default decides result by the votes that already have been cast. +/// +/// Returns `true` only if voting was actually ended with this call. +public final function bool ForceEnding(optional ForceEndingType type) { + if (status != VPM_InProgress) { + return false; } - for (i = 0; i < votesAgainst.length; i += 1) { - if (voter.IsEqual(votesAgainst[i])) { - return PVS_VoteAgainst; - } + switch (type) { + case FET_CurrentLeader: + if (IsVotingWinning()) { + status = VPM_Success; + } else { + status = VPM_Failure; + } + break; + case FET_Success: + status = VPM_Success; + break; + case FET_Failure: + default: + status = VPM_Failure; + break; } - return PVS_NoVote; + return true; } private final function RecountVotes() { - local bool canOverturn, everyoneVoted; + local MathApi.IntegerDivisionResult divisionResult; + local int winningScore, losingScore; local int totalPossibleVotes; - local int totalVotesFor, totalVotesAgainst; - local int lowerVoteCount, upperVoteCount, undecidedVoteCount; if (status != VPM_InProgress) { return; } - totalVotesFor = GetVotesFor(); - totalVotesAgainst = GetVotesAgainst(); totalPossibleVotes = GetTotalPossibleVotes(); - lowerVoteCount = Min(totalVotesFor, totalVotesAgainst); - upperVoteCount = Max(totalVotesFor, totalVotesAgainst); - undecidedVoteCount = totalPossibleVotes - (lowerVoteCount + upperVoteCount); - everyoneVoted = (undecidedVoteCount <= 0); - canOverturn = lowerVoteCount + undecidedVoteCount >= upperVoteCount; - if (everyoneVoted || !canOverturn) { - if (totalVotesFor > totalVotesAgainst) { - status = VPM_Success; - } else if (totalVotesFor < totalVotesAgainst) { - status = VPM_Failure; + 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 { - status = VPM_Draw; + // 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) {