mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-23 13:10:02 +01:00
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:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user