diff --git a/.github/workflows/byond.yml b/.github/workflows/byond.yml index ea641188e33..b39f676ab72 100644 --- a/.github/workflows/byond.yml +++ b/.github/workflows/byond.yml @@ -15,7 +15,7 @@ on: env: MACRO_COUNT: 0 GENDER_COUNT: 6 - TO_WORLD_COUNT: 181 + TO_WORLD_COUNT: 179 #These variables are filled from dependencies.sh inside the steps, DO NOT SET THEM HERE BYOND_MAJOR: "" diff --git a/aurorastation.dme b/aurorastation.dme index aebcb16fcba..1e9a4f6f566 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -338,7 +338,6 @@ #include "code\controllers\subsystems\evacuation\evacuation_pods.dm" #include "code\controllers\subsystems\evacuation\evacuation_predicate.dm" #include "code\controllers\subsystems\evacuation\evacuation_shuttle.dm" -#include "code\controllers\subsystems\evacuation\~evac.dm" #include "code\controllers\subsystems\initialization\atlas.dm" #include "code\controllers\subsystems\initialization\atoms.dm" #include "code\controllers\subsystems\initialization\codex.dm" @@ -501,6 +500,11 @@ #include "code\datums\uplink\telecrystals.dm" #include "code\datums\uplink\uplink_categories.dm" #include "code\datums\uplink\uplink_items.dm" +#include "code\datums\votes\_vote_datum.dm" +#include "code\datums\votes\crew_transfer.dm" +#include "code\datums\votes\custom_vote.dm" +#include "code\datums\votes\restart.dm" +#include "code\datums\votes\round_type.dm" #include "code\datums\wires\airlock.dm" #include "code\datums\wires\alarm.dm" #include "code\datums\wires\apc.dm" diff --git a/code/__DEFINES/logging.dm b/code/__DEFINES/logging.dm index 81c151856e8..e446d9216b6 100644 --- a/code/__DEFINES/logging.dm +++ b/code/__DEFINES/logging.dm @@ -13,6 +13,7 @@ //wrapper macros for easier grepping #define DIRECT_OUTPUT(A, B) A << B +#define SEND_SOUND(target, sound) DIRECT_OUTPUT(target, sound) #define SEND_TEXT(target, text) DIRECT_OUTPUT(target, text) #define WRITE_FILE(file, text) DIRECT_OUTPUT(file, text) diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 584273f21f7..210b5d826b7 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -271,3 +271,16 @@ #define SS_NO_DISPLAY 128 #define SS_IS_RUNNING(subsystem) (subsystem.can_fire && (GAME_STATE & subsystem.runlevels)) + +// 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/_lists.dm b/code/__HELPERS/_lists.dm index 4190b8b9cea..92b3c7c8035 100644 --- a/code/__HELPERS/_lists.dm +++ b/code/__HELPERS/_lists.dm @@ -228,6 +228,32 @@ };\ } while(FALSE) +/** + * Picks a random element from a list based on a weighting system. + * For example, given the following list: + * A = 6, B = 3, C = 1, D = 0 + * A would have a 60% chance of being picked, + * B would have a 30% chance of being picked, + * C would have a 10% chance of being picked, + * and D would have a 0% chance of being picked. + * You should only pass integers in. + */ +/proc/pick_weight(list/list_to_pick) + var/total = 0 + var/item + for(item in list_to_pick) + if(!list_to_pick[item]) + list_to_pick[item] = 0 + total += list_to_pick[item] + + total = rand(1, total) + for(item in list_to_pick) + total -= list_to_pick[item] + if(total <= 0 && list_to_pick[item]) + return item + + return null + ///replaces reverseList ~Carnie /proc/reverse_range(list/inserted_list, start = 1, end = 0) if(inserted_list.len) diff --git a/code/_onclick/hud/action.dm b/code/_onclick/hud/action.dm index 2c7a2b09f53..9e760cefd0b 100644 --- a/code/_onclick/hud/action.dm +++ b/code/_onclick/hud/action.dm @@ -44,11 +44,17 @@ return Remove(owner) owner = T - owner.actions.Add(src) - owner.update_action_buttons() + //This shit is because our actions are different than TG ones, remove it when we update the action datum + if(istype(T)) + owner.actions.Add(src) + owner.update_action_buttons() return /datum/action/proc/Remove(mob/living/T) + //This shit is because our actions are different than TG ones, remove it when we update the action datum + if(!istype(T)) + return + if(button) if(T.client) T.client.screen -= button diff --git a/code/controllers/subsystems/evacuation/~evac.dm b/code/controllers/subsystems/evacuation/~evac.dm deleted file mode 100644 index e49dec2b309..00000000000 --- a/code/controllers/subsystems/evacuation/~evac.dm +++ /dev/null @@ -1,5 +0,0 @@ -#undef EVAC_IDLE -#undef EVAC_PREPPING -#undef EVAC_IN_TRANSIT -#undef EVAC_COOLDOWN -#undef EVAC_COMPLETE diff --git a/code/controllers/subsystems/statpanel.dm b/code/controllers/subsystems/statpanel.dm index 49d9827488c..71a69917ad8 100644 --- a/code/controllers/subsystems/statpanel.dm +++ b/code/controllers/subsystems/statpanel.dm @@ -35,7 +35,7 @@ SUBSYSTEM_DEF(statpanels) "Round Time: [get_round_duration_formatted()]", "Ship Time: [worldtime2text()]", "Current Space Sector: [SSatlas.current_sector.name]", - "Last Transfer Vote: [SSvote.last_transfer_vote ? time2text(SSvote.last_transfer_vote, "hh:mm") : "Never"]" + "Last Transfer Vote: [GLOB.last_transfer_vote ? time2text(GLOB.last_transfer_vote, "hh:mm") : "Never"]" ) if(eta_status) global_data += eta_status diff --git a/code/controllers/subsystems/ticker.dm b/code/controllers/subsystems/ticker.dm index 74966e91c06..fc2be4f3543 100644 --- a/code/controllers/subsystems/ticker.dm +++ b/code/controllers/subsystems/ticker.dm @@ -4,6 +4,9 @@ #define SETUP_REVOTE 1 #define SETUP_REATTEMPT 2 +///The time at which the next automatic transfer vote will be called +GLOBAL_VAR_INIT(next_transfer_time, null) + var/datum/controller/subsystem/ticker/SSticker /datum/controller/subsystem/ticker @@ -73,6 +76,9 @@ var/datum/controller/subsystem/ticker/SSticker pregame() restart_timeout = GLOB.config.restart_timeout + //Initialize the auto-transfer time + GLOB.next_transfer_time = GLOB.config.vote_autotransfer_initial + return SS_INIT_SUCCESS /datum/controller/subsystem/ticker/stat_entry(msg) @@ -137,10 +143,9 @@ var/datum/controller/subsystem/ticker/SSticker total_players = length(GLOB.player_list) if (current_state == GAME_STATE_PREGAME && pregame_timeleft == GLOB.config.vote_autogamemode_timeleft) - if (!SSvote.time_remaining) - SSvote.autogamemode() - pregame_timeleft-- - return + SSvote.autogamemode() + pregame_timeleft-- + return if (pregame_timeleft <= 20 && !testmerges_printed) print_testmerges() @@ -226,6 +231,13 @@ var/datum/controller/subsystem/ticker/SSticker to_world("An admin has delayed the round end") else if(!delay_notified) to_world("An admin has delayed the round end") + + //If we have not finished the game already, and assuming it's time, call the transfer vote as per config + if(!game_finished && !mode_finished && !post_game) + if(get_round_duration() >= GLOB.next_transfer_time - 600) + SSvote.autotransfer() + GLOB.next_transfer_time += GLOB.config.vote_autotransfer_interval + return 1 /datum/controller/subsystem/ticker/proc/declare_completion() @@ -528,12 +540,6 @@ var/datum/controller/subsystem/ticker/SSticker var/can_start = src.mode.can_start() if(can_start & GAME_FAILURE_NO_PLAYERS) - var/list/voted_not_ready = list() - for(var/mob/abstract/new_player/player in SSvote.round_voters) - if((player.client)&&(!player.ready)) - voted_not_ready += player.ckey - message_admins("The following players voted for [mode.name], but did not ready up: [jointext(voted_not_ready, ", ")]") - log_game("Ticker: Players voted for [mode.name], but did not ready up: [jointext(voted_not_ready, ", ")]") fail_reasons += "Not enough players, [mode.required_players] player(s) needed" if(can_start & GAME_FAILURE_NO_ANTAGS) diff --git a/code/controllers/subsystems/vote.dm b/code/controllers/subsystems/vote.dm index 321b0828285..008da629c37 100644 --- a/code/controllers/subsystems/vote.dm +++ b/code/controllers/subsystems/vote.dm @@ -1,450 +1,405 @@ +/// Define to mimic a span macro but for the purple font that vote specifically uses. +#define vote_font(text) ("" + text + "") + SUBSYSTEM_DEF(vote) - name = "Voting" - wait = 1 SECOND + name = "Vote" + wait = 1 SECONDS flags = SS_KEEP_TIMING init_order = INIT_ORDER_VOTE runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT - var/next_transfer_time - - 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() - var/list/current_votes = list() - var/auto_muted = 0 - var/last_transfer_vote = null + /// A list of all ckeys currently voting for the current vote. + var/list/voting = list() - var/list/round_voters = list() +/datum/controller/subsystem/vote/Initialize() + for(var/vote_type in subtypesof(/datum/vote)) + var/datum/vote/vote = new vote_type() + if(!vote.is_accessible_vote()) + qdel(vote) + continue -/datum/controller/subsystem/vote/Initialize(timeofday) - next_transfer_time = GLOB.config.vote_autotransfer_initial - return SS_INIT_SUCCESS - -/datum/controller/subsystem/vote/fire(resumed = FALSE) - if (mode) - // No more change mode votes after the game has started. - if(mode == "gamemode" && ROUND_IS_STARTED) - to_world("Voting aborted due to game start.") - reset() - return - - if(((started_time + GLOB.config.vote_period - world.time)) < 0) - result() - SStgui.close_uis(src) - reset() - - if (get_round_duration() >= next_transfer_time - 600) - autotransfer() - next_transfer_time += GLOB.config.vote_autotransfer_interval + possible_votes[vote.name] = vote return SS_INIT_SUCCESS -/datum/controller/subsystem/vote/proc/autotransfer() - initiate_vote("crew_transfer","the server", 1) - LOG_DEBUG("The server has called a crew transfer vote") -/datum/controller/subsystem/vote/proc/autogamemode() - for(var/thing in GLOB.clients) - var/client/C = thing - window_flash(C) - initiate_vote("gamemode","the server", 1) - LOG_DEBUG("The server has called a gamemode vote") +// Called by master_controller +/datum/controller/subsystem/vote/fire() + if(!current_vote) + return + current_vote.time_remaining = round((current_vote.started_time + 600 - world.time) / 10) //Used to be "CONFIG_GET(number/vote_period)" instead of 600 + 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() - current_votes.Cut() + voting.Cut() + + current_vote?.reset() + current_vote = null + + QDEL_LIST(generated_actions) + SStgui.update_uis(src) -/datum/controller/subsystem/vote/proc/get_result() - //get the highest number of votes - var/greatest_votes = 0 +/** + * 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(!istype(non_voter_client) || non_voter_client.is_afk()) + 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/total_votes = 0 - for(var/option in choices) - var/votes = choices[option]["votes"] - total_votes += votes - if(votes > greatest_votes) - greatest_votes = votes - //default-vote for everyone who didn't vote - if(!GLOB.config.vote_no_default && choices.len) - var/non_voters = (GLOB.clients.len - total_votes) - if(non_voters > 0) - if(mode == "restart") - choices["Continue Playing"]["votes"] += non_voters - if(choices["Continue Playing"]["votes"] >= greatest_votes) - greatest_votes = choices["Continue Playing"]["votes"] - else if(mode == "gamemode") - if(GLOB.master_mode in choices) - choices[GLOB.master_mode]["votes"] += non_voters - if(choices[GLOB.master_mode]["votes"] >= greatest_votes) - greatest_votes = choices[GLOB.master_mode]["votes"] - else if(mode == "crew_transfer") - var/factor = 0.5 - switch(get_round_duration() / (10 * 60)) // 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 Crew Transfer"]["votes"] = round(choices["Initiate Crew Transfer"]["votes"] * factor) - to_world("Crew Transfer Factor: [factor]") - greatest_votes = max(choices["Initiate Crew Transfer"]["votes"], choices["Continue The Round"]["votes"]) + var/list/vote_choice_data = list() + for(var/choice in current_vote.choices) + var/choice_votes = current_vote.choices[choice] + total_votes += choice_votes + vote_choice_data["[choice]"] = choice_votes - if(mode == "crew_transfer") - if(round(get_round_duration() / 36000)+12 <= 14) - // Credit to Scopes @ oldcode. - to_world("Majority voting rule in effect. 2/3rds majority needed to initiate transfer.") - choices["Initiate Crew Transfer"]["votes"] = round(choices["Initiate Crew Transfer"]["votes"] - round(total_votes / 3)) - greatest_votes = max(choices["Initiate Crew Transfer"]["votes"], choices["Continue The Round"]["votes"]) + // stringify the winners to prevent potential unimplemented serialization errors. + // Perhaps this can be removed in the future and we assert that vote choices must implement serialization. + var/final_winner_string = final_winner && "[final_winner]" + var/list/winners_string = list() + for(var/winner in winners) + winners_string += "[winner]" - //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]["votes"] == greatest_votes) - . += option + var/list/vote_log_data = list( + "choices" = vote_choice_data, + "total" = total_votes, + "winners" = winners_string, + "final_winner" = final_winner_string, + ) + var/log_string = replacetext(to_display, "\n", "\\n") // 'keep' the newlines, but dont actually print them as newlines + log_vote(log_string, vote_log_data) + to_chat(world, SPAN_INFO(vote_font("\n[to_display]"))) -/datum/controller/subsystem/vote/proc/announce_result() - var/list/winners = get_result() - var/text - if(winners.len > 0) - if(winners.len > 1) - if(mode != "gamemode" || SSticker.hide_mode == 0) // Here we are making sure we don't announce potential game modes - text = "Vote Tied Between:\n" - for(var/option in winners) - text += "\t[option]\n" - . = pick(winners) + // 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) - for(var/key in current_votes) - if(current_votes[key] == .) - round_voters += key // Keep track of who voted for the winning round. +/** + * 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(FALSE && voter.stat == DEAD && !voter.client?.holder) //Used to be "CONFIG_GET(flag/no_dead_vote)" + return - if((mode == "gamemode" && . == "Extended") || SSticker.hide_mode == 0) // Announce Extended gamemode, but not other gamemodes - text += "Vote Result: [.]" - else - if(mode != "gamemode") - text += "Vote Result: [.]" - else - text += "The vote has ended." // What will be shown if it is a gamemode vote that isn't extended + // 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!" - if(mode == "add_antagonist") - antag_add_failed = 1 - log_vote(text) - to_world("[text]") + voted += voter.ckey -/datum/controller/subsystem/vote/proc/result() - . = announce_result() - var/restart = 0 - if(.) - switch(mode) - if("restart") - if(. == "Restart Round") - restart = 1 - if("gamemode") - if(GLOB.master_mode != .) - SSpersistent_configuration.last_gamemode = . - if(SSticker.mode) - restart = 1 - else - GLOB.master_mode = . - if("crew_transfer") - if(. == "Initiate Crew Transfer") - init_shift_change(null, 1) - last_transfer_vote = get_round_duration() - if("add_antagonist") - if(isnull(.) || . == "None") - antag_add_failed = 1 - else - GLOB.additional_antag_types |= GLOB.antag_names_to_ids[.] + current_vote.choices_by_ckey[voter.ckey] = their_vote + current_vote.choices[their_vote]++ - if(mode == "gamemode") //fire this even if the vote fails. - if(!GLOB.round_progressing) - GLOB.round_progressing = 1 - to_world("The round will start soon.") + return TRUE - if(restart) - to_world("World restarting due to vote...") - feedback_set_details("end_error","restart vote") - sleep(50) - log_game("Rebooting due to restart vote") - world.Reboot() +/** + * 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(FALSE && voter.stat == DEAD && !voter.client?.holder) //Used to be "CONFIG_GET(flag/no_dead_vote)" + return -/datum/controller/subsystem/vote/proc/submit_vote(ckey, vote) - if(mode) - if (mode == "crew_transfer") - if(GLOB.config.vote_no_dead && usr && !usr.client.holder) - if (isnewplayer(usr)) - to_chat(usr, "You must be playing or have been playing to start a vote.") - return 0 - else if (isobserver(usr)) - var/mob/abstract/observer/O = usr - if (O.started_as_observer) - to_chat(usr, "You must be playing or have been playing to start a vote.") - return 0 - if(vote in choices) - if(current_votes[ckey]) - choices[current_votes[ckey]]["votes"]-- - var/vote_descriptor = "" - if(isnewplayer(usr)) - vote_descriptor = ", from the lobby" - else if(isobserver(usr)) - vote_descriptor = ", as an observer" - log_vote("[ckey] submitted their vote for: [vote][vote_descriptor]") - voted += usr.ckey - choices[vote]["votes"]++ //check this - current_votes[ckey] = vote - return 1 - return 0 + 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]-- -/datum/controller/subsystem/vote/proc/initiate_vote(vote_type, initiator_key, automatic = FALSE) - if(!mode) - if(started_time != null && !(check_rights(R_ADMIN|R_MOD) || automatic)) - // Transfer votes are their own little special snowflake - var/next_allowed_time = 0 - if (vote_type == "crew_transfer") - if (GLOB.config.vote_no_dead && !usr.client.holder) - if (isnewplayer(usr)) - to_chat(usr, "You must be playing or have been playing to start a vote.") - return 0 - else if (isobserver(usr)) - var/mob/abstract/observer/O = usr - if (O.started_as_observer) - to_chat(usr, "You must be playing or have been playing to start a vote.") - return 0 + else + current_vote.choices_by_ckey[voter.ckey + their_vote] = 1 + current_vote.choices[their_vote]++ - if (last_transfer_vote) - next_allowed_time = (last_transfer_vote + GLOB.config.vote_delay) - else - next_allowed_time = GLOB.config.transfer_timeout - else - next_allowed_time = (started_time + GLOB.config.vote_delay) + return TRUE - if(next_allowed_time > get_round_duration()) - return 0 +/** + * 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) - //reset() - switch(vote_type) - if("restart") - AddChoice("Restart Round") - AddChoice("Continue Playing") - if("gamemode") - round_voters.Cut() //Delete the old list, since we are having a new gamemode vote - if(SSticker.current_state >= 2) - return 0 - for (var/F in GLOB.config.votable_modes) - var/datum/game_mode/M = GLOB.gamemode_cache[F] - if(!M) - continue - AddChoice(F, capitalize(M.name), "", M.required_players) - AddChoice(ROUNDTYPE_STR_SECRET, "Secret") - if(ROUNDTYPE_STR_MIXED_SECRET in choices) - AddChoice(ROUNDTYPE_STR_MIXED_SECRET, "Mixed Secret") - if("crew_transfer") - if(check_rights(R_ADMIN|R_MOD, 0)) - question = "End the shift?" - AddChoice("Initiate Crew Transfer") - AddChoice("Continue The Round") - else - if (get_security_level() == "red" || get_security_level() == "delta") - to_chat(initiator_key, "The current alert status is too high to call for a crew transfer!") - return 0 - if(SSticker.current_state <= 2) - to_chat(initiator_key, "The crew transfer button has been disabled!") - return 0 - question = "End the shift?" - AddChoice("Initiate Crew Transfer") - AddChoice("Continue The Round") - if("add_antagonist") - if(!GLOB.config.allow_extra_antags || SSticker.current_state >= 2) - return 0 - for(var/antag_type in GLOB.all_antag_types) - var/datum/antagonist/antag = GLOB.all_antag_types[antag_type] - if(!(antag.id in GLOB.additional_antag_types) && antag.is_votable()) - AddChoice(antag.role_text) - AddChoice("None") - if("custom") - question = tgui_input_text(usr, "What is the vote for?") - if(!question) - return 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 - for(var/i=1,i<=10,i++) - var/option = capitalize(sanitize(input(usr,"Please enter an option or hit cancel to finish") as text|null)) - if(!option || mode || !usr.client) break - AddChoice(option) - else - return 0 - mode = vote_type - initiator = initiator_key - started_time = world.time - var/text = "[capitalize(mode)] vote started by [initiator]." - if(mode == "custom") - text += "\n[sanitizeSafe(question)]" + // 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 || !!(vote_initiator.client?.holder?.rights & (R_ADMIN)) //Used GLOB.admin_datum - log_vote(text) - for(var/cc in GLOB.clients) - var/client/C = cc - to_chat(C, "[text]\nType vote or click here to place your votes.\nYou have [GLOB.config.vote_period/10] seconds to vote.") - if(C.prefs.sfx_toggles & ASFX_VOTE) //Personal mute - switch(vote_type) - if("crew_transfer") - sound_to(C, sound('sound/effects/vote.ogg', repeat = 0, wait = 0, volume = 50, channel = 3)) - if("gamemode") - sound_to(C, sound('sound/ambience/vote_alarm.ogg', repeat = 0, wait = 0, volume = 50, channel = 3)) - if("custom") - sound_to(C, sound('sound/ambience/vote_alarm.ogg', repeat = 0, wait = 0, volume = 50, channel = 3)) - if(mode == "gamemode" && GLOB.round_progressing) - GLOB.round_progressing = 0 - to_world("Round start has been delayed.") - SStgui.update_uis(src) - return 1 - return 0 + 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 -/datum/controller/subsystem/vote/proc/AddChoice(name, display_name, extra_text = "", required_players = 0) - if(!display_name) - display_name = name - choices[name] = list( - "name" = display_name, - "extra" = extra_text, - "required_players" = required_players, - "votes" = 0 - ) + // 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)] -/datum/controller/subsystem/vote/ui_state(mob/user) + // If we were passed an instance: use the instance + else if(istype(vote_type, /datum/vote)) + to_vote = vote_type + + // If we got neither a path or an instance, it could be a vote name, but is likely just an error / null + else + 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 = 600 //As above, used to be "CONFIG_GET(number/vote_period)" + var/to_display = current_vote.initiate_vote(vote_initiator_name, duration) + + log_vote(to_display) + to_chat(world, SPAN_INFO(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.sfx_toggles & ASFX_VOTE)) //Used "new_voter.prefs.read_preference(/datum/preference/toggle/sound_announcements)" but we don't have it + 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_status(mob/user, datum/ui_state/state) - return UI_INTERACTIVE - /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, "Voting", "Voting", 400, 500) + if(!ui) + ui = new(user, src, "VotePanel") ui.open() -/datum/controller/subsystem/vote/Topic(href, href_list) - . = ..() - if(href_list["open"]) - SSvote.ui_interact(usr) - return TRUE +/datum/controller/subsystem/vote/ui_data(mob/user) + var/list/data = list() -/datum/controller/subsystem/vote/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + var/is_lower_admin = (user.client?.holder?.rights & (R_ADMIN | R_MOD)) + var/is_upper_admin = (user.client?.holder?.rights & (R_ADMIN)) // Used "check_rights_for(user.client, R_ADMIN)" but we don't have that + + 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 - . = TRUE - var/isstaff = ui.user.client.holder && (ui.user.client.holder.rights & (R_ADMIN|R_MOD)) + var/mob/voter = usr switch(action) if("cancel") - if(isstaff) - reset() - return TRUE - if("toggle_restart") - if(isstaff) - GLOB.config.allow_vote_restart = !GLOB.config.allow_vote_restart - SStgui.update_uis(src) - return TRUE - if("toggle_gamemode") - if(isstaff) - GLOB.config.allow_vote_mode = !GLOB.config.allow_vote_mode - SStgui.update_uis(src) - return TRUE - if("restart") - if(isstaff) - initiate_vote("restart", ui.user.key) - return TRUE - else if (GLOB.config.allow_vote_restart) - var/admin_number_present = 0 - var/admin_number_afk = 0 + if(!(voter.client?.holder?.rights & (R_ADMIN | R_MOD))) + return - for (var/s in GLOB.staff) - var/client/X = s - if (X.holder.rights & R_ADMIN) - admin_number_present++ - if (X.is_afk()) - admin_number_afk++ - if (X.prefs.toggles & SOUND_ADMINHELP) - sound_to(X, 'sound/effects/adminhelp.ogg') - - if ((admin_number_present - admin_number_afk) <= 0) - initiate_vote("restart", ui.user.key) - return TRUE - else - log_and_message_admins("tried to start a restart vote.", usr, null) - to_chat(ui.user, "There are active admins around! You cannot start a restart vote due to this.") - if("gamemode") - if(GLOB.config.allow_vote_mode || isstaff) - initiate_vote("gamemode", ui.user.key) - return TRUE - if("crew_transfer") - if(GLOB.config.allow_vote_restart || isstaff) - initiate_vote("crew_transfer", ui.user.key) - return TRUE - if("add_antagonist") - if(!antag_add_failed && GLOB.config.allow_extra_antags) - initiate_vote("add_antagonist", ui.user.key) - return TRUE - if("custom") - if(isstaff) - initiate_vote("custom", ui.user.key) - return TRUE - if("vote") - var/list/T = params["vote"] - var/our_vote = T["choice"] - if(our_vote) - if(submit_vote(ui.user.ckey, our_vote)) - SStgui.update_uis(src) + message_admins("[key_name_admin(voter)] has cancelled the current vote.") + reset() return TRUE -/datum/controller/subsystem/vote/ui_data(mob/user) - var/list/data = list() - if(choices.len != LAZYLEN(data["choices"])) - data["choices"] = list() - for(var/choice in choices) - data["choices"] += list(list( - "choice" = choice, - "votes" = choices[choice]["votes"], - "extra" = choices[choice]["extra"], - "required_players" = choices[choice]["required_players"], - )) + if("toggleVote") + var/datum/vote/selected = possible_votes[params["voteName"]] + if(!istype(selected)) + return - data["mode"] = mode - data["voted"] = current_votes[user.ckey] - data["endtime"] = started_time + GLOB.config.vote_period - data["allow_vote_restart"] = GLOB.config.allow_vote_restart - data["allow_vote_mode"] = GLOB.config.allow_vote_mode - data["allow_extra_antags"] = (!antag_add_failed && GLOB.config.allow_extra_antags) + return selected.toggle_votable(voter) - if(!question) - data["question"] = capitalize(mode) - else - data["question"] = question - data["is_staff"] = user.client.holder && (user.client.holder.rights & (R_ADMIN|R_MOD)) - var/slevel = get_security_level() - data["is_code_red"] = (slevel == "red" || slevel == "delta") - data["total_players"] = SSticker.total_players - data["total_players_ready"] = SSticker.total_players_ready - return data + 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 + +/*######################### + Aurora snowflake area +###########################*/ + +/datum/controller/subsystem/vote/proc/autogamemode() + initiate_vote(/datum/vote/gamemode, "Server", null, TRUE) + +/datum/controller/subsystem/vote/proc/autotransfer() + initiate_vote(/datum/vote/crewtransfer, "Server", null, TRUE) + +/*############################## + Aurora snowflake area end +################################*/ + +/// Mob level verb that allows players to vote on the current vote. /mob/verb/vote() set category = "OOC" set name = "Vote" - SSvote.ui_interact(src) + 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" + //show_to_observers = FALSE + +/datum/action/vote/IsAvailable(feedback = FALSE) + return TRUE // Democracy is always available to the free people + +/datum/action/vote/Trigger(trigger_flags) + . = ..() + if(!.) + return + + 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 00000000000..dc301a552c6 --- /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/effects/vote.ogg' + /// A list of default choices we have for this vote. + var/list/default_choices + /// Does the name of this vote contain the word "vote"? + var/contains_vote_in_name = FALSE + /// 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 + GLOB.config.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 "[contains_vote_in_name ? "[capitalize(name)]" : "[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 = pick_weight(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]]" + + 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/crew_transfer.dm b/code/datums/votes/crew_transfer.dm new file mode 100644 index 00000000000..bacb8eae55f --- /dev/null +++ b/code/datums/votes/crew_transfer.dm @@ -0,0 +1,73 @@ +///Snowflake var to know when it's ok to vote the transfer, and show in the statpanel the last time it happened +GLOBAL_VAR(last_transfer_vote) + +/datum/vote/crewtransfer + name = "Crew Transfer" + message = "Initiate crew transfer" + winner_method = VOTE_WINNER_METHOD_NONE //We handle this ourself + default_choices = list("Initiate Crew Transfer", "Continue The Round") + + +/datum/vote/crewtransfer/can_be_initiated(mob/by_who, forced) + . = ..() + //Transfer already in progress, noone can call another vote + if(SSatlas.current_map.shuttle_call_restart_timer || (evacuation_controller.state != EVAC_IDLE)) + to_chat(by_who, SPAN_NOTICE("Transfer or evacuation are already in progress.")) + return FALSE + + //If in lobby, roundend, setup or whatever else + if(SSticker.current_state != GAME_STATE_PLAYING) + to_chat(by_who, SPAN_NOTICE("The game is not running yet or has already finished.")) + return FALSE + + //Admins, system and whatnot can always start this vote otherwise + if(forced) + return TRUE + + if(GLOB.security_level >= SEC_LEVEL_RED) + to_chat(by_who, "The current alert status is too high to call for a crew transfer!") + return FALSE + + //If enought time from the start passed, allows the vote + var/next_allowed_time = 0 + if(GLOB.last_transfer_vote) + next_allowed_time = (GLOB.last_transfer_vote + GLOB.config.vote_delay) + else + next_allowed_time = GLOB.config.transfer_timeout + + if(next_allowed_time > get_round_duration()) //Sorry, not the time yet + to_chat(by_who, SPAN_NOTICE("Time left until the crew transfer can be voted: [next_allowed_time - get_round_duration()]")) + return FALSE + else + return TRUE //We did it bro, we can vote the transfer now! + + +/datum/vote/crewtransfer/get_vote_result(list/non_voters) + SHOULD_CALL_PARENT(FALSE) + + //Calculate the factor based on the duration in minutes + var/factor = 0.5 + switch(get_round_duration() / (10 * 60)) // minutes + if(0 to 180) //Up to 3 hours + factor = 0.67 //2/3rd, rounded up from 0.6 periodic + else + factor = 1.0 //Equal weight + + var/list/result = null + if((choices["Initiate Crew Transfer"]*factor) >= (choices["Continue The Round"])) + result = list("Initiate Crew Transfer") + else + result = list("Continue The Round") + + if(round(get_round_duration() / 36000)+12 <= 14) + to_world(SPAN_VOTE("Majority voting rule in effect. 2/3rds majority needed to initiate transfer.")) + + return result + + +/datum/vote/crewtransfer/finalize_vote(winning_option) + to_world(SPAN_VOTE("Vote result: [winning_option]")) + GLOB.last_transfer_vote = get_round_duration() + + if(winning_option == "Initiate Crew Transfer") + init_shift_change(null, TRUE) diff --git a/code/datums/votes/custom_vote.dm b/code/datums/votes/custom_vote.dm new file mode 100644 index 00000000000..7b1604480f5 --- /dev/null +++ b/code/datums/votes/custom_vote.dm @@ -0,0 +1,73 @@ +/// 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"), + default = "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_WARNING("Unknown winner method. Contact a coder.")) + return FALSE + + override_question = tgui_input_text(vote_creator, "What is the vote for?", "Custom Vote") + if(!override_question) + return FALSE + + default_choices = list() + for(var/i in 1 to MAX_CUSTOM_VOTE_OPTIONS) + var/option = tgui_input_text(vote_creator, "Please enter an option, or hit cancel to finish. [MAX_CUSTOM_VOTE_OPTIONS] max.", "Options", max_length = 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.dm b/code/datums/votes/restart.dm new file mode 100644 index 00000000000..be7f28db9e3 --- /dev/null +++ b/code/datums/votes/restart.dm @@ -0,0 +1,42 @@ +/datum/vote/restart + name = "Restart" + message = "Restart the server?" + default_choices = list("Restart Round", "Continue Playing") + +/datum/vote/restart/can_be_initiated(mob/by_who, forced) + . = ..() + + //If it's disabled and you're not staff, no voting + if(!GLOB.config.allow_vote_restart && !forced) + return FALSE + + //Prompt the mob to confirm the special use case if present, otherwise say it's possible so the button isn't greyed out + if(ismob(by_who)) + var/acknowledge = tgui_alert(by_who, "CAUTION! This is a vote that must be used only in case of server issues that require a restart, and in no other case! Do you want to continue?", + "Caution!", list("No", "Yes"), 1 MINUTE, TRUE) + + if(acknowledge == "Yes") + return TRUE + else + return FALSE + + else + return TRUE + + +/datum/vote/restart/finalize_vote(winning_option) + if(winning_option != "Restart Round") + return + + to_world("World restarting due to vote...") + feedback_set_details("end_error","restart vote") + sleep(50) + log_game("Rebooting due to restart vote") + world.Reboot() + +/datum/vote/restart/toggle_votable(mob/toggler) + if(toggler?.client?.holder && (toggler.client.holder.rights & (R_ADMIN|R_MOD))) + GLOB.config.allow_vote_restart = !GLOB.config.allow_vote_restart + +/datum/vote/restart/is_config_enabled() + return GLOB.config.allow_vote_restart diff --git a/code/datums/votes/round_type.dm b/code/datums/votes/round_type.dm new file mode 100644 index 00000000000..0bbad675f0d --- /dev/null +++ b/code/datums/votes/round_type.dm @@ -0,0 +1,73 @@ +/datum/vote/gamemode + name = "Gamemode" + vote_sound = 'sound/ambience/vote_alarm.ogg' + message = "Vote for the gamemode of this round" + +/datum/vote/gamemode/create_vote(mob/vote_creator) + if(SSticker.current_state != GAME_STATE_PREGAME) + return FALSE + + //List of modes, will be inserted sorted by criteria + var/list/gamemodes_list = list() + + default_choices = list() + + //Put secret as the first option + default_choices.Add("Secret") + + //Sort the gamemodes + for(var/votable_mode_name in GLOB.config.votable_modes) + var/datum/game_mode/M = GLOB.gamemode_cache[votable_mode_name] + if(!M) + continue + + BINARY_INSERT(M, gamemodes_list, /datum/game_mode, M, required_players, COMPARE_KEY) + + //Actually populate the voting choices, which are now sorted + for(var/datum/game_mode/votable_mode_sorted in gamemodes_list) + default_choices += capitalize(votable_mode_sorted.name) + + //Stop the countdown while we vote + GLOB.round_progressing = FALSE + + . = ..() + +//Otherwise we get thanos snapped from the possible votes list +/datum/vote/gamemode/is_accessible_vote() + //Only accessible in pregame setup, further restricted from mobs in "can_be_initiated()" + if(SSticker.current_state == GAME_STATE_PREGAME) + return TRUE + return FALSE + +/datum/vote/gamemode/can_be_initiated(mob/by_who, forced) + . = ..() + //This can only be called by the server, noone else, not even admins + if(forced && !ismob(by_who)) + return TRUE + return FALSE + + +/datum/vote/gamemode/get_simple_winner() + var/list/winner = ..() + + //If noone voted, start extended + //yes this is kind of stupid, if noone won the list should be empty not have them all + //no it's not worth to change it, unless you want to do it yourself, if so, be my guest I guess + if(length(winner) == length(default_choices)) + to_world(SPAN_NOTICE("No votes for gamemode registered, defaulting to extended!")) + return list("extended") + + else + return winner + +/datum/vote/gamemode/finalize_vote(winning_option) + to_world(SPAN_WARNING("The round will start soon.")) + + if(GLOB.master_mode != lowertext(winning_option)) + SSpersistent_configuration.last_gamemode = lowertext(winning_option) + GLOB.master_mode = lowertext(winning_option) + +/datum/vote/gamemode/reset() + . = ..() + //Resume the countdown at vote end + GLOB.round_progressing = TRUE diff --git a/code/game/machinery/computer/camera.dm b/code/game/machinery/computer/camera.dm index d454dcc9e42..14305a375e3 100644 --- a/code/game/machinery/computer/camera.dm +++ b/code/game/machinery/computer/camera.dm @@ -76,7 +76,7 @@ if(!network_access) return TRUE - return (check_camera_access(user, ACCESS_SECURITY) && security_level >= SEC_LEVEL_BLUE) || check_camera_access(user, network_access) + return (check_camera_access(user, ACCESS_SECURITY) && GLOB.security_level >= SEC_LEVEL_BLUE) || check_camera_access(user, network_access) /obj/machinery/computer/security/Topic(href, href_list) if(..()) diff --git a/code/game/machinery/firealarm.dm b/code/game/machinery/firealarm.dm index 36ce7323ae2..4134922e181 100644 --- a/code/game/machinery/firealarm.dm +++ b/code/game/machinery/firealarm.dm @@ -225,7 +225,7 @@ update_icon() if(isContactLevel(z)) - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(set_security_level), (security_level ? get_security_level() : "green")) + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(set_security_level), (GLOB.security_level ? get_security_level() : "green")) soundloop = new(src, FALSE) diff --git a/code/modules/mob/living/silicon/robot/robot.dm b/code/modules/mob/living/silicon/robot/robot.dm index f0acd9baeba..c231e4b1327 100644 --- a/code/modules/mob/living/silicon/robot/robot.dm +++ b/code/modules/mob/living/silicon/robot/robot.dm @@ -338,7 +338,7 @@ return var/list/modules = list() modules.Add(GLOB.robot_module_types) - if((crisis_override && security_level == SEC_LEVEL_RED) || security_level == SEC_LEVEL_DELTA || crisis == TRUE) + if((crisis_override && GLOB.security_level == SEC_LEVEL_RED) || GLOB.security_level == SEC_LEVEL_DELTA || crisis == TRUE) to_chat(src, SPAN_WARNING("Crisis mode active. Combat module available.")) modules += "Combat" mod_type = tgui_input_list(src, "Please, select a module!", "Robot", modules) diff --git a/code/modules/modular_computers/file_system/programs/command/command_and_communications.dm b/code/modules/modular_computers/file_system/programs/command/command_and_communications.dm index 1ac78d58d87..36b61d77e38 100644 --- a/code/modules/modular_computers/file_system/programs/command/command_and_communications.dm +++ b/code/modules/modular_computers/file_system/programs/command/command_and_communications.dm @@ -49,8 +49,8 @@ data["isAI"] = issilicon(usr) data["authenticated"] = is_authenticated(user) data["boss_short"] = SSatlas.current_map.boss_short - data["current_security_level"] = security_level - data["current_security_level_title"] = num2seclevel(security_level) + data["current_security_level"] = GLOB.security_level + data["current_security_level_title"] = num2seclevel(GLOB.security_level) data["current_maint_all_access"] = maint_all_access data["def_SEC_LEVEL_DELTA"] = SEC_LEVEL_DELTA @@ -190,7 +190,7 @@ var/current_level = text2num(params["target"]) var/confirm = alert("Are you sure you want to change alert level to [num2seclevel(current_level)]?", filedesc, "No", "Yes") if(confirm == "Yes" && !computer.use_check_and_message(usr, (isAI(usr) ? USE_ALLOW_NON_ADJACENT : FALSE))) - var/old_level = security_level + var/old_level = GLOB.security_level if(!current_level) current_level = SEC_LEVEL_GREEN if(current_level < SEC_LEVEL_GREEN) @@ -198,10 +198,10 @@ if(current_level > SEC_LEVEL_BLUE) current_level = SEC_LEVEL_BLUE set_security_level(current_level) - if(security_level != old_level) + if(GLOB.security_level != old_level) log_game("[key_name(usr)] has changed the security level to [get_security_level()].", ckey = key_name(usr)) message_admins("[key_name_admin(usr)] has changed the security level to [get_security_level()].") - switch(security_level) + switch(GLOB.security_level) if(SEC_LEVEL_GREEN) feedback_inc("alert_comms_green",1) if(SEC_LEVEL_BLUE) diff --git a/code/modules/modular_computers/file_system/programs/security/camera.dm b/code/modules/modular_computers/file_system/programs/security/camera.dm index 038378b4c79..b9214406fe7 100644 --- a/code/modules/modular_computers/file_system/programs/security/camera.dm +++ b/code/modules/modular_computers/file_system/programs/security/camera.dm @@ -92,7 +92,7 @@ if(!network_access) return TRUE - return (check_network_access(user, ACCESS_SECURITY) && security_level >= SEC_LEVEL_BLUE) || check_network_access(user, network_access) + return (check_network_access(user, ACCESS_SECURITY) && GLOB.security_level >= SEC_LEVEL_BLUE) || check_network_access(user, network_access) /datum/computer_file/program/camera_monitor/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) . = ..() diff --git a/code/modules/projectiles/pins.dm b/code/modules/projectiles/pins.dm index 4615082896f..b83fde04309 100644 --- a/code/modules/projectiles/pins.dm +++ b/code/modules/projectiles/pins.dm @@ -305,13 +305,13 @@ var/list/wireless_firing_pins = list() //A list of all initialized wireless firi var/obj/item/gun/energy/EG = gun if(EG.required_firemode_auth[EG.sel_mode] == WIRELESS_PIN_STUN) return TRUE - else if (security_level == SEC_LEVEL_YELLOW || security_level == SEC_LEVEL_RED) + else if (GLOB.security_level == SEC_LEVEL_YELLOW || GLOB.security_level == SEC_LEVEL_RED) return TRUE else fail_message = SPAN_WARNING("Unable to fire: insufficient security level.") return FALSE else - if (security_level == SEC_LEVEL_YELLOW || security_level == SEC_LEVEL_RED) + if (GLOB.security_level == SEC_LEVEL_YELLOW || GLOB.security_level == SEC_LEVEL_RED) return TRUE else fail_message = SPAN_WARNING("Unable to fire: insufficient security level.") diff --git a/code/modules/security levels/keycard authentication.dm b/code/modules/security levels/keycard authentication.dm index cd732771da4..565a8cf9e31 100644 --- a/code/modules/security levels/keycard authentication.dm +++ b/code/modules/security levels/keycard authentication.dm @@ -217,7 +217,7 @@ var/global/maint_all_access = 0 if(!I) return ..(M) var/list/A = I.GetAccess() - var/maint_sec_access = ((security_level > SEC_LEVEL_GREEN) && has_access(ACCESS_SECURITY, accesses = A)) + var/maint_sec_access = ((GLOB.security_level > SEC_LEVEL_GREEN) && has_access(ACCESS_SECURITY, accesses = A)) var/exceptional_circumstances = maint_all_access || maint_sec_access if(exceptional_circumstances && src.check_access_list(list(ACCESS_MAINT_TUNNELS))) return 1 diff --git a/code/modules/security levels/security levels.dm b/code/modules/security levels/security levels.dm index a3b837e3408..8f1dbe1f787 100644 --- a/code/modules/security levels/security levels.dm +++ b/code/modules/security levels/security levels.dm @@ -1,4 +1,4 @@ -/var/security_level = 0 +GLOBAL_VAR_INIT(security_level, SEC_LEVEL_GREEN) //0 = code green //1 = code yellow //2 = code blue @@ -23,34 +23,34 @@ level = SEC_LEVEL_DELTA //Will not be announced if you try to set to the same level as it already is - if(level >= SEC_LEVEL_GREEN && level <= SEC_LEVEL_DELTA && level != security_level) + if(level >= SEC_LEVEL_GREEN && level <= SEC_LEVEL_DELTA && level != GLOB.security_level) switch(level) if(SEC_LEVEL_GREEN) security_announcement.Announce("[GLOB.config.alert_desc_green]", "Attention! Security level lowered to green.") - security_level = SEC_LEVEL_GREEN + GLOB.security_level = SEC_LEVEL_GREEN SSnightlight.end_temp_disable() if(SEC_LEVEL_BLUE) - if(security_level < SEC_LEVEL_BLUE) + if(GLOB.security_level < SEC_LEVEL_BLUE) security_announcement_sound.Announce("[GLOB.config.alert_desc_blue_upto]", "Attention! Security level elevated to blue.") else security_announcement.Announce("[GLOB.config.alert_desc_blue_downto]", "Attention! Security level lowered to blue.") - security_level = SEC_LEVEL_BLUE + GLOB.security_level = SEC_LEVEL_BLUE SSnightlight.end_temp_disable() if(SEC_LEVEL_YELLOW) security_announcement_sound.Announce("[GLOB.config.alert_desc_yellow_to]", "Attention! Biohazard alert declared!") - security_level = SEC_LEVEL_YELLOW + GLOB.security_level = SEC_LEVEL_YELLOW SSnightlight.end_temp_disable() if(SEC_LEVEL_RED) - if(security_level < SEC_LEVEL_RED) + if(GLOB.security_level < SEC_LEVEL_RED) security_announcement_sound.Announce("[GLOB.config.alert_desc_red_upto]", "Attention! Security level elevated to red!", new_sound = 'sound/effects/high_alert.ogg') SSnightlight.temp_disable() else security_announcement.Announce("[GLOB.config.alert_desc_red_downto]", "Attention! Code red!") - security_level = SEC_LEVEL_RED + GLOB.security_level = SEC_LEVEL_RED post_display_status("alert", "redalert") if(SEC_LEVEL_DELTA) security_announcement_sound.Announce("[GLOB.config.alert_desc_delta]", "Attention! Delta security level reached!", new_sound = 'sound/effects/siren.ogg') - security_level = SEC_LEVEL_DELTA + GLOB.security_level = SEC_LEVEL_DELTA SSnightlight.temp_disable() var/newlevel = get_security_level() @@ -59,7 +59,7 @@ powercontrol.manage_emergency(newlevel) /proc/get_security_level() - switch(security_level) + switch(GLOB.security_level) if(SEC_LEVEL_GREEN) return "green" if(SEC_LEVEL_BLUE) @@ -96,15 +96,3 @@ return SEC_LEVEL_RED if("delta") return SEC_LEVEL_DELTA - - -/*DEBUG -/mob/verb/set_thing0() - set_security_level(0) -/mob/verb/set_thing1() - set_security_level(1) -/mob/verb/set_thing2() - set_security_level(2) -/mob/verb/set_thing3() - set_security_level(3) -*/ diff --git a/html/changelogs/fluffyghost-updatessvote.yml b/html/changelogs/fluffyghost-updatessvote.yml new file mode 100644 index 00000000000..66bc7a07a7f --- /dev/null +++ b/html/changelogs/fluffyghost-updatessvote.yml @@ -0,0 +1,43 @@ +################################ +# Example Changelog File +# +# Note: This file, and files beginning with ".", and files that don't end in ".yml" will not be read. If you change this file, you will look really dumb. +# +# Your changelog will be merged with a master changelog. (New stuff added only, and only on the date entry for the day it was merged.) +# When it is, any changes listed below will disappear. +# +# Valid Prefixes: +# bugfix +# wip (For works in progress) +# tweak +# soundadd +# sounddel +# rscadd (general adding of nice things) +# rscdel (general deleting of nice things) +# imageadd +# imagedel +# maptweak +# spellcheck (typo fixes) +# experiment +# balance +# admin +# backend +# security +# refactor +################################# + +# Your name. +author: FluffyGhost + +# Optional: Remove this file after generating master changelog. Useful for PR changelogs that won't get used again. +delete-after: True + +# Any changes you've made. See valid prefix list above. +# INDENT WITH TWO SPACES. NOT TABS. SPACES. +# SCREW THIS UP AND IT WON'T WORK. +# Also, all entries are changed into a single [] after a master changelog generation. Just remove the brackets when you add new entries. +# Please surround your changes in double quotes ("), as certain characters otherwise screws up compiling. The quotes will not show up in the changelog. +changes: + - backend: "Updated vote subsystem." + - backend: "Turned some variables into proper globs." + - backend: "Adapted the ticker and actions to behave properly with the updated subsystem." diff --git a/tgui/packages/tgui/interfaces/VotePanel.tsx b/tgui/packages/tgui/interfaces/VotePanel.tsx new file mode 100644 index 00000000000..594993fd947 --- /dev/null +++ b/tgui/packages/tgui/interfaces/VotePanel.tsx @@ -0,0 +1,272 @@ +import { BooleanLike } from 'common/react'; + +import { useBackend } from '../backend'; +import { Box, Button, Collapsible, Icon, LabeledList, NoticeBox, Section, Stack } from '../components'; +import { Window } from '../layouts'; + +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 && ( + + )} + +
+
+ ); +};