Ranked Choice Voting (#4166)

## About The Pull Request

Implements ranked choice voting for storyteller and map vote

## Why It's Good For The Game

I can close the secret storyteller PR

## Changelog

🆑 LT3
qol: Ranked choice voting is now available for storyteller and map votes
/🆑
This commit is contained in:
LT3
2025-07-02 17:14:40 -06:00
committed by GitHub
parent efc69c608a
commit 30bd96e37e
18 changed files with 550 additions and 27 deletions
+1
View File
@@ -4,4 +4,5 @@
#define span_velvet(str) ("<span class='velvet'>" + str + "</span>")
#define span_velvet_notice(str) ("<span class='velvet_notice'>" + str + "</span>")
#define vote_font(str) ("<span class='vote'>" + str + "</span>")
#define span_vote_notice(str) ("<span class='vote_notice'>" + str + "</span>")
#define span_yellow_flashy(str) ("<span class='glossy'>" + str + "</span>")
@@ -0,0 +1,6 @@
// Vote subsystem counting methods
/// Each person ranks their votes in order of preference
#define VOTE_COUNT_METHOD_RANKED 3
/// The choice with the most votes wins. Ties are broken by the first choice to reach that number of votes.
#define VOTE_WINNER_METHOD_RANKED "Ranked"
+2 -1
View File
@@ -1,3 +1,4 @@
/// Logging for dynamic procs
/proc/log_dynamic(text, list/data)
logger.Log(LOG_CATEGORY_DYNAMIC, text, data)
//logger.Log(LOG_CATEGORY_DYNAMIC, text, data)
logger.Log(LOG_CATEGORY_DYNAMIC, text) // BUBBER EDIT CHANGE - Ranked Choice Voting
+1 -1
View File
@@ -28,7 +28,7 @@ SUBSYSTEM_DEF(map_vote)
/datum/controller/subsystem/map_vote/Initialize()
if(rustg_file_exists(MAP_VOTE_CACHE_LOCATION))
map_vote_cache = json_decode(file2text(MAP_VOTE_CACHE_LOCATION))
var/carryover = CONFIG_GET(number/map_vote_tally_carryover_percentage)
var/carryover = 0 // BUBBER EDIT CHANGE - Ranked Choice Voting - Original: CONFIG_GET(number/map_vote_tally_carryover_percentage)
for(var/map_id in map_vote_cache)
map_vote_cache[map_id] = round(map_vote_cache[map_id] * (carryover / 100))
sanitize_cache()
+3 -3
View File
@@ -168,12 +168,12 @@ SUBSYSTEM_DEF(vote)
else
voted += voter.ckey
if(current_vote.choices_by_ckey[voter.ckey + their_vote] == 1)
current_vote.choices_by_ckey[voter.ckey + their_vote] = 0
if(current_vote.choices_by_ckey["[voter.ckey]_[their_vote]"] == 1) // BUBBER EDIT CHANGE - Original: [voter.ckey + their_vote]
current_vote.choices_by_ckey["[voter.ckey]_[their_vote]"] = 0 // BUBBER EDIT CHANGE - Original: [voter.ckey + their_vote]
current_vote.choices[their_vote]--
else
current_vote.choices_by_ckey[voter.ckey + their_vote] = 1
current_vote.choices_by_ckey["[voter.ckey]_[their_vote]"] = 1 // BUBBER EDIT CHANGE - Original: [voter.ckey + their_vote]
current_vote.choices[their_vote]++
return TRUE
+8
View File
@@ -133,6 +133,10 @@
return get_simple_winner()
if(VOTE_WINNER_METHOD_WEIGHTED_RANDOM)
return get_random_winner()
// BUBBER EDIT ADDITION BEGIN - RANKED CHOICE VOTING
if(VOTE_WINNER_METHOD_RANKED)
return get_ranked_winner()
// BUBBER EDIT ADDITION END
stack_trace("invalid select winner method: [winner_method]. Defaulting to simple.")
return get_simple_winner()
@@ -183,6 +187,10 @@
returned_text += "None"
if(VOTE_WINNER_METHOD_WEIGHTED_RANDOM)
returned_text += "Weighted Random"
// BUBBER EDIT ADDITION BEGIN - RANKED CHOICE VOTING
if(VOTE_WINNER_METHOD_RANKED)
returned_text += "Ranked"
// BUBBER EDIT ADDITION END
else
returned_text += "Simple"
+23 -3
View File
@@ -29,9 +29,9 @@
/datum/vote/custom_vote/create_vote(mob/vote_creator)
var/custom_count_method = tgui_input_list(
user = vote_creator,
message = "Single or multiple choice?",
message = "Single, multiple, or ranked choice?", // BUBBER EDIT CHANGE - Ranked Choice Voting - Original: "Single or multiple choice?"
title = "Choice Method",
items = list("Single", "Multiple"),
items = list("Single", "Multiple", "Ranked"), // BUBBER EDIT CHANGE - Ranked Choice Voting - Original: ("Single", "Multiple")
default = "Single",
)
switch(custom_count_method)
@@ -39,6 +39,22 @@
count_method = VOTE_COUNT_METHOD_SINGLE
if("Multiple")
count_method = VOTE_COUNT_METHOD_MULTI
// BUBBER EDIT ADDITION BEGIN - Ranked Choice Voting
if("Ranked")
count_method = VOTE_COUNT_METHOD_RANKED
// Ask for the threshold if it's ranked voting
var/threshold = tgui_input_number(
user = vote_creator,
message = "Set the victory threshold percentage (1-100)",
title = "Ranked Choice Threshold",
default = 50,
min_value = 1,
max_value = 100
)
if(isnull(threshold))
return FALSE
ranked_winner_threshold = threshold
// BUBBER EDIT ADDITION END
if(null)
return FALSE
else
@@ -50,7 +66,7 @@
user = vote_creator,
message = "How should the vote winner be determined?",
title = "Winner Method",
items = list("Simple", "Weighted Random", "No Winner"),
items = list("Simple", "Weighted Random", "Ranked", "No Winner"), // BUBBER EDIT CHANGE - Ranked Choice Voting - Original: ("Simple", "Weighted Random", "No Winner")
default = "Simple",
)
switch(custom_win_method)
@@ -58,6 +74,10 @@
winner_method = VOTE_WINNER_METHOD_SIMPLE
if("Weighted Random")
winner_method = VOTE_WINNER_METHOD_WEIGHTED_RANDOM
// BUBBER EDIT ADDITION BEGIN - Ranked Choice Voting
if("Ranked")
winner_method = VOTE_WINNER_METHOD_RANKED
// BUBBER EDIT ADDITION END
if("No Winner")
winner_method = VOTE_WINNER_METHOD_NONE
if(null)
@@ -1,4 +1,4 @@
/datum/controller/subsystem/map_vote/finalize_map_vote(datum/vote/map_vote/map_vote)
/datum/controller/subsystem/map_vote/finalize_map_vote(datum/vote/map_vote/map_vote, winning_option) // BUBBER EDIT CHANGE - Ranked Choice Voting - add winning_option
if(already_voted)
message_admins("Attempted to finalize a map vote after a map vote has already been finalized.")
return
@@ -17,6 +17,9 @@
send_map_vote_notice("Admin Override is in effect. Map will not be changed.", "Tallies are recorded and saved.")
return
// BUBBER EDIT CHANGE BEGIN - Ranked Choice Voting
/*
var/list/message_data = list()
var/winner
var/winner_amount = 0
@@ -48,6 +51,12 @@
update_tally_printout()
else
vote_result_message += "Only one map was possible, tallies were not reset."
*/
set_next_map(config.maplist[winning_option])
var/list/vote_results = map_vote.elimination_results
var/serialized_vote_results = "[vote_results.Join("\n")]"
var/list/vote_result_message = list("Method: Ranked Vote\n\nElimination order:\n[serialized_vote_results]\n\nNext Map: [span_vote_notice(span_bold(winning_option))]")
// BUBBER EDIT CHANGE END - Ranked Choice Voting
send_map_vote_notice(arglist(vote_result_message))
@@ -58,7 +67,8 @@
last_message_at = world.time
var/list/messages = args.Copy()
to_chat(world, custom_boxed_message("purple_box", vote_font("[span_bold("Map Vote")]\n<hr>[messages.Join("\n")]")))
//to_chat(world, custom_boxed_message("purple_box", vote_font("[span_bold("Map Vote")]\n<hr>[messages.Join("\n")]"))) // BUBBER EDIT CHANGE - Ranked Choice Voting
to_chat(world, vote_font(fieldset_block("Map Vote - Results", "[messages.Join("\n")]", "boxed_message purple_box")))
/datum/controller/subsystem/map_vote/update_tally_printout()
var/list/data = list()
@@ -4,6 +4,12 @@
/// Has the vote reminder fired yet
var/reminder_fired = FALSE
/datum/controller/subsystem/vote
dependencies = list(
/datum/controller/subsystem/persistence,
/datum/controller/subsystem/map_vote,
)
/// Bubber vote fire proc, original at code/controllers/subsystem/vote.dm
/datum/controller/subsystem/vote/fire()
if(!current_vote)
@@ -30,5 +36,49 @@
if(current_vote.vote_sound && (late_voter.prefs.read_preference(/datum/preference/toggle/sound_announcements)))
SEND_SOUND(late_voter, sound(current_vote.vote_sound))
to_chat(late_voter, custom_boxed_message("purple_box", vote_font("[span_bold("[current_vote.name] Vote")]\n<hr>It's time to make your choices! Type 'vote' or click <a href='byond://winset?command=vote'>here</a> to place your votes.")))
to_chat(late_voter, vote_font(fieldset_block("Storyteller Vote", "It's time to make your choices! Type 'vote' or click <a href='byond://winset?command=vote'>here</a> to place your votes.", "boxed_message purple_box")))
/**
* Ranked choice voting, where voters rank options in order of preference.
* If an option doesn't reach the threshold, lowest votes are transferred to next preferences.
*/
/datum/controller/subsystem/vote/proc/submit_ranked_vote(mob/voter, their_vote, rank)
if(!current_vote)
return
if(!voter?.ckey)
return
if(CONFIG_GET(flag/no_dead_vote) && voter.stat == DEAD && !voter.client?.holder)
return
if(!current_vote.can_mob_vote(voter))
return
var/ckey = voter.ckey
voted += ckey
// Clear previous ranking for this option if any
var/old_rank = current_vote.choices_by_ckey["[ckey]_[their_vote]"]
if(old_rank)
// Remove the old first place vote if we're changing from rank 1
if(old_rank == 1)
current_vote.choices[their_vote]--
current_vote.choices_by_ckey["[ckey]_[their_vote]"] = null
// Add new ranking
if(rank > 0)
current_vote.choices_by_ckey["[ckey]_[their_vote]"] = rank
// Only count first place votes in the total
if(rank == 1)
current_vote.choices[their_vote]++
return TRUE
/datum/controller/subsystem/vote/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
. = ..()
if(.)
return
var/mob/voter = usr
switch(action)
if("voteRanked")
return submit_ranked_vote(voter, params["voteOption"], params["voteRank"])
@@ -0,0 +1,211 @@
/datum/vote
/// The threshold for a winner in ranked voting as a percentage (0-100)
var/ranked_winner_threshold = 50
/// A list of results from the elimination process.
var/list/elimination_results = list()
/// Gets the winner using ranked choice voting.
/datum/vote/proc/get_ranked_winner()
// Total number of voters who submitted at least one ranked choice
var/total_voters = 0
// List of all voter ckeys
var/list/all_voters = list()
// Collect all voters and create a map of their current rankings
var/list/voter_rankings = list() // Stores current rankings for each voter
for(var/key in choices_by_ckey)
var/split_key = splittext(key, "_")
if(length(split_key) != 2)
continue
var/ckey = split_key[1]
var/choice = split_key[2]
var/rank = choices_by_ckey[key]
if(rank > 0)
if(!(ckey in all_voters))
all_voters += ckey
total_voters++
voter_rankings[ckey] = list()
voter_rankings[ckey][choice] = rank
// Log initial state
var/initial_state_text = "Ranked Vote Initial State:\nTotal Voters: [total_voters]\nCurrent Choices:\n"
var/list/initial_state_data = list(
"total_voters" = total_voters,
"choices" = list()
)
elimination_results = list()
for(var/choice in choices)
initial_state_text += "\t[choice]: [choices[choice]] votes\n"
initial_state_data["choices"][choice] = choices[choice]
initial_state_text += "\nVoter Rankings:\n"
initial_state_data["voter_rankings"] = list()
for(var/ckey in voter_rankings)
initial_state_text += "\t[ckey]'s rankings:\n\t\t"
var/list/sorted_rankings = list()
var/list/rankings = voter_rankings[ckey]
initial_state_data["voter_rankings"][ckey] = length(rankings) ? rankings.Copy() : null
for(var/i in 1 to length(rankings))
for(var/choice in rankings)
if(rankings[choice] == i)
sorted_rankings += "[i]. [choice]"
initial_state_text += sorted_rankings.Join(", ") + "\n"
log_dynamic(initial_state_text, initial_state_data)
if(istype(src, /datum/vote/storyteller))
SSgamemode.vote_datum = new
SSgamemode.vote_datum.choices = choices.Copy()
SSgamemode.vote_datum.choices_by_ckey = choices_by_ckey.Copy()
SSgamemode.vote_datum.ranked_winner_threshold = ranked_winner_threshold
// If no one voted, return empty list
if(total_voters == 0)
return list()
// Calculate the threshold for victory
var/victory_threshold = round((total_voters * ranked_winner_threshold) / 100, 1)
log_dynamic("Victory threshold set to [victory_threshold] votes ([ranked_winner_threshold]% of [total_voters] voters)",
list("threshold" = victory_threshold, "threshold_percent" = ranked_winner_threshold, "total_voters" = total_voters))
var/round_number = 1
var/highest_votes = 0
// While we still have choices to consider
while(length(choices) > 1)
log_dynamic("=== Round [round_number] of Ranked Choice Voting ===", list("round" = round_number))
// Find highest vote count and check if it meets threshold
var/list/highest_choices = list()
for(var/option in choices)
var/votes = choices[option]
if(votes > highest_votes)
highest_votes = votes
highest_choices = list(option)
else if(votes == highest_votes)
highest_choices += option
// Check if any option has reached the threshold
if(highest_votes >= victory_threshold)
log_dynamic("Victory threshold ([victory_threshold]) reached! Winner(s): [highest_choices.Join(", ")] with [highest_votes] votes",
list("winners" = highest_choices, "votes" = highest_votes))
elimination_results += "[highest_choices[1]] wins by threshold victory with [highest_votes]/[total_voters] votes!"
if(istype(src, /datum/vote/storyteller))
SSgamemode.vote_datum.elimination_results = elimination_results.Copy()
return highest_choices
// Find lowest vote count to eliminate
var/lowest_votes = INFINITY
var/list/lowest_choices = list()
for(var/option in choices)
var/votes = choices[option]
if(votes < lowest_votes)
lowest_votes = votes
lowest_choices = list(option)
else if(votes == lowest_votes)
lowest_choices += option
// If we have multiple options with the lowest votes, pick one randomly
var/option_to_eliminate
if(length(lowest_choices) > 1)
option_to_eliminate = pick(lowest_choices)
log_dynamic("Multiple options tied for lowest votes ([lowest_votes]): [lowest_choices.Join(", ")]. Randomly eliminating: [option_to_eliminate]",
list("tied_options" = lowest_choices, "eliminated" = option_to_eliminate, "votes" = lowest_votes))
else
option_to_eliminate = lowest_choices[1]
log_dynamic("Eliminating [option_to_eliminate] with lowest votes: [lowest_votes]",
list("eliminated" = option_to_eliminate, "votes" = lowest_votes))
// Remove the eliminated option from choices
choices -= option_to_eliminate
elimination_results += "[option_to_eliminate]"
// Update rankings and redistribute votes
var/redistribution_text = "Vote redistribution after eliminating [option_to_eliminate]:\n"
var/list/redistribution_data = list(
"eliminated" = option_to_eliminate,
"redistributions" = list()
)
for(var/ckey in voter_rankings)
var/list/rankings = voter_rankings[ckey]
var/eliminated_rank = rankings[option_to_eliminate]
if(!eliminated_rank)
continue
// Remove the eliminated option from their rankings
rankings -= option_to_eliminate
// If it was their first choice, we need to redistribute their vote
if(eliminated_rank == 1)
// Find their new first choice
var/new_first_choice
var/lowest_rank = INFINITY
for(var/choice in rankings)
var/rank = rankings[choice]
if(rank < lowest_rank)
lowest_rank = rank
new_first_choice = choice
// Redistribute the vote if they have another choice
if(new_first_choice)
redistribution_text += "\t[ckey]'s vote moved from [option_to_eliminate] to [new_first_choice]\n"
redistribution_data["redistributions"] += list(list(
"voter" = ckey,
"from" = option_to_eliminate,
"to" = new_first_choice
))
choices[new_first_choice]++
// Adjust all remaining ranks down by 1
for(var/choice in rankings)
rankings[choice]--
// Update the choices_by_ckey list to reflect the new rankings
for(var/choice in rankings)
choices_by_ckey["[ckey]_[choice]"] = rankings[choice]
log_dynamic(redistribution_text, redistribution_data)
// Log current state of choices and rankings
var/status_text = "Current vote counts after round [round_number]:\n"
var/list/status_data = list(
"round" = round_number,
"choices" = list(),
"rankings" = list()
)
for(var/option in choices)
status_text += "\t[option]: [choices[option]] votes\n"
status_data["choices"][option] = choices[option]
status_text += "\nUpdated Rankings:\n"
for(var/ckey in voter_rankings)
status_text += "\t[ckey]'s rankings:\n\t\t"
var/list/sorted_rankings = list()
var/list/rankings = voter_rankings[ckey]
status_data["rankings"][ckey] = length(rankings) ? rankings.Copy() : null
for(var/i in 1 to length(rankings))
for(var/choice in rankings)
if(rankings[choice] == i)
sorted_rankings += "[i]. [choice]"
status_text += sorted_rankings.Join(", ") + "\n"
log_dynamic(status_text, status_data)
round_number++
// If we're down to one option, it's the winner
if(length(choices) == 1)
log_dynamic("Only one option remains: [choices[1]] is the winner!", list("winner" = choices[1]))
elimination_results += "[choices[1]]"
if(istype(src, /datum/vote/storyteller))
SSgamemode.vote_datum.elimination_results = elimination_results.Copy()
return list(choices[1])
// This should never happen but just in case
log_dynamic("ERROR: Ranked choice voting ended with no winner!", list())
return list()
@@ -135,6 +135,8 @@ SUBSYSTEM_DEF(gamemode)
var/wizardmode = FALSE
var/storyteller_voted = FALSE
var/ready_only_vote = FALSE
var/datum/vote/storyteller/vote_datum
/datum/controller/subsystem/gamemode/Initialize(time, zlevel)
. = ..()
@@ -698,7 +700,7 @@ SUBSYSTEM_DEF(gamemode)
vote_message += "[storyboy.desc]"
vote_message += ""
var/finalized_message = "[vote_message.Join("\n")]"
to_chat(world, custom_boxed_message("purple_box", vote_font("[span_bold("Storyteller Vote")]\n<hr>[finalized_message]")))
to_chat(world, vote_font(fieldset_block("Storyteller Vote", "[finalized_message]", "boxed_message purple_box")))
return choices
/datum/controller/subsystem/gamemode/proc/storyteller_vote_result(winner_name)
@@ -719,9 +721,73 @@ SUBSYSTEM_DEF(gamemode)
var/datum/storyteller/storyteller_pick
if(!voted_storyteller)
storyteller_pick = pick(storytellers)
log_dynamic("Roundstart picked storyteller [storyteller.name] randomly due to no vote result.")
log_dynamic("Roundstart picked storyteller [storyteller_pick.name] randomly due to no vote result.")
voted_storyteller = storyteller_pick
if(ready_only_vote)
var/processed_storyteller = process_storyteller_vote()
if(!isnull(processed_storyteller))
voted_storyteller = processed_storyteller
else
stack_trace("Processing storyteller vote results failed! That's less than ideal. Using backup non-weighted result [voted_storyteller]")
set_storyteller(voted_storyteller)
if(vote_datum)
var/list/vote_results = vote_datum.elimination_results
var/serialized_vote_results = "[vote_results.Join("\n")]"
var/list/vote_result_message = list("Method: Ranked Vote\n\nElimination order:\n[serialized_vote_results]")
to_chat(world, custom_boxed_message("purple_box", vote_font("[vote_result_message.Join("\n")]")))
to_chat(world, vote_font(fieldset_block("Storyteller: [storyteller.name]", "[storyteller.welcome_text]", "boxed_message purple_box")))
if(vote_datum)
QDEL_NULL(vote_datum)
// Notify discord about the round's selected storyteller
for(var/channel_tag in CONFIG_GET(str_list/channel_announce_new_game))
send2chat(
new /datum/tgs_message_content("The storyteller selected for this round is [storyteller.name]!"),
channel_tag,
)
/datum/controller/subsystem/gamemode/proc/process_storyteller_vote()
var/list/players = list()
if(!length(!vote_datum?.choices_by_ckey))
return
for(var/mob/dead/new_player/player as anything in GLOB.new_player_list)
if(player.ready == PLAYER_READY_TO_PLAY)
players += player.ckey
log_dynamic("[players.len] players ready! Processing storyteller vote results.")
for(var/vote as anything in vote_datum.choices_by_ckey)
if(!vote_datum.choices_by_ckey[vote])
continue
var/vote_string = "[vote]"
var/list/vote_components = splittext(vote_string, "_")
var/vote_ckey = vote_components[1]
var/vote_storyteller = vote_components[2]
if(players.Find(vote_ckey))
log_dynamic("VALID: [vote_ckey] voted for [vote_storyteller]")
else
log_dynamic("INVALID: [vote_ckey] not eligible to vote for [vote_storyteller]")
if(vote_datum.choices_by_ckey[vote] == 1) //only the player's 1st choice is mapped in the other table
vote_datum.choices[vote_storyteller]--
vote_datum.choices_by_ckey -= vote
var/list/vote_winner = vote_datum.get_vote_result()
log_dynamic("Storyteller vote winner is [vote_winner[1]]")
to_chat(GLOB.admins,
type = MESSAGE_TYPE_ADMINLOG,
html = span_vote_notice(fieldset_block("Storyteller", "Selected storyteller: [vote_winner[1]]", "boxed_message blue_box")),
confidential = TRUE,
)
for(var/storyteller_type in storytellers)
var/datum/storyteller/storyboy = storytellers[storyteller_type]
if(storyboy.name == vote_winner[1])
return storyteller_type
stack_trace("Storyteller [vote_winner[1]] was declared vote winner, but couldn't locate datum in storytellers! This should never happen!")
/**
* set_storyteller
@@ -743,8 +809,6 @@ SUBSYSTEM_DEF(gamemode)
point_thresholds[EVENT_TRACK_CREWSET] = track_data.threshold_crewset * CONFIG_GET(number/crewset_point_threshold)
point_thresholds[EVENT_TRACK_GHOSTSET] = track_data.threshold_ghostset * CONFIG_GET(number/ghostset_point_threshold)
to_chat(world, span_notice("<b>Storyteller is [storyteller.name]!</b>"))
to_chat(world, span_notice("[storyteller.welcome_text]"))
log_admin_private("Storyteller switched to [storyteller.name]. [forced ? "Forced by admin ckey [force_ckey]" : ""]")
/**
@@ -1,4 +1,5 @@
/datum/vote/var/has_desc = FALSE
/datum/vote
var/has_desc = FALSE
/datum/vote/proc/return_desc(vote_name)
return ""
@@ -7,19 +8,32 @@
name = "Storyteller"
default_message = "Vote for the storyteller!"
has_desc = TRUE
count_method = VOTE_COUNT_METHOD_MULTI
winner_method = VOTE_WINNER_METHOD_SIMPLE
count_method = VOTE_COUNT_METHOD_RANKED
winner_method = VOTE_WINNER_METHOD_RANKED
ranked_winner_threshold = 70 // 70% threshold for direct win
display_statistics = FALSE
vote_reminder = TRUE
/// Only readied players can vote
var/ready_only = TRUE
/datum/vote/storyteller/New()
. = ..()
default_choices = list()
default_choices = SSgamemode.storyteller_vote_choices()
/datum/vote/storyteller/initiate_vote(initiator, duration)
. = ..()
to_chat(world, vote_font(fieldset_block("Storyteller Vote", "[span_vote_notice("Only players who are ready and joining the game round start will be calculated in voting results.")]", "boxed_message purple_box")))
/datum/vote/storyteller/return_desc(vote_name)
return SSgamemode.storyteller_desc(vote_name)
/datum/vote/storyteller/get_result_text(winners, final_winner, non_voters)
if(!ready_only)
return ..()
return fieldset_block("Storyteller Vote", "Storyteller voting is now closed! Storyteller will be determined when the round starts.", "boxed_message purple_box")
/datum/vote/storyteller/create_vote()
. = ..()
if((length(choices) == 1)) // Only one choice, no need to vote.
@@ -40,6 +54,8 @@
/datum/vote/storyteller/finalize_vote(winning_option)
SSgamemode.storyteller_vote_result(winning_option)
SSgamemode.storyteller_voted = TRUE
if(ready_only)
SSgamemode.ready_only_vote = TRUE
/*
### PERSISTENCE SUBSYSTEM TRACKING BELOW ###
@@ -21,6 +21,8 @@
allow_ghosts = FALSE
// Has this vote been run before?
var/has_ran = FALSE
winner_method = VOTE_WINNER_METHOD_SIMPLE
display_statistics = FALSE
/datum/vote/transfer_vote/can_mob_vote(mob/voter)
if(has_ran)
@@ -1,10 +1,7 @@
/datum/vote/map_vote
count_method = VOTE_COUNT_METHOD_MULTI
winner_method = VOTE_WINNER_METHOD_SIMPLE
display_statistics = TRUE
/datum/vote/transfer_vote
winner_method = VOTE_WINNER_METHOD_SIMPLE
/datum/vote/transfer_vote
count_method = VOTE_COUNT_METHOD_RANKED
winner_method = VOTE_WINNER_METHOD_RANKED
display_statistics = FALSE
/datum/vote/map_vote/finalize_vote(winning_option)
SSmap_vote.finalize_map_vote(src, winning_option)
+2
View File
@@ -538,6 +538,7 @@
#include "code\__DEFINES\~~bubber_defines\species.dm"
#include "code\__DEFINES\~~bubber_defines\status_indicator_defines.dm"
#include "code\__DEFINES\~~bubber_defines\storyteller_defines.dm"
#include "code\__DEFINES\~~bubber_defines\subsystems.dm"
#include "code\__DEFINES\~~bubber_defines\surgery.dm"
#include "code\__DEFINES\~~bubber_defines\transport.dm"
#include "code\__DEFINES\~~bubber_defines\vehicles.dm"
@@ -8959,6 +8960,7 @@
#include "modular_zubbers\code\datums\station_traits\negative_traits.dm"
#include "modular_zubbers\code\datums\storage\subtypes\belts.dm"
#include "modular_zubbers\code\datums\storage\subtypes\others\misc.dm"
#include "modular_zubbers\code\datums\votes\_vote_datum.dm"
#include "modular_zubbers\code\datums\weather\weather_types\radiation_storm.dm"
#include "modular_zubbers\code\datums\weather\weather_types\sand_storm.dm"
#include "modular_zubbers\code\datums\wounds\permanent_limp.dm"
@@ -1369,6 +1369,10 @@ $border-width-px: $border-width * 1px;
color: #db00a0;
}
.vote_notice {
color: #7df9ff;
}
.velvet {
color: #660015;
font-weight: bold;
@@ -1352,6 +1352,10 @@ $border-width-px: $border-width * 1px;
color: #b30083;
}
.vote_notice {
color: #0ea1e6;
}
.velvet {
color: #660015;
font-weight: bold;
+128 -1
View File
@@ -54,6 +54,7 @@ type UserData = {
enum VoteSystem {
VOTE_SINGLE = 1,
VOTE_MULTI = 2,
VOTE_RANKED = 3, // BUBBER EDIT ADDITION - Ranked Choice Voting
}
type Data = {
@@ -315,7 +316,8 @@ const ChoicesPanel = (props) => {
}
>
{user.multiSelection &&
user.multiSelection[user.ckey.concat(choice.name)] === 1 ? (
// BUBBER EDIT CHANGE - Original: [user.ckey.concat(choice.name)]
user.multiSelection[`${user.ckey}_${choice.name}`] === 1 ? (
<Icon align="right" mr={2} color="blue" name="vote-yea" />
) : null}
{
@@ -329,6 +331,131 @@ const ChoicesPanel = (props) => {
))}
</LabeledList>
) : null}
{/* BUBBER EDIT ADDITION - Ranked Choice Voting */}
{currentVote && currentVote.countMethod === VoteSystem.VOTE_RANKED ? (
<NoticeBox success>
Click options to rank them in order of preference. Click again to
remove.
</NoticeBox>
) : null}
{currentVote &&
currentVote.choices.length !== 0 &&
currentVote.countMethod === VoteSystem.VOTE_RANKED ? (
<LabeledList>
{currentVote.choices
.map((choice) => {
// Get all current ranks for this user
const userRanks: Record<string, number> = {};
let maxRank = 0;
currentVote.choices.forEach((c) => {
const rankKey = `${user.ckey}_${c.name}`;
const rank = user.multiSelection?.[rankKey] || 0;
if (rank > 0) {
userRanks[c.name] = rank;
maxRank = Math.max(maxRank, rank);
}
});
// Get this choice's current rank
const rankKey = `${user.ckey}_${choice.name}`;
const currentRank = user.multiSelection?.[rankKey] || 0;
return {
choice,
currentRank,
userRanks,
maxRank,
};
})
// Sort by rank (unranked at bottom)
.sort((a, b) => {
if (a.currentRank === 0 && b.currentRank === 0) {
// If both unranked, sort alphabetically
return a.choice.name.localeCompare(b.choice.name);
}
if (a.currentRank === 0) return 1; // a is unranked, move to bottom
if (b.currentRank === 0) return -1; // b is unranked, move to bottom
return a.currentRank - b.currentRank; // sort by rank
})
.map(({ choice, currentRank, userRanks, maxRank }) => {
// Function to get button text
const getButtonText = () => {
if (currentRank === 0) {
return 'Vote';
}
return `Choice #${currentRank}`;
};
// Function to handle vote click
const handleVoteClick = () => {
if (currentRank > 0) {
// Remove this rank and shift others up
const newRanks: Record<string, number> = {};
Object.entries(userRanks).forEach(
([name, rank]: [string, number]) => {
if (name === choice.name) {
return; // Skip this one as we're removing it
}
if (rank > currentRank) {
newRanks[name] = rank - 1; // Shift up
} else {
newRanks[name] = rank; // Keep same
}
},
);
// Send all rank updates
Object.entries(newRanks).forEach(
([name, newRank]: [string, number]) => {
act('voteRanked', {
voteOption: name,
voteRank: newRank,
});
},
);
// Remove this rank
act('voteRanked', {
voteOption: choice.name,
voteRank: 0,
});
} else {
// Add as next rank
act('voteRanked', {
voteOption: choice.name,
voteRank: maxRank + 1,
});
}
};
return (
<Box key={choice.name}>
<LabeledList.Item
label={choice.name.replace(/^\w/, (c) => c.toUpperCase())}
textAlign="right"
buttons={
<Button
tooltip={
user.isGhost &&
'Ghost voting was disabled by an admin.'
}
selected={currentRank > 0}
disabled={user.isGhost}
onClick={handleVoteClick}
>
{getButtonText()}
</Button>
}
>
{currentVote.displayStatistics || user.isLowerAdmin
? `${choice.votes} Votes`
: null}
</LabeledList.Item>
<LabeledList.Divider />
</Box>
);
})}
</LabeledList>
) : null}
{/* BUBBER EDIT ADDITION END */}
{currentVote ? null : <NoticeBox>No vote active!</NoticeBox>}
</Section>
</Stack.Item>