diff --git a/check_regex.yaml b/check_regex.yaml index f19d67f7d16f..5420d8fa692c 100644 --- a/check_regex.yaml +++ b/check_regex.yaml @@ -38,7 +38,7 @@ standards: - exactly: [ - 273, + 271, "non-bitwise << uses", '(? length +#define DIGITS(x) (ROUND_UP(log(10, x))) + // round() acts like floor(x, 1) by default but can't handle other values #define FLOOR(x, y) (round((x) / (y)) * (y)) diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index d9f90cbaf9a2..629755487849 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -258,3 +258,16 @@ } \ A.flags_1 &= ~OVERLAY_QUEUED_1; \ }while(FALSE) + +// Vote subsystem counting methods +/// First past the post. One selection per person, and the selection with the most votes wins. +#define VOTE_COUNT_METHOD_SINGLE 1 +/// Approval voting. Any number of selections per person, and the selection with the most votes wins. +#define VOTE_COUNT_METHOD_MULTI 2 + +/// The choice with the most votes wins. Ties are broken by the first choice to reach that number of votes. +#define VOTE_WINNER_METHOD_SIMPLE "Simple" +/// The winning choice is selected randomly based on the number of votes each choice has. +#define VOTE_WINNER_METHOD_WEIGHTED_RANDOM "Weighted Random" +/// There is no winner for this vote. +#define VOTE_WINNER_METHOD_NONE "None" diff --git a/code/__HELPERS/game.dm b/code/__HELPERS/game.dm index 6dc31eea2fdb..b249fede86ed 100644 --- a/code/__HELPERS/game.dm +++ b/code/__HELPERS/game.dm @@ -363,8 +363,8 @@ block( \ viewing += M.client flick_overlay(I, viewing, duration) -/proc/get_active_player_count(alive_check = 0, afk_check = 0, human_check = 0) - // Get active players who are playing in the round +///Get active players who are playing in the round +/proc/get_active_player_count(alive_check = FALSE, afk_check = FALSE, human_check = FALSE) var/active_players = 0 for(var/i = 1; i <= GLOB.player_list.len; i++) var/mob/M = GLOB.player_list[i] diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 6a5959574754..a59d14cce4d3 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -102,9 +102,9 @@ /datum/config_entry/flag/allow_admin_asaycolor //Allows admins with relevant permissions to have a personalized asay color -/datum/config_entry/flag/allow_vote_restart // allow votes to restart +/datum/config_entry/flag/allow_vote_restart // allow player votes to restart -/datum/config_entry/flag/allow_vote_mode // allow votes to change mode +/datum/config_entry/flag/allow_vote_transfer // allow player votes to initiate a transfer /datum/config_entry/flag/auth_only // server can only be used for authentication @@ -120,7 +120,9 @@ integer = FALSE min_val = 0 -//WS Begin - Autotranfer vote +/// If disabled, no-voters will automatically have their votes added to certain vote options +/// (For eample: restart votes will default to "no restart", map votes will default to their preferred map / default map) +/datum/config_entry/flag/default_no_vote /datum/config_entry/number/vote_autotransfer_initial //length of time before the first autotransfer vote is called (deciseconds, default 2 hours) config_entry_value = 72000 @@ -132,9 +134,6 @@ integer = FALSE min_val = 0 -//WS End - -/datum/config_entry/flag/default_no_vote // vote does not default to nochange/norestart /datum/config_entry/flag/no_dead_vote // dead people can't vote diff --git a/code/controllers/subsystem/autotransfer.dm b/code/controllers/subsystem/autotransfer.dm index 1cb5e7851c13..2b1259ef4f5f 100644 --- a/code/controllers/subsystem/autotransfer.dm +++ b/code/controllers/subsystem/autotransfer.dm @@ -3,19 +3,19 @@ SUBSYSTEM_DEF(autotransfer) flags = SS_KEEP_TIMING | SS_BACKGROUND wait = 1 MINUTES - var/starttime - var/targettime + COOLDOWN_DECLARE(next_vote) /datum/controller/subsystem/autotransfer/Initialize(timeofday) - starttime = world.time - targettime = starttime + CONFIG_GET(number/vote_autotransfer_initial) + COOLDOWN_START(src, next_vote, CONFIG_GET(number/vote_autotransfer_initial)) return ..() /datum/controller/subsystem/autotransfer/fire() - if (world.time > targettime) - SSvote.initiate_vote("transfer",null, FALSE) //WS Edit - Ghost Vote Rework - targettime = targettime + CONFIG_GET(number/vote_autotransfer_interval) + if(COOLDOWN_FINISHED(src, next_vote)) + //Delay the vote if there's already a vote in progress + if(SSvote.current_vote) + COOLDOWN_START(src, next_vote, SSvote.current_vote.time_remaining + 10 SECONDS) + SSvote.initiate_vote(/datum/vote/transfer_vote, "The Server", forced = TRUE) + COOLDOWN_START(src, next_vote, CONFIG_GET(number/vote_autotransfer_interval)) /datum/controller/subsystem/autotransfer/Recover() - starttime = SSautotransfer.starttime - targettime = SSautotransfer.targettime + next_vote = SSautotransfer.next_vote diff --git a/code/controllers/subsystem/mapping.dm b/code/controllers/subsystem/mapping.dm index bc474bd6c4a0..03720e4d641f 100644 --- a/code/controllers/subsystem/mapping.dm +++ b/code/controllers/subsystem/mapping.dm @@ -109,11 +109,6 @@ SUBSYSTEM_DEF(mapping) #define INIT_ANNOUNCE(X) to_chat(world, "[X]"); log_world(X) -/datum/controller/subsystem/mapping/proc/mapvote() - SSvote.initiate_vote("map", "automatic map rotation", TRUE) //WS Edit - Ghost Voting Rework - -/datum/controller/subsystem/mapping/proc/changemap(datum/map_template/map) - /datum/controller/subsystem/mapping/proc/preloadTemplates(path = "_maps/templates/") //see master controller setup var/list/filelist = flist(path) for(var/map in filelist) diff --git a/code/controllers/subsystem/vote.dm b/code/controllers/subsystem/vote.dm index b06baa1c49c2..03c244ae05d2 100644 --- a/code/controllers/subsystem/vote.dm +++ b/code/controllers/subsystem/vote.dm @@ -1,383 +1,370 @@ +/// Define to mimic a span macro but for the purple font that vote specifically uses. +#define vote_font(text) ("" + text + "") + SUBSYSTEM_DEF(vote) name = "Vote" - wait = 10 - - flags = SS_KEEP_TIMING|SS_NO_INIT - + wait = 1 SECONDS + flags = SS_KEEP_TIMING runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT - var/initiator = null - var/started_time = null - var/time_remaining = 0 - var/mode = null - var/question = null - var/list/choices = list() + /// A list of all generated action buttons + var/list/datum/action/generated_actions = list() + /// All votes that we can possible vote for. + var/list/datum/vote/possible_votes = list() + /// The vote we're currently voting on. + var/datum/vote/current_vote + /// A list of all ckeys who have voted for the current vote. var/list/voted = list() + /// A list of all ckeys currently voting for the current vote. var/list/voting = list() - var/list/generated_actions = list() -/datum/controller/subsystem/vote/fire() //called by master_controller - if(mode) - time_remaining = round((started_time + CONFIG_GET(number/vote_period) - world.time)/10) +/datum/controller/subsystem/vote/Initialize(start_timeofday) + for(var/vote_type in subtypesof(/datum/vote)) + var/datum/vote/vote = new vote_type() + if(!vote.is_accessible_vote()) + qdel(vote) + continue - if(time_remaining < 0) - result() - for(var/client/C in voting) - C << browse(null, "window=vote;can_close=0") - reset() - else - var/datum/browser/client_popup - for(var/client/C in voting) - client_popup = new(C, "vote", "Voting Panel") - client_popup.set_window_options("can_close=0") - client_popup.set_content(interface(C)) - client_popup.open(FALSE) + possible_votes[vote.name] = vote + + return ..() +// Called by master_controller +/datum/controller/subsystem/vote/fire() + if(!current_vote) + return + current_vote.time_remaining = round((current_vote.started_time + CONFIG_GET(number/vote_period) - world.time) / 10) + if(current_vote.time_remaining < 0) + process_vote_result() + SStgui.close_uis(src) + reset() + +/// Resets all of our vars after votes conclude / are cancelled. /datum/controller/subsystem/vote/proc/reset() - initiator = null - time_remaining = 0 - mode = null - question = null - choices.Cut() voted.Cut() voting.Cut() - remove_action_buttons() - -/datum/controller/subsystem/vote/proc/get_result() - //get the highest number of votes - var/greatest_votes = 0 - var/total_votes = 0 - for(var/option in choices) - var/votes = choices[option] - total_votes += votes - if(votes > greatest_votes) - greatest_votes = votes - //default-vote for everyone who didn't vote - if(!CONFIG_GET(flag/default_no_vote) && choices.len) - var/list/non_voters = GLOB.directory.Copy() - non_voters -= voted - for (var/non_voter_ckey in non_voters) - var/client/C = non_voters[non_voter_ckey] - if (!C || C.is_afk()) - non_voters -= non_voter_ckey - if(non_voters.len > 0) - if(mode == "restart") - choices["Continue Playing"] += non_voters.len - if(choices["Continue Playing"] >= greatest_votes) - greatest_votes = choices["Continue Playing"] - else if(mode == "gamemode") - if(GLOB.master_mode in choices) - choices[GLOB.master_mode] += non_voters.len - if(choices[GLOB.master_mode] >= greatest_votes) - greatest_votes = choices[GLOB.master_mode] - else if(mode == "transfer") - var/factor = 1 - switch(world.time / (1 MINUTES)) - if(0 to 60) - factor = 0.5 - if(61 to 120) - factor = 0.8 - if(121 to 240) - factor = 1 - if(241 to 300) - factor = 1.2 - else - factor = 1.4 - choices["Initiate Bluespace Jump"] += round(non_voters.len * factor) - - //get all options with that many votes and return them in a list - . = list() - if(greatest_votes) - for(var/option in choices) - if(choices[option] == greatest_votes) - . += option - return . - -/datum/controller/subsystem/vote/proc/announce_result() - var/list/winners = get_result() - var/text - if(winners.len > 0) - if(question) - text += "[question]" - else - text += "[capitalize(mode)] Vote" - for(var/i=1,i<=choices.len,i++) - var/votes = choices[choices[i]] - if(!votes) - votes = 0 - text += "\n[choices[i]]: [votes]" - if(mode != "custom") - if(winners.len > 1) - text = "\nVote Tied Between:" - for(var/option in winners) - text += "\n\t[option]" - . = pick(winners) - text += "\nVote Result: [.]" - else - text += "\nDid not vote: [GLOB.clients.len-voted.len]" + + current_vote?.reset() + current_vote = null + + QDEL_LIST(generated_actions) + + SStgui.update_uis(src) + +/** + * Process the results of the vote. + * Collects all the winners, breaks any ties that occur, + * prints the results of the vote to the world, + * and finally follows through with the effects of the vote. + */ +/datum/controller/subsystem/vote/proc/process_vote_result() + + // First collect all the non-voters we have. + var/list/non_voters = GLOB.directory.Copy() - voted + // Remove AFK or clientless non-voters. + for(var/non_voter_ckey in non_voters) + var/client/non_voter_client = non_voters[non_voter_ckey] + if(!non_voter_client || non_voter_client.is_afk() || (CONFIG_GET(flag/no_dead_vote) && non_voter_client.mob.stat == DEAD && !non_voter_client.holder)) + non_voters -= non_voter_ckey + + // Now get the result of the vote. + // This is a list, as we could have a tie (multiple winners). + var/list/winners = current_vote.get_vote_result(non_voters) + + // Now we should determine who actually won the vote. + var/final_winner + // 1 winner? That's the winning option + if(length(winners) == 1) + final_winner = winners[1] + + // More than 1 winner? Tiebreaker between all the winners + else if(length(winners) > 1) + final_winner = current_vote.tiebreaker(winners) + + // Announce the results of the vote to the world. + var/to_display = current_vote.get_result_text(winners, final_winner, non_voters) + + var/log_string = replacetext(to_display, "\n", "\\n") // 'keep' the newlines, but dont actually print them as newlines + log_vote(log_string) + to_chat(world, span_infoplain(vote_font("\n[to_display]"))) + + // Finally, doing any effects on vote completion + if (final_winner) // if no one voted, or the vote cannot be won, final_winner will be null + current_vote.finalize_vote(final_winner) + +/** + * One selection per person, and the selection with the most votes wins. + */ +/datum/controller/subsystem/vote/proc/submit_single_vote(mob/voter, their_vote) + if(!current_vote) + return + if(!voter?.ckey) + return + if(CONFIG_GET(flag/no_dead_vote) && voter.stat == DEAD && !voter.client?.holder) + return + + // If user has already voted, remove their specific vote + if(voter.ckey in current_vote.choices_by_ckey) + var/their_old_vote = current_vote.choices_by_ckey[voter.ckey] + current_vote.choices[their_old_vote]-- + else - text += "Vote Result: Inconclusive - No Votes!" - log_vote(text) - remove_action_buttons() - to_chat(world, span_purple(examine_block(text))) - return . - -/datum/controller/subsystem/vote/proc/result() - . = announce_result() - var/restart = FALSE - if(.) - switch(mode) - if("restart") - if(. == "Restart Round") - restart = TRUE - if("gamemode") - if(GLOB.master_mode != .) - SSticker.save_mode(.) - if(SSticker.HasRoundStarted()) - restart = TRUE - else - GLOB.master_mode = . - if("transfer") - if(. == "Initiate Bluespace Jump") - SSshuttle.request_jump() - - if(restart) - var/active_admins = FALSE - for(var/client/C in GLOB.admins) - if(!C.is_afk() && check_rights_for(C, R_SERVER)) - active_admins = TRUE - break - if(!active_admins) - SSticker.Reboot("Restart vote successful.", "restart vote") - else - to_chat(world, "Notice:Restart vote will not restart the server automatically because there are active admins on.") - message_admins("A restart vote has passed, but there are active admins on with +server, so it has been canceled. If you wish, you may restart the server.") - - return . - -/datum/controller/subsystem/vote/proc/submit_vote(vote) - if(mode) - if(CONFIG_GET(flag/no_dead_vote) && usr.stat == DEAD && !usr.client.holder) - return FALSE - if(!(usr.ckey in voted)) - if(vote && 1<=vote && vote<=choices.len) - voted += usr.ckey - choices[choices[vote]]++ //check this - return vote - return FALSE - -/datum/controller/subsystem/vote/proc/initiate_vote(vote_type, initiator_key, observer_vote_allowed = TRUE) - if(!MC_RUNNING(init_stage)) //Server is still intializing. - to_chat(usr, "Cannot start vote, server is not done initializing.") + voted += voter.ckey + + current_vote.choices_by_ckey[voter.ckey] = their_vote + current_vote.choices[their_vote]++ + + return TRUE + +/** + * Any number of selections per person, and the selection with the most votes wins. + */ +/datum/controller/subsystem/vote/proc/submit_multi_vote(mob/voter, their_vote) + if(!current_vote) + return + if(!voter?.ckey) + return + if(CONFIG_GET(flag/no_dead_vote) && voter.stat == DEAD && !voter.client?.holder) + return + + 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 + current_vote.choices[their_vote]-- + + else + current_vote.choices_by_ckey[voter.ckey + their_vote] = 1 + current_vote.choices[their_vote]++ + + return TRUE + +/** + * Initiates a vote, allowing all players to vote on something. + * + * * vote_type - The type of vote to initiate. Can be a [/datum/vote] typepath, a [/datum/vote] instance, or the name of a vote datum. + * * vote_initiator_name - The ckey (if player initiated) or name that initiated a vote. Ex: "UristMcAdmin", "the server" + * * vote_initiator - If a person / mob initiated the vote, this is the mob that did it + * * forced - Whether we're forcing the vote to go through regardless of existing votes or other circumstances. Note: If the vote is admin created, forced becomes true regardless. + */ +/datum/controller/subsystem/vote/proc/initiate_vote(vote_type, vote_initiator_name, mob/vote_initiator, forced = FALSE) + + // Even if it's forced we can't vote before we're set up + if(!MC_RUNNING(init_stage)) + if(vote_initiator) + to_chat(vote_initiator, span_warning("You cannot start vote now, the server is not done initializing.")) return FALSE - var/admin = FALSE - var/ckey = ckey(initiator_key) - if(GLOB.admin_datums[ckey]) - admin = TRUE - if(!mode) - if(started_time) - var/next_allowed_time = (started_time + CONFIG_GET(number/vote_delay)) - if(mode) - to_chat(usr, "There is already a vote in progress! please wait for it to finish.") - return FALSE + // Check if we have unlimited voting power. + // Admin started (or forced) voted will go through even if there's an ongoing vote, + // if voting is on cooldown, or regardless if a vote is config disabled (in some cases) + var/unlimited_vote_power = forced || !!GLOB.admin_datums[vote_initiator?.ckey] + if(current_vote && !unlimited_vote_power) + if(vote_initiator) + to_chat(vote_initiator, span_warning("There is already a vote in progress! Please wait for it to finish.")) + return FALSE - if(next_allowed_time > world.time && !admin) - to_chat(usr, "A vote was initiated recently, you must wait [DisplayTimeText(next_allowed_time-world.time)] before a new vote can be started!") - return FALSE + // Get our actual datum + var/datum/vote/to_vote + // If we were passed a path: find the path in possible_votes + if(ispath(vote_type, /datum/vote)) + var/datum/vote/vote_path = vote_type + to_vote = possible_votes[initial(vote_path.name)] - reset() - switch(vote_type) - if("restart") - choices.Add("Restart Round","Continue Playing") - if("gamemode") - choices.Add(config.votable_modes) - if("transfer") - if(SSshuttle.jump_mode != BS_JUMP_IDLE) - return FALSE - choices.Add("Initiate Bluespace Jump","Continue Playing") - if("custom") - question = stripped_input(usr,"What is the vote for?") - if(!question) - return FALSE - for(var/i=1,i<=10,i++) - var/option = capitalize(stripped_input(usr,"Please enter an option or hit cancel to finish")) - if(!option || mode || !usr.client) - break - choices.Add(option) - else - return FALSE - mode = vote_type - initiator = initiator_key || "the Server" - started_time = world.time - var/text = "[capitalize(mode)] vote started by [initiator]." - if(mode == "custom") - text += "\n[question]" - log_vote(text) - - var/vp = CONFIG_GET(number/vote_period) - var/vote_message = "[text]\nType vote or click here to place your votes.\nYou have [DisplayTimeText(vp)] to vote." - if(observer_vote_allowed) - to_chat(world, examine_block(vote_message)) - SEND_SOUND(world, sound('sound/misc/compiler-stage2.ogg')) - time_remaining = round(vp/10) - for(var/c in GLOB.clients) - var/client/C = c - var/datum/action/vote/V = new - if(question) - V.name = "Vote: [question]" - C.player_details.player_actions += V - V.Grant(C.mob) - generated_actions += V - return TRUE - else - var/list/valid_clients = GLOB.clients.Copy() - for(var/c in valid_clients) - var/client/C = c - if(C.mob && (isobserver(C.mob) || isnewplayer(C.mob) || ismouse(C.mob)) && !check_rights_for(C, R_ADMIN)) - valid_clients -= C - for(var/c in valid_clients) - var/client/C = c - SEND_SOUND(C, sound('sound/misc/compiler-stage2.ogg')) - to_chat(C.mob, examine_block(vote_message)) - var/datum/action/vote/V = new - if(question) - V.name = "Vote: [question]" - C.player_details.player_actions += V - V.Grant(C.mob) - generated_actions += V - time_remaining = round(vp/10) - return TRUE - return FALSE + // If we were passed an instance: use the instance + else if(istype(vote_type, /datum/vote)) + to_vote = vote_type -/datum/controller/subsystem/vote/proc/interface(client/C) - if(!C) - return - var/admin = FALSE - var/trialmin = FALSE - if(C.holder) - admin = TRUE - if(check_rights_for(C, R_ADMIN)) - trialmin = TRUE - voting |= C - - if(mode) - if(question) - . += "

Vote: '[question]'

" - else - . += "

Vote: [capitalize(mode)]

" - . += "Time Left: [time_remaining] s

" - if(admin) - . += "(Cancel Vote) " + // If we got neither a path or an instance, it could be a vote name, but is likely just an error / null else - . += "

Start a vote:



" - . += "Close" - return . - - -/datum/controller/subsystem/vote/Topic(href,href_list[],hsrc) - if(!usr || !usr.client) - return //not necessary but meh...just in-case somebody does something stupid - - var/trialmin = FALSE - if(usr.client.holder) - if(check_rights_for(usr.client, R_ADMIN)) - trialmin = TRUE - - switch(href_list["vote"]) - if("close") - voting -= usr.client - usr << browse(null, "window=vote") - return + to_vote = possible_votes[vote_type] + if(!to_vote) + stack_trace("Voting initiate_vote was passed an invalid vote type. (Got: [vote_type || "null"])") + + // No valid vote found? No vote + if(!istype(to_vote)) + if(vote_initiator) + to_chat(vote_initiator, span_warning("Invalid voting choice.")) + return FALSE + + // Vote can't be initiated in our circumstances? No vote + if(!to_vote.can_be_initiated(vote_initiator, unlimited_vote_power)) + return FALSE + + // Okay, we're ready to actually create a vote - + // Do a reset, just to make sure + reset() + + // Try to create the vote. If the creation fails, no vote + if(!to_vote.create_vote(vote_initiator)) + return FALSE + + // Okay, the vote's happening now, for real. Set it up. + current_vote = to_vote + + var/duration = CONFIG_GET(number/vote_period) + var/to_display = current_vote.initiate_vote(vote_initiator_name, duration) + + log_vote(to_display) + to_chat(world, span_infoplain(vote_font("\n[span_bold(to_display)]\n\ + Type vote or click here to place your votes.\n\ + You have [DisplayTimeText(duration)] to vote."))) + + // And now that it's going, give everyone a voter action + for(var/client/new_voter as anything in GLOB.clients) + var/datum/action/vote/voting_action = new() + voting_action.name = "Vote: [current_vote.override_question || current_vote.name]" + voting_action.Grant(new_voter.mob) + + new_voter.player_details.player_actions += voting_action + generated_actions += voting_action + + if(current_vote.vote_sound && (new_voter.prefs.toggles & SOUND_ANNOUNCEMENTS)) + SEND_SOUND(new_voter, sound(current_vote.vote_sound)) + + return TRUE + +/datum/controller/subsystem/vote/ui_state() + return GLOB.always_state + +/datum/controller/subsystem/vote/ui_interact(mob/user, datum/tgui/ui) + // Tracks who is currently voting + voting |= user.client?.ckey + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "VotePanel") + ui.open() + +/datum/controller/subsystem/vote/ui_data(mob/user) + var/list/data = list() + + var/is_lower_admin = !!user.client?.holder + var/is_upper_admin = check_rights_for(user.client, R_ADMIN) + + data["user"] = list( + "ckey" = user.client?.ckey, + "isLowerAdmin" = is_lower_admin, + "isUpperAdmin" = is_upper_admin, + // What the current user has selected in any ongoing votes. + "singleSelection" = current_vote?.choices_by_ckey[user.client?.ckey], + "multiSelection" = current_vote?.choices_by_ckey, + ) + + data["voting"]= is_lower_admin ? voting : list() + + var/list/all_vote_data = list() + for(var/vote_name in possible_votes) + var/datum/vote/vote = possible_votes[vote_name] + if(!istype(vote)) + continue + + var/list/vote_data = list( + "name" = vote_name, + "canBeInitiated" = vote.can_be_initiated(forced = is_lower_admin), + "config" = vote.is_config_enabled(), + "message" = vote.message, + ) + + if(vote == current_vote) + var/list/choices = list() + for(var/key in current_vote.choices) + choices += list(list( + "name" = key, + "votes" = current_vote.choices[key], + )) + + data["currentVote"] = list( + "name" = current_vote.name, + "question" = current_vote.override_question, + "timeRemaining" = current_vote.time_remaining, + "countMethod" = current_vote.count_method, + "choices" = choices, + "vote" = vote_data, + ) + + all_vote_data += list(vote_data) + + data["possibleVotes"] = all_vote_data + + return data + +/datum/controller/subsystem/vote/ui_act(action, params) + . = ..() + if(.) + return + + var/mob/voter = usr + + switch(action) if("cancel") - if(usr.client.holder) - reset() - if("toggle_restart") - if(usr.client.holder && trialmin) - CONFIG_SET(flag/allow_vote_restart, !CONFIG_GET(flag/allow_vote_restart)) - if("toggle_gamemode") - if(usr.client.holder && trialmin) - CONFIG_SET(flag/allow_vote_mode, !CONFIG_GET(flag/allow_vote_mode)) - if("restart") - if(CONFIG_GET(flag/allow_vote_restart) || usr.client.holder) - initiate_vote("restart",usr.key, TRUE) - if("gamemode") - if(CONFIG_GET(flag/allow_vote_mode) || usr.client.holder) - initiate_vote("gamemode",usr.key, TRUE) - if("custom") - if(usr.client.holder) - initiate_vote("custom",usr.key, TRUE) - else - submit_vote(round(text2num(href_list["vote"]))) - usr.vote() - -/datum/controller/subsystem/vote/proc/remove_action_buttons() - for(var/v in generated_actions) - var/datum/action/vote/V = v - if(!QDELETED(V)) - V.remove_from_client() - V.Remove(V.owner) - generated_actions = list() + if(!voter.client?.holder) + return + voter.log_message("[key_name_admin(voter)] cancelled a vote.", LOG_ADMIN) + message_admins("[key_name_admin(voter)] has cancelled the current vote.") + reset() + return TRUE + + if("toggleVote") + var/datum/vote/selected = possible_votes[params["voteName"]] + if(!istype(selected)) + return + + return selected.toggle_votable(voter) + + if("callVote") + var/datum/vote/selected = possible_votes[params["voteName"]] + if(!istype(selected)) + return + + // Whether the user actually can initiate this vote is checked in initiate_vote, + // meaning you can't spoof initiate a vote you're not supposed to be able to + return initiate_vote(selected, voter.key, voter) + + if("voteSingle") + return submit_single_vote(voter, params["voteOption"]) + + if("voteMulti") + return submit_multi_vote(voter, params["voteOption"]) + +/datum/controller/subsystem/vote/ui_close(mob/user) + voting -= user.client?.ckey + +/// Mob level verb that allows players to vote on the current vote. /mob/verb/vote() set category = "OOC" set name = "Vote" - var/datum/browser/popup = new(src, "vote", "Voting Panel") - popup.set_window_options("can_close=0") - popup.set_content(SSvote.interface(client)) - popup.open(FALSE) + SSvote.ui_interact(usr) +/// Datum action given to mobs that allows players to vote on the current vote. /datum/action/vote name = "Vote!" button_icon_state = "vote" -/datum/action/vote/Trigger() - if(owner) - owner.vote() - remove_from_client() - Remove(owner) - /datum/action/vote/IsAvailable() - return TRUE + return TRUE // Democracy is always available to the free people -/datum/action/vote/proc/remove_from_client() - if(!owner) +/datum/action/vote/Trigger(trigger_flags) + . = ..() + if(!.) return - if(owner.client) - owner.client.player_details.player_actions -= src - else if(owner.ckey) - var/datum/player_details/P = GLOB.player_details[owner.ckey] - if(P) - P.player_actions -= src + + owner.vote() + Remove(owner) + +// We also need to remove our action from the player actions when we're cleaning up. +/datum/action/vote/Remove(mob/removed_from) + if(removed_from.client) + removed_from.client?.player_details.player_actions -= src + + else if(removed_from.ckey) + var/datum/player_details/associated_details = GLOB.player_details[removed_from.ckey] + associated_details?.player_actions -= src + + return ..() + +#undef vote_font diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm new file mode 100644 index 000000000000..c34d4600d2b0 --- /dev/null +++ b/code/datums/votes/_vote_datum.dm @@ -0,0 +1,250 @@ + +/** + * # Vote Singleton + * + * A singleton datum that represents a type of vote for the voting subsystem. + */ +/datum/vote + /// The name of the vote. + var/name + /// If supplied, an override question will be displayed instead of the name of the vote. + var/override_question + /// The sound effect played to everyone when this vote is initiated. + var/vote_sound = 'sound/misc/compiler-stage2.ogg' + /// A list of default choices we have for this vote. + var/list/default_choices + /// What message do we want to pass to the player-side vote panel as a tooltip? + var/message = "Click to initiate a vote." + + // Internal values used when tracking ongoing votes. + // Don't mess with these, change the above values / override procs for subtypes. + /// An assoc list of [all choices] to [number of votes in the current running vote]. + var/list/choices = list() + /// A assoc list of [ckey] to [what they voted for in the current running vote]. + var/list/choices_by_ckey = list() + /// The world time this vote was started. + var/started_time + /// The time remaining in this vote's run. + var/time_remaining + /// The counting method we use for votes. + var/count_method = VOTE_COUNT_METHOD_SINGLE + /// The method for selecting a winner. + var/winner_method = VOTE_WINNER_METHOD_SIMPLE + +/** + * Used to determine if this vote is a possible + * vote type for the vote subsystem. + * + * If FALSE is returned, this vote singleton + * will not be created when the vote subsystem initializes, + * meaning no one will be able to hold this vote. + */ +/datum/vote/proc/is_accessible_vote() + return !!length(default_choices) + +/** + * Resets our vote to its default state. + */ +/datum/vote/proc/reset() + SHOULD_CALL_PARENT(TRUE) + + choices.Cut() + choices_by_ckey.Cut() + started_time = null + time_remaining = null + +/** + * If this vote has a config associated, toggles it between enabled and disabled. + * Returns TRUE on a successful toggle, FALSE otherwise + */ +/datum/vote/proc/toggle_votable(mob/toggler) + return FALSE + +/** + * If this vote has a config associated, returns its value (True or False, usually). + * If it has no config, returns -1. + */ +/datum/vote/proc/is_config_enabled() + return -1 + +/** + * Checks if the passed mob can initiate this vote. + * + * Return TRUE if the mob can begin the vote, allowing anyone to actually vote on it. + * Return FALSE if the mob cannot initiate the vote. + */ +/datum/vote/proc/can_be_initiated(mob/by_who, forced = FALSE) + SHOULD_CALL_PARENT(TRUE) + + if(started_time) + var/next_allowed_time = (started_time + CONFIG_GET(number/vote_delay)) + if(next_allowed_time > world.time && !forced) + message = "A vote was initiated recently. You must wait [DisplayTimeText(next_allowed_time - world.time)] before a new vote can be started!" + return FALSE + + message = initial(message) + return TRUE + +/** + * Called prior to the vote being initiated. + * + * Return FALSE to prevent the vote from being initiated. + */ +/datum/vote/proc/create_vote(mob/vote_creator) + SHOULD_CALL_PARENT(TRUE) + + for(var/key in default_choices) + choices[key] = 0 + + return TRUE + +/** + * Called when this vote is actually initiated. + * + * Return a string - the text displayed to the world when the vote is initiated. + */ +/datum/vote/proc/initiate_vote(initiator, duration) + SHOULD_CALL_PARENT(TRUE) + + started_time = world.time + time_remaining = round(duration / 10) + + return "[capitalize(name)] vote started by [initiator || "Central Command"]." + +/** + * Gets the result of the vote. + * + * non_voters - a list of all ckeys who didn't vote in the vote. + * + * Returns a list of all options that won. + * If there were no votes at all, the list will be length = 0, non-null. + * If only one option one, the list will be length = 1. + * If there was a tie, the list will be length > 1. + */ +/datum/vote/proc/get_vote_result(list/non_voters) + RETURN_TYPE(/list) + SHOULD_CALL_PARENT(TRUE) + + switch(winner_method) + if(VOTE_WINNER_METHOD_NONE) + return list() + if(VOTE_WINNER_METHOD_SIMPLE) + return get_simple_winner() + if(VOTE_WINNER_METHOD_WEIGHTED_RANDOM) + return get_random_winner() + + stack_trace("invalid select winner method: [winner_method]. Defaulting to simple.") + return get_simple_winner() + +/// Gets the winner of the vote, selecting the choice with the most votes. +/datum/vote/proc/get_simple_winner() + var/highest_vote = 0 + var/list/current_winners = list() + + for(var/option in choices) + var/vote_count = choices[option] + if(vote_count < highest_vote) + continue + + if(vote_count > highest_vote) + highest_vote = vote_count + current_winners = list(option) + continue + current_winners += option + + return length(current_winners) ? current_winners : list() + +/// Gets the winner of the vote, selecting a random choice from all choices based on their vote count. +/datum/vote/proc/get_random_winner() + var/winner = pickweight(choices) + return winner ? list(winner) : list() + +/** + * Gets the resulting text displayed when the vote is completed. + * + * all_winners - list of all options that won. Can be multiple, in the event of ties. + * real_winner - the option that actually won. + * non_voters - a list of all ckeys who didn't vote in the vote. + * + * Return a formatted string of text to be displayed to everyone. + */ +/datum/vote/proc/get_result_text(list/all_winners, real_winner, list/non_voters) + var/returned_text = "" + if(override_question) + returned_text += span_bold(override_question) + else + returned_text += span_bold("[capitalize(name)] Vote") + + returned_text += "\nWinner Selection: " + switch(winner_method) + if(VOTE_WINNER_METHOD_NONE) + returned_text += "None" + if(VOTE_WINNER_METHOD_WEIGHTED_RANDOM) + returned_text += "Weighted Random" + else + returned_text += "Simple" + + var/total_votes = 0 // for determining percentage of votes + for(var/option in choices) + total_votes += choices[option] + + if(total_votes <= 0) + return span_bold("Vote Result: Inconclusive - No Votes!") + + returned_text += "\nResults:" + for(var/option in choices) + returned_text += "\n" + var/votes = choices[option] + var/percentage_text = "" + if(votes > 0) + var/actual_percentage = round((votes / total_votes) * 100, 0.1) + var/text = "[actual_percentage]" + var/spaces_needed = 5 - length(text) + for(var/_ in 1 to spaces_needed) + returned_text += " " + percentage_text += "[text]%" + else + percentage_text = " 0%" + returned_text += "[percentage_text] | [span_bold(option)]: [choices[option]]" + + returned_text += "\n [span_bold("Non-Voters")]: [length(non_voters)]" + + if(!real_winner) // vote has no winner or cannot be won, but still had votes + return returned_text + + returned_text += "\n" + returned_text += get_winner_text(all_winners, real_winner, non_voters) + + return returned_text + +/** + * Gets the text that displays the winning options within the result text. + * + * all_winners - list of all options that won. Can be multiple, in the event of ties. + * real_winner - the option that actually won. + * non_voters - a list of all ckeys who didn't vote in the vote. + * + * Return a formatted string of text to be displayed to everyone. + */ +/datum/vote/proc/get_winner_text(list/all_winners, real_winner, list/non_voters) + var/returned_text = "" + if(length(all_winners) > 1) + returned_text += "\n[span_bold("Vote Tied Between:")]" + for(var/a_winner in all_winners) + returned_text += "\n\t[a_winner]" + + returned_text += span_bold("\nVote Result: [real_winner]") + return returned_text + +/** + * How this vote handles a tiebreaker between multiple winners. + */ +/datum/vote/proc/tiebreaker(list/winners) + return pick(winners) + +/** + * Called when a vote is actually all said and done. + * Apply actual vote effects here. + */ +/datum/vote/proc/finalize_vote(winning_option) + return diff --git a/code/datums/votes/custom_vote.dm b/code/datums/votes/custom_vote.dm new file mode 100644 index 000000000000..4dbc984759c8 --- /dev/null +++ b/code/datums/votes/custom_vote.dm @@ -0,0 +1,75 @@ +/// The max amount of options someone can have in a custom vote. +#define MAX_CUSTOM_VOTE_OPTIONS 10 + +/datum/vote/custom_vote/single + name = "Custom Standard" + message = "Click here to start a custom vote (one selection per voter)" + +/datum/vote/custom_vote/multi + name = "Custom Multi" + message = "Click here to start a custom multi vote (multiple selections per voter)" + count_method = VOTE_COUNT_METHOD_MULTI + +// Custom votes ares always accessible. +/datum/vote/custom_vote/is_accessible_vote() + return TRUE + +/datum/vote/custom_vote/reset() + default_choices = null + override_question = null + return ..() + +/datum/vote/custom_vote/can_be_initiated(mob/by_who, forced = FALSE) + . = ..() + if(!.) + return FALSE + + // Custom votes can only be created if they're forced to be made. + // (Either an admin makes it, or otherwise.) + return forced + +/datum/vote/custom_vote/create_vote(mob/vote_creator) + var/custom_win_method = tgui_input_list( + vote_creator, + "How should the vote winner be determined?", + "Winner Method", + list("Simple", "Weighted Random", "No Winner"), + ) + if(!custom_win_method) + custom_win_method = "Simple" + switch(custom_win_method) + if("Simple") + winner_method = VOTE_WINNER_METHOD_SIMPLE + if("Weighted Random") + winner_method = VOTE_WINNER_METHOD_WEIGHTED_RANDOM + if("No Winner") + winner_method = VOTE_WINNER_METHOD_NONE + else + to_chat(vote_creator, span_boldwarning("Unknown winner method. Contact a coder.")) + return FALSE + + override_question = input(vote_creator, "What is the vote for?", "Custom Vote") as text|null + if(!override_question) + return FALSE + + default_choices = list() + for(var/i in 1 to MAX_CUSTOM_VOTE_OPTIONS) + var/option = input(vote_creator, "Please enter an option, or hit cancel to finish. [MAX_CUSTOM_VOTE_OPTIONS] max.", "Options") as text|null + option = copytext(option, 1, MAX_NAME_LEN) + if(!vote_creator?.client) + return FALSE + if(!option) + break + + default_choices += capitalize(option) + + if(!length(default_choices)) + return FALSE + + return ..() + +/datum/vote/custom_vote/initiate_vote(initiator, duration) + . = ..() + . += "\n[override_question]" + +#undef MAX_CUSTOM_VOTE_OPTIONS diff --git a/code/datums/votes/restart_vote.dm b/code/datums/votes/restart_vote.dm new file mode 100644 index 000000000000..24d38f35396d --- /dev/null +++ b/code/datums/votes/restart_vote.dm @@ -0,0 +1,76 @@ +#define CHOICE_RESTART "Restart Round" +#define CHOICE_CONTINUE "Continue Playing" + +/datum/vote/restart_vote + name = "Restart" + default_choices = list( + CHOICE_RESTART, + CHOICE_CONTINUE, + ) + message = "Vote to restart the ongoing round." + +/// This proc checks to see if any admins are online for the purposes of this vote to see if it can pass. Returns TRUE if there are valid admins online (Has +SERVER and is not AFK), FALSE otherwise. +/datum/vote/restart_vote/proc/admins_present() + for(var/client/online_admin as anything in GLOB.admins) + if(online_admin.is_afk() || !check_rights_for(online_admin, R_SERVER)) + continue + + return TRUE + + return FALSE + +/datum/vote/restart_vote/toggle_votable(mob/toggler) + if(!toggler) + CRASH("[type] wasn't passed a \"toggler\" mob to toggle_votable.") + + if(!check_rights_for(toggler.client, R_ADMIN)) + return FALSE + + CONFIG_SET(flag/allow_vote_restart, !CONFIG_GET(flag/allow_vote_restart)) + return TRUE + +/datum/vote/restart_vote/is_config_enabled() + return CONFIG_GET(flag/allow_vote_restart) + +/datum/vote/restart_vote/can_be_initiated(mob/by_who, forced) + . = ..() + if(!.) + return FALSE + + if(!forced && !CONFIG_GET(flag/allow_vote_restart)) + message = "Restart voting is disabled by server configuration settings." + return FALSE + + // We still want players to be able to vote to restart even if valid admins are online. Let's update the message just so that the player is aware of this fact. + // We don't want to lock-out the vote though, so we'll return TRUE. + if(admins_present()) + message = "Regardless of the results of this vote, the round will not automatically restart because an admin is online." + return TRUE + + message = initial(message) + return TRUE + +/datum/vote/restart_vote/get_vote_result(list/non_voters) + if(!CONFIG_GET(flag/default_no_vote)) + // Default no votes will add non-voters to "Continue Playing" + choices[CHOICE_CONTINUE] += length(non_voters) + + return ..() + +/datum/vote/restart_vote/finalize_vote(winning_option) + if(winning_option == CHOICE_CONTINUE) + return + + if(winning_option == CHOICE_RESTART) + if(admins_present()) + to_chat(world, span_boldannounce("Notice: A restart vote will not restart the server automatically because there are active admins on.")) + message_admins("A restart vote has passed, but there are active admins on with +SERVER, so it has been canceled. If you wish, you may restart the server.") + return + + SSticker.Reboot("Restart vote successful.", "restart vote", 1) + return + + CRASH("[type] wasn't passed a valid winning choice. (Got: [winning_option || "null"])") + +#undef CHOICE_RESTART +#undef CHOICE_CONTINUE diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm new file mode 100644 index 000000000000..650c8266f872 --- /dev/null +++ b/code/datums/votes/transfer_vote.dm @@ -0,0 +1,66 @@ +#define CHOICE_TRANSFER "Initiate Bluespace Jump" +#define CHOICE_CONTINUE "Continue Playing" + +/// The fraction of non-voters that will be added to the transfer option when the vote is finalized. +#define TRANSFER_FACTOR clamp((world.time / (1 MINUTES) - 120) / 240, 0, 1) + +/datum/vote/transfer_vote + name = "Transfer" + default_choices = list( + CHOICE_TRANSFER, + CHOICE_CONTINUE, + ) + +/datum/vote/transfer_vote/toggle_votable(mob/toggler) + if(!toggler) + CRASH("[type] wasn't passed a \"toggler\" mob to toggle_votable.") + if(!check_rights_for(toggler.client, R_ADMIN)) + return FALSE + + CONFIG_SET(flag/allow_vote_transfer, !CONFIG_GET(flag/allow_vote_transfer)) + return TRUE + +/datum/vote/transfer_vote/is_config_enabled() + return CONFIG_GET(flag/allow_vote_transfer) + +/datum/vote/transfer_vote/can_be_initiated(mob/by_who, forced) + . = ..() + if(!.) + return FALSE + + if(SSshuttle.jump_mode != BS_JUMP_IDLE) + return FALSE + + if(!forced && !CONFIG_GET(flag/allow_vote_transfer)) + if(by_who) + to_chat(by_who, span_warning("Transfer voting is disabled.")) + return FALSE + + return TRUE + +/datum/vote/transfer_vote/get_vote_result(list/non_voters) + choices[CHOICE_TRANSFER] += round(length(non_voters) * TRANSFER_FACTOR) + + return ..() + +/datum/vote/transfer_vote/get_winner_text(list/all_winners, real_winner, list/non_voters) + . = ..() + var/boost = round(length(non_voters) * TRANSFER_FACTOR) + if(boost) + . += "\n" + . += span_bold("Transfer option was boosted by [boost] non-voters ([round(TRANSFER_FACTOR * 100, 0.1)]%) due to round length.") + +/datum/vote/transfer_vote/finalize_vote(winning_option) + if(winning_option == CHOICE_CONTINUE) + return + + if(winning_option == CHOICE_TRANSFER) + SSshuttle.request_jump() + return + + CRASH("[type] wasn't passed a valid winning choice. (Got: [winning_option || "null"])") + +#undef TRANSFER_FACTOR + +#undef CHOICE_TRANSFER +#undef CHOICE_CONTINUE diff --git a/code/game/world.dm b/code/game/world.dm index a9881d19d977..aae1ea9d6307 100644 --- a/code/game/world.dm +++ b/code/game/world.dm @@ -304,10 +304,6 @@ GLOBAL_VAR(restart_counter) if (server_name) s += "[server_name] — " features += "[CONFIG_GET(flag/norespawn) ? "no " : ""]respawn" - if(CONFIG_GET(flag/allow_vote_mode)) - features += "vote" - if(CONFIG_GET(flag/allow_ai)) - features += "AI allowed" hostedby = CONFIG_GET(string/hostedby) var/discord_url diff --git a/config/config.txt b/config/config.txt index 7a579123028b..7a348b2c9cc0 100644 --- a/config/config.txt +++ b/config/config.txt @@ -186,11 +186,8 @@ ID_CONSOLE_JOBSLOT_DELAY 30 ## allow players to initiate a restart vote #ALLOW_VOTE_RESTART -## allow players to initiate a mode-change vote -#ALLOW_VOTE_MODE - -## allow players to initiate a map-change vote -#ALLOW_VOTE_MAP +## allow players to initiate a transfer vote +#ALLOW_VOTE_TRANSFER ## min delay (deciseconds) between voting sessions (default 10 minutes) VOTE_DELAY 6000 diff --git a/shiptest.dme b/shiptest.dme index 716dde409b8c..f9f878426156 100644 --- a/shiptest.dme +++ b/shiptest.dme @@ -742,7 +742,11 @@ #include "code\datums\traits\good.dm" #include "code\datums\traits\negative.dm" #include "code\datums\traits\neutral.dm" +#include "code\datums\votes\_vote_datum.dm" +#include "code\datums\votes\custom_vote.dm" +#include "code\datums\votes\restart_vote.dm" #include "code\datums\weather\weather.dm" +#include "code\datums\votes\transfer_vote.dm" #include "code\datums\weather\weather_controller.dm" #include "code\datums\weather\weather_types\acid_rain.dm" #include "code\datums\weather\weather_types\ash_storm.dm" diff --git a/tgui/packages/tgui/interfaces/VotePanel.tsx b/tgui/packages/tgui/interfaces/VotePanel.tsx new file mode 100644 index 000000000000..eda355382768 --- /dev/null +++ b/tgui/packages/tgui/interfaces/VotePanel.tsx @@ -0,0 +1,291 @@ +import { BooleanLike } from 'common/react'; +import { + Box, + Icon, + Stack, + Button, + Section, + NoticeBox, + LabeledList, + Collapsible, +} from '../components'; +import { Window } from '../layouts'; +import { useBackend } from '../backend'; + +enum VoteConfig { + None = -1, + Disabled = 0, + Enabled = 1, +} + +type Vote = { + name: string; + canBeInitiated: BooleanLike; + config: VoteConfig; + message: string; +}; + +type Option = { + name: string; + votes: number; +}; + +type ActiveVote = { + vote: Vote; + question: string | null; + timeRemaining: number; + choices: Option[]; + countMethod: number; +}; + +type UserData = { + ckey: string; + isLowerAdmin: BooleanLike; + isUpperAdmin: BooleanLike; + singleSelection: string | null; + multiSelection: string[] | null; + countMethod: VoteSystem; +}; + +enum VoteSystem { + VOTE_SINGLE = 1, + VOTE_MULTI = 2, +} + +type Data = { + currentVote: ActiveVote; + possibleVotes: Vote[]; + user: UserData; + voting: string[]; +}; + +export const VotePanel = (props, context) => { + const { data } = useBackend(context); + const { currentVote, user } = data; + + /** + * Adds the voting type to title if there is an ongoing vote. + */ + let windowTitle = 'Vote'; + if (currentVote) { + windowTitle += + ': ' + + (currentVote.question || currentVote.vote.name).replace(/^\w/, (c) => + c.toUpperCase() + ); + } + + return ( + + + +
+ + {!!user.isLowerAdmin && currentVote && } +
+ + +
+
+
+ ); +}; + +/** + * The create vote options menu. Only upper admins can disable voting. + * @returns A section visible to everyone with vote options. + */ +const VoteOptions = (props, context) => { + const { act, data } = useBackend(context); + const { possibleVotes, user } = data; + + return ( + + + + {possibleVotes.map((option) => ( + + {!!user.isLowerAdmin && option.config !== VoteConfig.None && ( + + act('toggleVote', { + voteName: option.name, + }) + } + /> + )} + + } + > + {user.singleSelection && + choice.name === user.singleSelection && ( + + )} + {choice.votes} Votes + + + + ))} + + ) : null} + {currentVote && currentVote.countMethod === VoteSystem.VOTE_MULTI ? ( + Select any number of options + ) : null} + {currentVote && + currentVote.choices.length !== 0 && + currentVote.countMethod === VoteSystem.VOTE_MULTI ? ( + + {currentVote.choices.map((choice) => ( + + c.toUpperCase())} + textAlign="right" + buttons={ + + } + > + {user.multiSelection && + user.multiSelection[user.ckey.concat(choice.name)] === 1 ? ( + + ) : null} + {choice.votes} Votes + + + + ))} + + ) : null} + {currentVote ? null : No vote active!} + + + ); +}; + +/** + * Countdown timer at the bottom. Includes a cancel vote option for admins. + * @returns A section visible to everyone. + */ +const TimePanel = (props, context) => { + const { act, data } = useBackend(context); + const { currentVote, user } = data; + + return ( + +
+ + + Time Remaining:  + {currentVote?.timeRemaining || 0}s + + {!!user.isLowerAdmin && ( + + )} + +
+
+ ); +};