From 5ef98c7c5b4743f262affff9c78f1c3cb0daeaa2 Mon Sep 17 00:00:00 2001 From: armored-dragon Date: Wed, 18 Sep 2024 05:10:23 -0500 Subject: [PATCH] Refactored voting engine. Should be more simple to follow. --- applications/voting/vote.js | 98 ++++++------------------------ applications/voting/vote.qml | 1 - applications/voting/vote_engine.js | 98 ++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 79 deletions(-) create mode 100644 applications/voting/vote_engine.js diff --git a/applications/voting/vote.js b/applications/voting/vote.js index 9e5de4c..455fdd3 100644 --- a/applications/voting/vote.js +++ b/applications/voting/vote.js @@ -14,6 +14,7 @@ // TODO: Documentation // FIXME: Handle ties: kill both of tied results // FIXME: Handle ties: Last two standing are tied. +// FIXME: Make the candidate name have reserved value "-1", or change to uncommon name. (() => { "use strict"; @@ -28,6 +29,8 @@ let activePolls = []; // All active polls. let selectedPage = ""; // Selected page the vote screen is on. Used when the host closes the window. + let voteEngine = Script.require("./vote_engine.js"); + const url = Script.resolvePath("./vote.qml"); const myUuid = generateUUID(MyAvatar.sessionUUID); Messages.messageReceived.connect(receivedMessage); @@ -220,88 +223,27 @@ // Take the gathered responses and preform the election // FIXME: Recursive function call function preformElection(){ - let firstVotes = []; // List of first choices from every ballot - let voteResults = {}; // Object that stores the total amount of votes each candidate gets - - // Don't run election if we don't have any votes. - if (Object.keys(pollStats.responses).length == 0) return; - - // Go though each vote received and get the most preferred candidate per ballot. - Object.keys(pollStats.responses).forEach((key) => { - let uuid = key; - let vote = pollStats.responses[uuid]; - - // Assign first vote to new array - firstVotes.push(vote[0]); + + // Format the votes into a format that the election engine can understand + let votesFormatted = []; + const allVoters = Object.keys(pollStats.responses); + allVoters.forEach((voterId) => { + votesFormatted.push(pollStats.responses[voterId]); }); - - // Go through each first choice and increment the total amount of votes per candidate. - for (let i = 0; i < firstVotes.length; i++) { - let candidate = firstVotes[i]; - - // Check if firstVotes index exists - if (!candidate) candidate = -1; // If we have received a "no-vote", just assign -1 - - // Create voteResults index if it does not exist - if (!voteResults[candidate]) voteResults[candidate] = 0; - - // Increment value for each vote - voteResults[candidate]++; - } - - const totalVotes = Object.keys(pollStats.responses).length; // Total votes to expect to be counted. - const majority = Math.floor(totalVotes / 2); // Minimum value to be considered a majority - - const sortedArray = Object.entries(voteResults).sort((a, b) => b[1] - a[1]); - let sortedObject = []; - for (const [key, value] of sortedArray) { - sortedObject.push({ [key]: value }); - } - console.log(`Iteration Votes: ${JSON.stringify(sortedObject, null, 2)}`); - - // Check the most voted for option to see if it makes up over 50% of votes - // NOTE: Has to be *over* 50%. - if (sortedObject[0][Object.keys(sortedObject[0])[0]] > majority) { - let winnerName = Object.keys(sortedObject[0])[0]; - if (winnerName == '-1') winnerName = "No vote"; + const winner = voteEngine.preformVote(votesFormatted); + console.log(`Winner: ${winner.name}`); - pollStats.winnerName = winnerName; - pollStats.votesCounted = totalVotes; - pollStats.winnerSelected = true; + // Update the stats + pollStats.winnerName = winner.name; + pollStats.winnerSelected = true; + pollStats.votesCounted = Object.keys(pollStats.responses).length; + pollStats.iterations = winner.iterations; - // _emitEvent({type: "poll_sync", poll: poll, pollStats: pollStats}); - - Messages.sendMessage(poll.id, JSON.stringify({type: "poll_winner", pollStats: pollStats})); - console.log(`\nWinner: ${winnerName}\nElection rounds: ${pollStats.iterations}\nVotes counted: ${totalVotes}`); - pollStats.responses = {}; - return; // Winner was selected. We are done! - }; - - // If there is not a majority vote, remove the least popular candidate and call preformElection() again - let leastPopularIndex = sortedObject.length - 1; - let leastPopular = Object.keys(sortedObject[leastPopularIndex])[0]; - - // Check to see if least popular is "-1"/"no-vote" - if (leastPopular === "-1") { - leastPopularIndex--; - leastPopular = Object.keys(sortedObject[leastPopularIndex])[0]; // Get the real leastPopular candidate - } - - console.log(`Removing least popular: ${leastPopular}`); - - // Go into each vote and delete the selected least popular candidate - Object.keys(pollStats.responses).forEach((uuid) => { - // Remove the least popular candidate from each vote. - if (pollStats.responses[uuid].indexOf(leastPopular) != -1) pollStats.responses[uuid].splice(pollStats.responses[uuid].indexOf(leastPopular), 1); - console.log(pollStats.responses[uuid]); - }); - - // Update statistics - pollStats.iterations++; - - // Run again - preformElection(); + // Synchronize the winner with all clients + Messages.sendMessage(poll.id, JSON.stringify({type: "poll_winner", pollStats: pollStats})); + pollStats.responses = {}; + return; } // Create a UUID or turn an existing UUID into a string diff --git a/applications/voting/vote.qml b/applications/voting/vote.qml index 57e4de2..15c4524 100644 --- a/applications/voting/vote.qml +++ b/applications/voting/vote.qml @@ -626,7 +626,6 @@ Rectangle { } } - // TODO: Style: Not centered. Remake. // Client actions RowLayout { width: parent.width diff --git a/applications/voting/vote_engine.js b/applications/voting/vote_engine.js new file mode 100644 index 0000000..0dfc5bc --- /dev/null +++ b/applications/voting/vote_engine.js @@ -0,0 +1,98 @@ +/* +[ + ["", "", "", "", ""], // Each entry in the array is an array of strings. + ["", "", "", "", ""], // Each entry is a separate vote received from a participant. + ["", "", "", "" ], // There may be entries missing as a form of "non-vote" + [ ] // There may just be an empty array. +] +*/ + +let ballotsStorage = []; +let iterations = 0; + +function preformVote(arrayOfBallots) { + if (ballotsStorage.length === 0) { + ballotsStorage = arrayOfBallots + print(JSON.stringify(ballotsStorage, null, 4)); + }; + + const totalAmountOfVotes = ballotsStorage.length; + let firstChoices = {}; + + if (totalAmountOfVotes === 0) return; // No votes, no results. + iterations++; + + // Go though each ballot and count the first choice for each + for (let ballotIndex = 0; ballotIndex < totalAmountOfVotes; ballotIndex++) { + const firstChoice = ballotsStorage[ballotIndex][0]; + + // Convert "undefined" to "-1" as a non vote. + if (!firstChoice) { + if (!firstChoices["-1"]) firstChoices["-1"] = 0; + firstChoices["-1"]++; + continue; + } + + // Keep track of the most preferred candidate. + if (!firstChoices[firstChoice]) firstChoices[firstChoice] = 0; + firstChoices[firstChoice]++; + } + + // At this point we have a map of the first choices. + // Now we need to find the candidates that have the lowest amount of votes. + // Exclude candidates that have a name of "-1". This is considered a non vote. + // We look for the lowest voted for candidate, take their amount of votes, and look for other candidates that share this value. + let lowestVoteAmount = Infinity; + let highestVoteAmount = -Infinity; + let highestVoteCandidate = ""; + // let highestVoteCandidateTied = false; // If there are multiple candidates with the same amount of votes. TODO + + // Find the lowest amount of votes for a candidate + Object.keys(firstChoices).forEach((candidate) => { + if (firstChoices[candidate] > highestVoteAmount) { + highestVoteAmount = firstChoices[candidate]; + highestVoteCandidate = candidate; + }; + + if (candidate === "-1") return; // Never eliminate -1 + if (firstChoices[candidate] < lowestVoteAmount) lowestVoteAmount = firstChoices[candidate]; + }); + + // Check to see if we have a winner. + // A winner is chosen when they have a total vote amount that is more than half of the total votes. + // print(JSON.stringify(firstChoices, null, 4)); + // print(`Is ${highestVoteAmount} > ${Math.floor(ballotsStorage.length / 2)}`); + // TODO: Check for ties + if (highestVoteAmount > Math.floor(ballotsStorage.length / 2)) { + const returnValue = {name: highestVoteCandidate, iterations: iterations}; + iterations = 0; // Reset iterations. + ballotsStorage = []; // Reset the ballots array. + return returnValue; + } + + // Make a list of candidates that share the lowest vote + // These will be the candidates that will be removed before the next "round/iteration" of elimination + let candidatesWithSameLowestVote = []; + + Object.keys(firstChoices).forEach((candidate) => { + if (candidate === "-1") return; + + if (firstChoices[candidate] == lowestVoteAmount) candidatesWithSameLowestVote.push(candidate); + }); + + print(`Lowest amount of votes: ${lowestVoteAmount}`); + print(`Removing candidates: ${candidatesWithSameLowestVote}`); + + // Remove all candidates with the lowest vote amount from the first choices + for (let ballotIndex = 0; ballotIndex < totalAmountOfVotes; ballotIndex++) { + const firstChoice = ballotsStorage[ballotIndex][0]; + + if (candidatesWithSameLowestVote.includes(firstChoice)) { + ballotsStorage[ballotIndex].shift(); // Remove the first choice from this ballot + } + } + + return preformVote(ballotsStorage); +} + +module.exports = { preformVote };