From 30bd96e37efbf737df44d3c4f8efba7c5ae5d6c2 Mon Sep 17 00:00:00 2001 From: LT3 <83487515+lessthnthree@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:14:40 -0700 Subject: [PATCH] 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 :cl: LT3 qol: Ranked choice voting is now available for storyteller and map votes /:cl: --- code/__DEFINES/~~bubber_defines/span.dm | 1 + code/__DEFINES/~~bubber_defines/subsystems.dm | 6 + code/__HELPERS/logging/dynamic.dm | 3 +- code/controllers/subsystem/map_vote.dm | 2 +- code/controllers/subsystem/vote.dm | 6 +- code/datums/votes/_vote_datum.dm | 8 + code/datums/votes/custom_vote.dm | 26 ++- .../code/controllers/subsystem/map_vote.dm | 14 +- .../code/controllers/subsystem/vote.dm | 52 ++++- .../code/datums/votes/_vote_datum.dm | 211 ++++++++++++++++++ .../code/modules/storyteller/gamemode.dm | 72 +++++- .../modules/storyteller/storyteller_vote.dm | 22 +- modular_zubbers/code/modules/voting/_votes.dm | 2 + .../code/modules/voting/vote_overrides.dm | 13 +- tgstation.dme | 2 + .../tgui-panel/styles/tgchat/chat-dark.scss | 4 + .../tgui-panel/styles/tgchat/chat-light.scss | 4 + tgui/packages/tgui/interfaces/VotePanel.tsx | 129 ++++++++++- 18 files changed, 550 insertions(+), 27 deletions(-) create mode 100644 code/__DEFINES/~~bubber_defines/subsystems.dm create mode 100644 modular_zubbers/code/datums/votes/_vote_datum.dm diff --git a/code/__DEFINES/~~bubber_defines/span.dm b/code/__DEFINES/~~bubber_defines/span.dm index fa11f9e3111..4d95e613b92 100644 --- a/code/__DEFINES/~~bubber_defines/span.dm +++ b/code/__DEFINES/~~bubber_defines/span.dm @@ -4,4 +4,5 @@ #define span_velvet(str) ("" + str + "") #define span_velvet_notice(str) ("" + str + "") #define vote_font(str) ("" + str + "") +#define span_vote_notice(str) ("" + str + "") #define span_yellow_flashy(str) ("" + str + "") diff --git a/code/__DEFINES/~~bubber_defines/subsystems.dm b/code/__DEFINES/~~bubber_defines/subsystems.dm new file mode 100644 index 00000000000..f46e59f7296 --- /dev/null +++ b/code/__DEFINES/~~bubber_defines/subsystems.dm @@ -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" diff --git a/code/__HELPERS/logging/dynamic.dm b/code/__HELPERS/logging/dynamic.dm index 70e3d873163..424eaa94420 100644 --- a/code/__HELPERS/logging/dynamic.dm +++ b/code/__HELPERS/logging/dynamic.dm @@ -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 diff --git a/code/controllers/subsystem/map_vote.dm b/code/controllers/subsystem/map_vote.dm index 6b0495613ea..f08149efb5a 100644 --- a/code/controllers/subsystem/map_vote.dm +++ b/code/controllers/subsystem/map_vote.dm @@ -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() diff --git a/code/controllers/subsystem/vote.dm b/code/controllers/subsystem/vote.dm index 44d4bed41b8..3099645b40b 100644 --- a/code/controllers/subsystem/vote.dm +++ b/code/controllers/subsystem/vote.dm @@ -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 diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm index 957160dea7d..957d1717f31 100644 --- a/code/datums/votes/_vote_datum.dm +++ b/code/datums/votes/_vote_datum.dm @@ -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" diff --git a/code/datums/votes/custom_vote.dm b/code/datums/votes/custom_vote.dm index 78eb6d3b876..a0379eb4ac7 100644 --- a/code/datums/votes/custom_vote.dm +++ b/code/datums/votes/custom_vote.dm @@ -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) diff --git a/modular_zubbers/code/controllers/subsystem/map_vote.dm b/modular_zubbers/code/controllers/subsystem/map_vote.dm index 4010e824b9c..d9b6f334bd6 100644 --- a/modular_zubbers/code/controllers/subsystem/map_vote.dm +++ b/modular_zubbers/code/controllers/subsystem/map_vote.dm @@ -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
[messages.Join("\n")]"))) + //to_chat(world, custom_boxed_message("purple_box", vote_font("[span_bold("Map Vote")]\n
[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() diff --git a/modular_zubbers/code/controllers/subsystem/vote.dm b/modular_zubbers/code/controllers/subsystem/vote.dm index c23d80b4282..6bd6a5aed23 100644 --- a/modular_zubbers/code/controllers/subsystem/vote.dm +++ b/modular_zubbers/code/controllers/subsystem/vote.dm @@ -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
It's time to make your choices! Type 'vote' or click here 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 here 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"]) diff --git a/modular_zubbers/code/datums/votes/_vote_datum.dm b/modular_zubbers/code/datums/votes/_vote_datum.dm new file mode 100644 index 00000000000..b547dca4b68 --- /dev/null +++ b/modular_zubbers/code/datums/votes/_vote_datum.dm @@ -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() diff --git a/modular_zubbers/code/modules/storyteller/gamemode.dm b/modular_zubbers/code/modules/storyteller/gamemode.dm index 8291b60b39e..48ecd881f32 100644 --- a/modular_zubbers/code/modules/storyteller/gamemode.dm +++ b/modular_zubbers/code/modules/storyteller/gamemode.dm @@ -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
[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("Storyteller is [storyteller.name]!")) - to_chat(world, span_notice("[storyteller.welcome_text]")) log_admin_private("Storyteller switched to [storyteller.name]. [forced ? "Forced by admin ckey [force_ckey]" : ""]") /** diff --git a/modular_zubbers/code/modules/storyteller/storyteller_vote.dm b/modular_zubbers/code/modules/storyteller/storyteller_vote.dm index 42a4ca665e3..c1634e2fbec 100644 --- a/modular_zubbers/code/modules/storyteller/storyteller_vote.dm +++ b/modular_zubbers/code/modules/storyteller/storyteller_vote.dm @@ -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 ### diff --git a/modular_zubbers/code/modules/voting/_votes.dm b/modular_zubbers/code/modules/voting/_votes.dm index 1e249f22dec..3694b2b769c 100644 --- a/modular_zubbers/code/modules/voting/_votes.dm +++ b/modular_zubbers/code/modules/voting/_votes.dm @@ -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) diff --git a/modular_zubbers/code/modules/voting/vote_overrides.dm b/modular_zubbers/code/modules/voting/vote_overrides.dm index fb0b4e369f8..f1a50fd9c84 100644 --- a/modular_zubbers/code/modules/voting/vote_overrides.dm +++ b/modular_zubbers/code/modules/voting/vote_overrides.dm @@ -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) diff --git a/tgstation.dme b/tgstation.dme index e9bf5d89635..2f2b52f3d4d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -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" diff --git a/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss b/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss index 862fe2d74e8..a26c9a63f30 100644 --- a/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss +++ b/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss @@ -1369,6 +1369,10 @@ $border-width-px: $border-width * 1px; color: #db00a0; } +.vote_notice { + color: #7df9ff; +} + .velvet { color: #660015; font-weight: bold; diff --git a/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss b/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss index dc75cb13880..074e3fad7de 100644 --- a/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss +++ b/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss @@ -1352,6 +1352,10 @@ $border-width-px: $border-width * 1px; color: #b30083; } +.vote_notice { + color: #0ea1e6; +} + .velvet { color: #660015; font-weight: bold; diff --git a/tgui/packages/tgui/interfaces/VotePanel.tsx b/tgui/packages/tgui/interfaces/VotePanel.tsx index eddc6cf2505..618365497d1 100644 --- a/tgui/packages/tgui/interfaces/VotePanel.tsx +++ b/tgui/packages/tgui/interfaces/VotePanel.tsx @@ -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 ? ( ) : null} { @@ -329,6 +331,131 @@ const ChoicesPanel = (props) => { ))} ) : null} + {/* BUBBER EDIT ADDITION - Ranked Choice Voting */} + {currentVote && currentVote.countMethod === VoteSystem.VOTE_RANKED ? ( + + Click options to rank them in order of preference. Click again to + remove. + + ) : null} + {currentVote && + currentVote.choices.length !== 0 && + currentVote.countMethod === VoteSystem.VOTE_RANKED ? ( + + {currentVote.choices + .map((choice) => { + // Get all current ranks for this user + const userRanks: Record = {}; + 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 = {}; + 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 ( + + c.toUpperCase())} + textAlign="right" + buttons={ + + } + > + {currentVote.displayStatistics || user.isLowerAdmin + ? `${choice.votes} Votes` + : null} + + + + ); + })} + + ) : null} + {/* BUBBER EDIT ADDITION END */} {currentVote ? null : No vote active!}