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!}