rott/kf_sources/AcediaCore/Classes/VotingModel.uc
2026-07-14 20:27:09 +07:00

440 lines
14 KiB
Ucode

/**
* 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 {
}