From 8400a218cddcd1d1a04362e97a978a8e112fb9db Mon Sep 17 00:00:00 2001
From: MrMelbert <51863163+MrMelbert@users.noreply.github.com>
Date: Sun, 8 May 2022 13:52:29 -0500
Subject: [PATCH 01/15] Refactors SSvote, makes votes into datums, also makes
vote ui Typescript (#66772)
Makes vote into their own singleton datums.
Refactors the voting subsystem to accommodate.
Refactors the vote UI from JS to TSX (probably badly).
---
.../configuration/entries/general.dm | 7 +-
code/controllers/subsystem/autotransfer.dm | 15 +-
code/controllers/subsystem/mapping.dm | 5 -
code/controllers/subsystem/vote.dm | 651 ++++++++----------
code/datums/votes/_vote_datum.dm | 205 ++++++
code/datums/votes/custom_vote.dm | 54 ++
code/datums/votes/restart_vote.dm | 61 ++
code/datums/votes/transfer_vote.dm | 66 ++
shiptest.dme | 4 +
tgui/packages/tgui/interfaces/VotePanel.tsx | 207 ++++++
10 files changed, 909 insertions(+), 366 deletions(-)
create mode 100644 code/datums/votes/_vote_datum.dm
create mode 100644 code/datums/votes/custom_vote.dm
create mode 100644 code/datums/votes/restart_vote.dm
create mode 100644 code/datums/votes/transfer_vote.dm
create mode 100644 tgui/packages/tgui/interfaces/VotePanel.tsx
diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm
index 6a5959574754..7fa437b14da2 100644
--- a/code/controllers/configuration/entries/general.dm
+++ b/code/controllers/configuration/entries/general.dm
@@ -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..07e3eccce3ff 100644
--- a/code/controllers/subsystem/autotransfer.dm
+++ b/code/controllers/subsystem/autotransfer.dm
@@ -3,19 +3,16 @@ 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))
+ 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 92b8d146c4fc..ec57fd22c360 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..caac0eed074b 100644
--- a/code/controllers/subsystem/vote.dm
+++ b/code/controllers/subsystem/vote.dm
@@ -1,383 +1,338 @@
+/// 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
+
+ for(var/datum/action/vote/voting_action as anything in generated_actions)
+ if(QDELETED(voting_action))
+ continue
+ voting_action.Remove(voting_action.owner)
+
+ generated_actions.Cut()
+
+ 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())
+ 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)
+
+ log_vote(to_display)
+ to_chat(world, span_infoplain(vote_font("\n[to_display]")))
+
+ // Finally, doing any effects on vote completion
+ current_vote.finalize_vote(final_winner)
+
+/datum/controller/subsystem/vote/proc/submit_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
+
+/**
+ * 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"
- for(var/i=1,i<=choices.len,i++)
- var/votes = choices[choices[i]]
- if(!votes)
- votes = 0
- . += "[choices[i]] ([votes] votes) "
- . += " "
- 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: "
- //restart
- var/avr = CONFIG_GET(flag/allow_vote_restart)
- if(trialmin || avr)
- . += "Restart "
- else
- . += "Restart (Disallowed) "
- if(trialmin)
- . += "\t([avr ? "Allowed" : "Disallowed"] )"
- . += " "
- //gamemode
- var/avm = CONFIG_GET(flag/allow_vote_mode)
- if(trialmin || avm)
- . += "GameMode "
- else
- . += "GameMode (Disallowed) "
- if(trialmin)
- . += "\t([avm ? "Allowed" : "Disallowed"] )"
-
- . += " "
- //custom
- if(trialmin)
- . += "Custom "
- . += " "
- . += "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(
+ "isLowerAdmin" = is_lower_admin,
+ "isUpperAdmin" = is_upper_admin,
+ // What the current user has selected in any ongoing votes.
+ "selectedChoice" = current_vote?.choices_by_ckey[user.client?.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(),
+ )
+
+ 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,
+ "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("vote")
+ return submit_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..f7ea9aceb9f4
--- /dev/null
+++ b/code/datums/votes/_vote_datum.dm
@@ -0,0 +1,205 @@
+
+/**
+ * # 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
+
+ // 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
+
+/**
+ * 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)
+ if(by_who)
+ to_chat(by_who, span_warning("A vote was initiated recently. You must wait [DisplayTimeText(next_allowed_time - world.time)] before a new vote can be started!"))
+ return FALSE
+
+ 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)
+
+ var/list/winners = list()
+ var/highest_vote = 0
+
+ for(var/option in choices)
+
+ var/vote_count = choices[option]
+ // If we currently have no winners...
+ if(!length(winners))
+ // And the current option has any votes, it's the new highest.
+ if(vote_count > 0)
+ winners += option
+ highest_vote = vote_count
+ continue
+
+ // If we're greater than, and NOT equal to, the highest vote,
+ // we are the new supreme winner - clear all others
+ if(vote_count > highest_vote)
+ winners.Cut()
+ winners += option
+ highest_vote = vote_count
+
+ // If we're equal to the highest vote, we tie for winner
+ else if(vote_count == highest_vote)
+ winners += option
+
+ return winners
+
+/**
+ * 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)
+ if(length(all_winners) <= 0 || !real_winner)
+ return span_bold("Vote Result: Inconclusive - No Votes!")
+
+ var/returned_text = ""
+ if(override_question)
+ returned_text += span_bold(override_question)
+ else
+ returned_text += span_bold("[capitalize(name)]")
+
+ for(var/option in choices)
+ returned_text += "\n[span_bold(option)]: [choices[option]]"
+
+ 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("Vote 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..f57ff67ea6d4
--- /dev/null
+++ b/code/datums/votes/custom_vote.dm
@@ -0,0 +1,54 @@
+/// The max amount of options someone can have in a custom vote.
+#define MAX_CUSTOM_VOTE_OPTIONS 10
+
+/datum/vote/custom_vote
+ name = "Custom"
+
+// 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)
+ 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]"
+
+// There are no winners or losers for custom votes
+/datum/vote/custom_vote/get_winner_text(list/all_winners, real_winner, list/non_voters)
+ return "[span_bold("Did not vote:")] [length(non_voters)]"
+
+#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..fbea2436e495
--- /dev/null
+++ b/code/datums/votes/restart_vote.dm
@@ -0,0 +1,61 @@
+#define CHOICE_RESTART "Restart Round"
+#define CHOICE_CONTINUE "Continue Playing"
+
+/datum/vote/restart_vote
+ name = "Restart"
+ default_choices = list(
+ CHOICE_RESTART,
+ CHOICE_CONTINUE,
+ )
+
+/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))
+ if(by_who)
+ to_chat(by_who, span_warning("Restart voting is disabled."))
+ return FALSE
+
+ 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)
+ for(var/client/online_admin as anything in GLOB.admins | GLOB.deadmins)
+ if(online_admin.is_afk() || !check_rights_for(online_admin, R_SERVER))
+ continue
+
+ 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..a3a95649be04
--- /dev/null
+++ b/code/datums/votes/transfer_vote.dm
@@ -0,0 +1,66 @@
+#define CHOICE_TRANSFER "Initiate Bluespace Jump"
+#define CHOICE_CONTINUE "Continue Playing"
+
+/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)
+ var/factor = 1
+ switch(world.time / (1 HOURS))
+ if(0 to 1)
+ factor = 0.5
+ if(1 to 2)
+ factor = 0.8
+ if(2 to 3)
+ factor = 1
+ if(3 to 4)
+ factor = 1.2
+ else
+ factor = 1.4
+ choices[CHOICE_TRANSFER] += round(length(non_voters) * factor)
+
+ return ..()
+
+/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 CHOICE_TRANSFER
+#undef CHOICE_CONTINUE
diff --git a/shiptest.dme b/shiptest.dme
index 543b4322394d..cea9427ae72e 100644
--- a/shiptest.dme
+++ b/shiptest.dme
@@ -741,7 +741,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..24644ae5ba4f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/VotePanel.tsx
@@ -0,0 +1,207 @@
+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;
+};
+
+type Option = {
+ name: string;
+ votes: number;
+};
+
+type ActiveVote = {
+ vote: Vote;
+ question: string | null;
+ timeRemaining: number;
+ choices: Option[];
+};
+
+type UserData = {
+ isLowerAdmin: BooleanLike;
+ isUpperAdmin: BooleanLike;
+ selectedChoice: string | null;
+};
+
+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,
+ })} />
+ )}
+ act('callVote', {
+ voteName: option.name,
+ })} />
+
+ ))}
+
+
+
+ );
+};
+
+/**
+ * View Voters by ckey. Admin only.
+ * @returns A collapsible list of voters
+ */
+const VotersList = (props, context) => {
+ const { data } = useBackend(context);
+
+ return (
+
+
+
+ {data.voting.map((voter) => {
+ return {voter} ;
+ })}
+
+
+
+ );
+};
+
+/**
+ * The choices panel which displays all options in the list.
+ * @returns A section visible to all users.
+ */
+const ChoicesPanel = (props, context) => {
+ const { act, data } = useBackend(context);
+ const { currentVote, user } = data;
+
+ return (
+
+
+ {currentVote && currentVote.choices.length !== 0 ? (
+
+ {currentVote.choices.map(choice => (
+
+ c.toUpperCase())}
+ textAlign="right"
+ buttons={
+ {
+ act('vote', { voteOption: choice.name });
+ }}>
+ Vote
+
+ }>
+ {user.selectedChoice
+ && choice.name === user.selectedChoice && (
+
+ )}
+ {choice.votes} Votes
+
+
+
+ ))}
+
+ ) : (
+ {currentVote ? "No choices available!" : "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 && (
+ act('cancel')}>
+ Cancel Vote
+
+ )}
+
+
+
+ );
+};
From df770c5da01f5ead2b23c7b0d9c9e2929d4511ed Mon Sep 17 00:00:00 2001
From: MrMelbert <51863163+MrMelbert@users.noreply.github.com>
Date: Fri, 13 May 2022 03:10:54 -0500
Subject: [PATCH 02/15] Fixes some minor formatting issues with vote text
(#66913)
* Fixes some minor formatting issues with vote
* Wrong thing.
* Actually I think this is supposed to be here
---
code/datums/votes/_vote_datum.dm | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm
index f7ea9aceb9f4..1332ff242c22 100644
--- a/code/datums/votes/_vote_datum.dm
+++ b/code/datums/votes/_vote_datum.dm
@@ -162,7 +162,7 @@
if(override_question)
returned_text += span_bold(override_question)
else
- returned_text += span_bold("[capitalize(name)]")
+ returned_text += span_bold("[capitalize(name)] Vote")
for(var/option in choices)
returned_text += "\n[span_bold(option)]: [choices[option]]"
@@ -188,7 +188,7 @@
for(var/a_winner in all_winners)
returned_text += "\n\t[a_winner]"
- returned_text += span_bold("Vote Result: [real_winner]")
+ returned_text += span_bold("\nVote Result: [real_winner]")
return returned_text
/**
From 3a26d9d5d785de6cd164a6145c5b9dadefefb326 Mon Sep 17 00:00:00 2001
From: san7890
Date: Tue, 13 Sep 2022 21:40:01 -0600
Subject: [PATCH 03/15] Verbose Vote Initiation Feedback Tooltippery (#69763)
* Verbose Vote Initiation Feedback Tooltippery
Hey there,
So basically, the old implementation had it such that when a vote was disabled and you tried to trigger it, you could get a very nice message in your chat explaining why you could not trigger that vote in that moment. HOWEVER, there's a current fatal flaw in this logic:
You can't ever get that to_chat reason as to _why_ this vote is disabled since you can't click the button. I don't know if this ever worked, which is sad, because we had a lot of these nice messages that one would never see. So, let's leverage the power of TGUI and add messages.
The messages are applied per-datum singleton, and are a generic explanation of what the vote does when there is no specific reason assigned to it when the can_be_initiated() proc runs. If it can not be initiated, we change the message to reflect exactly why the player can not initiate the vote. It ends up looking something like this:
In order for this to work well for the restart vote and to lessen the amount of copy-pasting I might have to do, I created a new proc that checks to see if a valid admin is online, and uses that for both updating the message and restarting the server if the vote clears.
* fixes messages not resetting
* removes misleading section
the admin can always restart the server if they wish
---
code/controllers/subsystem/vote.dm | 1 +
code/datums/votes/_vote_datum.dm | 6 +++--
code/datums/votes/custom_vote.dm | 1 +
code/datums/votes/restart_vote.dm | 27 ++++++++++++++++-----
tgui/packages/tgui/interfaces/VotePanel.tsx | 2 ++
5 files changed, 29 insertions(+), 8 deletions(-)
diff --git a/code/controllers/subsystem/vote.dm b/code/controllers/subsystem/vote.dm
index caac0eed074b..aba0890813b6 100644
--- a/code/controllers/subsystem/vote.dm
+++ b/code/controllers/subsystem/vote.dm
@@ -238,6 +238,7 @@ SUBSYSTEM_DEF(vote)
"name" = vote_name,
"canBeInitiated" = vote.can_be_initiated(forced = is_lower_admin),
"config" = vote.is_config_enabled(),
+ "message" = vote.message,
)
if(vote == current_vote)
diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm
index 1332ff242c22..d8cbbc6ef112 100644
--- a/code/datums/votes/_vote_datum.dm
+++ b/code/datums/votes/_vote_datum.dm
@@ -13,6 +13,8 @@
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.
@@ -73,10 +75,10 @@
if(started_time)
var/next_allowed_time = (started_time + CONFIG_GET(number/vote_delay))
if(next_allowed_time > world.time && !forced)
- if(by_who)
- to_chat(by_who, span_warning("A vote was initiated recently. You must wait [DisplayTimeText(next_allowed_time - world.time)] before a new vote can be started!"))
+ 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
/**
diff --git a/code/datums/votes/custom_vote.dm b/code/datums/votes/custom_vote.dm
index f57ff67ea6d4..be701970f19d 100644
--- a/code/datums/votes/custom_vote.dm
+++ b/code/datums/votes/custom_vote.dm
@@ -3,6 +3,7 @@
/datum/vote/custom_vote
name = "Custom"
+ message = "Click here to start a custom vote."
// Custom votes ares always accessible.
/datum/vote/custom_vote/is_accessible_vote()
diff --git a/code/datums/votes/restart_vote.dm b/code/datums/votes/restart_vote.dm
index fbea2436e495..24d38f35396d 100644
--- a/code/datums/votes/restart_vote.dm
+++ b/code/datums/votes/restart_vote.dm
@@ -7,10 +7,22 @@
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
@@ -26,10 +38,16 @@
return FALSE
if(!forced && !CONFIG_GET(flag/allow_vote_restart))
- if(by_who)
- to_chat(by_who, span_warning("Restart voting is disabled."))
+ 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)
@@ -44,10 +62,7 @@
return
if(winning_option == CHOICE_RESTART)
- for(var/client/online_admin as anything in GLOB.admins | GLOB.deadmins)
- if(online_admin.is_afk() || !check_rights_for(online_admin, R_SERVER))
- continue
-
+ 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
diff --git a/tgui/packages/tgui/interfaces/VotePanel.tsx b/tgui/packages/tgui/interfaces/VotePanel.tsx
index 24644ae5ba4f..776294ef1a56 100644
--- a/tgui/packages/tgui/interfaces/VotePanel.tsx
+++ b/tgui/packages/tgui/interfaces/VotePanel.tsx
@@ -13,6 +13,7 @@ type Vote = {
name: string;
canBeInitiated: BooleanLike;
config: VoteConfig;
+ message: string;
};
type Option = {
@@ -95,6 +96,7 @@ const VoteOptions = (props, context) => {
)}
act('callVote', {
voteName: option.name,
From 57c0416368ed179136a908bfb4db44dd71d62c33 Mon Sep 17 00:00:00 2001
From: MrMelbert <51863163+MrMelbert@users.noreply.github.com>
Date: Sat, 22 Oct 2022 15:06:55 -0500
Subject: [PATCH 04/15] Fixes runtime from voting action `Remove()` (#70702)
Fixes runtime from voting actions due to calling remove on ownerless actions
---
code/controllers/subsystem/vote.dm | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/code/controllers/subsystem/vote.dm b/code/controllers/subsystem/vote.dm
index aba0890813b6..6165d669dc26 100644
--- a/code/controllers/subsystem/vote.dm
+++ b/code/controllers/subsystem/vote.dm
@@ -48,12 +48,7 @@ SUBSYSTEM_DEF(vote)
current_vote?.reset()
current_vote = null
- for(var/datum/action/vote/voting_action as anything in generated_actions)
- if(QDELETED(voting_action))
- continue
- voting_action.Remove(voting_action.owner)
-
- generated_actions.Cut()
+ QDEL_LIST(generated_actions)
SStgui.update_uis(src)
From ae7222a13c82585d3975d389ea8821ca326b48b6 Mon Sep 17 00:00:00 2001
From: lessthanthree <83487515+lessthnthree@users.noreply.github.com>
Date: Sat, 4 Mar 2023 17:29:24 -0800
Subject: [PATCH 05/15] Vote System: Approval Voting (#73749)
Approval Voting is a system in which voters can select as many maps as
they want, instead of selecting only one. Final tallies show how many
votes each map received, and the winner is the map with the most
support.
- Custom votes can now be started using either system
- Icon during AV votes indicating your selections
- Map population filter counts active players and participating ghosts
https://user-images.githubusercontent.com/83487515/222580901-61506cc3-dc42-4435-9775-1e6291a3f734.mp4
First-past-the-post (our current voting system) has flaws such as
creating a bunch of wasted votes, in that a large number of selections
ultimately have no impact and for example, a map can win a 3 way race
11/10/10, even though 2/3 of the votes were not for that map. This leads
to people having to vote strategically, and perhaps not what their true
choice is.
Approval Voting solves this by instead allowing the player to select all
the maps they would like to play, so they can vote for their true
preferred choice, as well as alternates.
For example, a player that wants Metastation, is okay with Icebox, and
doesn't want Delta may feel pressured to vote Icebox if it's in a 2 way
race with Delta.
AV lets them vote for Meta, and Icebox or as many others as they want as
their alternates and creates a more fair outcome of a map vote.
Map population filter removing AFK/lobby screen dwellers gives a better
number of active players so as to not trip the map filter's population
cap earlier than it should.
tl;dr: Less of this
![image](https://user-images.githubusercontent.com/83487515/222860681-210f2d7e-2368-4d42-84d5-6de838995e50.png)
:cl: LT3
rscadd: Added new multi-vote system
balance: Map votes are now calculated using multi-vote instead of the
old single-vote system
admin: Admins can now use either multi-vote or single-vote for custom
votes
code: Map choice filtering uses active player count, not all connected
clients
/:cl:
---
code/__DEFINES/subsystems.dm | 6 ++
code/__HELPERS/game.dm | 4 +-
code/controllers/subsystem/vote.dm | 42 +++++++++++--
code/datums/votes/_vote_datum.dm | 2 +
code/datums/votes/custom_vote.dm | 11 +++-
tgui/packages/tgui/interfaces/VotePanel.tsx | 69 ++++++++++++++++++---
6 files changed, 115 insertions(+), 19 deletions(-)
diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm
index 7f5569e3e609..59537e561f1e 100644
--- a/code/__DEFINES/subsystems.dm
+++ b/code/__DEFINES/subsystems.dm
@@ -257,3 +257,9 @@
} \
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
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/subsystem/vote.dm b/code/controllers/subsystem/vote.dm
index 6165d669dc26..0d142861e1a2 100644
--- a/code/controllers/subsystem/vote.dm
+++ b/code/controllers/subsystem/vote.dm
@@ -91,7 +91,10 @@ SUBSYSTEM_DEF(vote)
// Finally, doing any effects on vote completion
current_vote.finalize_vote(final_winner)
-/datum/controller/subsystem/vote/proc/submit_vote(mob/voter, their_vote)
+/**
+ * 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)
@@ -109,6 +112,31 @@ SUBSYSTEM_DEF(vote)
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
/**
@@ -215,10 +243,12 @@ SUBSYSTEM_DEF(vote)
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.
- "selectedChoice" = current_vote?.choices_by_ckey[user.client?.ckey],
+ "singleSelection" = current_vote?.choices_by_ckey[user.client?.ckey],
+ "multiSelection" = current_vote?.choices_by_ckey,
)
data["voting"]= is_lower_admin ? voting : list()
@@ -248,6 +278,7 @@ SUBSYSTEM_DEF(vote)
"name" = current_vote.name,
"question" = current_vote.override_question,
"timeRemaining" = current_vote.time_remaining,
+ "countMethod" = current_vote.count_method,
"choices" = choices,
"vote" = vote_data,
)
@@ -291,8 +322,11 @@ SUBSYSTEM_DEF(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("vote")
- return submit_vote(voter, params["voteOption"])
+ 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
diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm
index d8cbbc6ef112..72b58e25fce4 100644
--- a/code/datums/votes/_vote_datum.dm
+++ b/code/datums/votes/_vote_datum.dm
@@ -26,6 +26,8 @@
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
/**
* Used to determine if this vote is a possible
diff --git a/code/datums/votes/custom_vote.dm b/code/datums/votes/custom_vote.dm
index be701970f19d..b610724c4aa8 100644
--- a/code/datums/votes/custom_vote.dm
+++ b/code/datums/votes/custom_vote.dm
@@ -1,9 +1,14 @@
/// The max amount of options someone can have in a custom vote.
#define MAX_CUSTOM_VOTE_OPTIONS 10
-/datum/vote/custom_vote
- name = "Custom"
- message = "Click here to start a custom vote."
+/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()
diff --git a/tgui/packages/tgui/interfaces/VotePanel.tsx b/tgui/packages/tgui/interfaces/VotePanel.tsx
index 776294ef1a56..53a2481d495e 100644
--- a/tgui/packages/tgui/interfaces/VotePanel.tsx
+++ b/tgui/packages/tgui/interfaces/VotePanel.tsx
@@ -26,14 +26,23 @@ type ActiveVote = {
question: string | null;
timeRemaining: number;
choices: Option[];
+ countMethod: number;
};
type UserData = {
+ ckey: string;
isLowerAdmin: BooleanLike;
isUpperAdmin: BooleanLike;
- selectedChoice: string | null;
+ singleSelection: string | null;
+ multiSelection: string[] | null;
+ countMethod: VoteSystem;
};
+enum VoteSystem {
+ VOTE_SINGLE = 1,
+ VOTE_MULTI = 2,
+}
+
type Data = {
currentVote: ActiveVote;
possibleVotes: Vote[];
@@ -139,8 +148,13 @@ const ChoicesPanel = (props, context) => {
return (
-
- {currentVote && currentVote.choices.length !== 0 ? (
+
+ {currentVote && currentVote.countMethod === VoteSystem.VOTE_SINGLE ? (
+ Select one option
+ ) : null}
+ {currentVote &&
+ currentVote.choices.length !== 0 &&
+ currentVote.countMethod === VoteSystem.VOTE_SINGLE ? (
{currentVote.choices.map(choice => (
@@ -149,15 +163,15 @@ const ChoicesPanel = (props, context) => {
textAlign="right"
buttons={
{
- act('vote', { voteOption: choice.name });
+ act('voteSingle', { voteOption: choice.name });
}}>
Vote
}>
- {user.selectedChoice
- && choice.name === user.selectedChoice && (
+ {user.singleSelection
+ && choice.name === user.singleSelection && (
{
))}
- ) : (
- {currentVote ? "No choices available!" : "No vote active!"}
- )}
+ ) : 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={
+ {
+ act('voteMulti', { voteOption: choice.name });
+ }}>
+ Vote
+
+ }>
+ {user.multiSelection &&
+ user.multiSelection[user.ckey.concat(choice.name)] === 1 ? (
+
+ ) : null}
+ {choice.votes} Votes
+
+
+
+ ))}
+
+ ) : null}
+ {currentVote ? null : No vote active! }
);
From 12be50f5d1e18f0baa1c549550ca33ee3d7e1b0c Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Wed, 24 Jan 2024 21:14:45 -0600
Subject: [PATCH 06/15] more stuff
---
code/__DEFINES/maths.dm | 4 +
code/__DEFINES/subsystems.dm | 7 ++
.../configuration/entries/general.dm | 4 +-
code/controllers/subsystem/autotransfer.dm | 3 +
code/controllers/subsystem/vote.dm | 6 +-
code/datums/votes/_vote_datum.dm | 79 ++++++++++++++-----
code/datums/votes/custom_vote.dm | 23 +++++-
code/datums/votes/transfer_vote.dm | 4 +-
code/game/world.dm | 4 -
config/config.txt | 7 +-
10 files changed, 102 insertions(+), 39 deletions(-)
diff --git a/code/__DEFINES/maths.dm b/code/__DEFINES/maths.dm
index 77c32e0ff653..719f06f2a812 100644
--- a/code/__DEFINES/maths.dm
+++ b/code/__DEFINES/maths.dm
@@ -31,6 +31,10 @@
#define ROUND_UP(x) (-round(-(x)))
+/// Returns the number of digits in a number. Only works on whole numbers.
+/// This is marginally faster than string interpolation -> 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 59537e561f1e..edade85c5671 100644
--- a/code/__DEFINES/subsystems.dm
+++ b/code/__DEFINES/subsystems.dm
@@ -263,3 +263,10 @@
#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/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm
index 7fa437b14da2..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
diff --git a/code/controllers/subsystem/autotransfer.dm b/code/controllers/subsystem/autotransfer.dm
index 07e3eccce3ff..2b1259ef4f5f 100644
--- a/code/controllers/subsystem/autotransfer.dm
+++ b/code/controllers/subsystem/autotransfer.dm
@@ -11,6 +11,9 @@ SUBSYSTEM_DEF(autotransfer)
/datum/controller/subsystem/autotransfer/fire()
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))
diff --git a/code/controllers/subsystem/vote.dm b/code/controllers/subsystem/vote.dm
index 0d142861e1a2..d1d1edceb7e7 100644
--- a/code/controllers/subsystem/vote.dm
+++ b/code/controllers/subsystem/vote.dm
@@ -85,11 +85,13 @@ SUBSYSTEM_DEF(vote)
// Announce the results of the vote to the world.
var/to_display = current_vote.get_result_text(winners, final_winner, non_voters)
- log_vote(to_display)
+ 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
- current_vote.finalize_vote(final_winner)
+ 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.
diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm
index 72b58e25fce4..cc35d3d2f841 100644
--- a/code/datums/votes/_vote_datum.dm
+++ b/code/datums/votes/_vote_datum.dm
@@ -28,6 +28,8 @@
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
@@ -121,33 +123,41 @@
*/
/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()
- var/list/winners = list()
+/// 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 we currently have no winners...
- if(!length(winners))
- // And the current option has any votes, it's the new highest.
- if(vote_count > 0)
- winners += option
- highest_vote = vote_count
+ if(vote_count < highest_vote)
continue
- // If we're greater than, and NOT equal to, the highest vote,
- // we are the new supreme winner - clear all others
if(vote_count > highest_vote)
- winners.Cut()
- winners += option
highest_vote = vote_count
+ current_winners = list(option)
+ continue
+ current_winners += option
- // If we're equal to the highest vote, we tie for winner
- else if(vote_count == highest_vote)
- winners += option
+ return length(current_winners) ? current_winners : list()
- return winners
+/// 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.
@@ -159,17 +169,46 @@
* 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)
- if(length(all_winners) <= 0 || !real_winner)
- return span_bold("Vote Result: Inconclusive - No Votes!")
-
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[span_bold(option)]: [choices[option]]"
+ 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)
diff --git a/code/datums/votes/custom_vote.dm b/code/datums/votes/custom_vote.dm
index b610724c4aa8..4dbc984759c8 100644
--- a/code/datums/votes/custom_vote.dm
+++ b/code/datums/votes/custom_vote.dm
@@ -29,6 +29,25 @@
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
@@ -53,8 +72,4 @@
. = ..()
. += "\n[override_question]"
-// There are no winners or losers for custom votes
-/datum/vote/custom_vote/get_winner_text(list/all_winners, real_winner, list/non_voters)
- return "[span_bold("Did not vote:")] [length(non_voters)]"
-
#undef MAX_CUSTOM_VOTE_OPTIONS
diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm
index a3a95649be04..aff2fec0d388 100644
--- a/code/datums/votes/transfer_vote.dm
+++ b/code/datums/votes/transfer_vote.dm
@@ -45,9 +45,9 @@
if(2 to 3)
factor = 1
if(3 to 4)
- factor = 1.2
+ factor = 1.5
else
- factor = 1.4
+ factor = 2
choices[CHOICE_TRANSFER] += round(length(non_voters) * factor)
return ..()
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
From 0b7e8e7d4725c7e4bc065d77117c1d556319b59a Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Thu, 25 Jan 2024 16:19:02 -0600
Subject: [PATCH 07/15] tweaks things
---
code/controllers/subsystem/vote.dm | 2 +-
code/datums/votes/transfer_vote.dm | 23 ++++++++++-------------
2 files changed, 11 insertions(+), 14 deletions(-)
diff --git a/code/controllers/subsystem/vote.dm b/code/controllers/subsystem/vote.dm
index d1d1edceb7e7..03c244ae05d2 100644
--- a/code/controllers/subsystem/vote.dm
+++ b/code/controllers/subsystem/vote.dm
@@ -65,7 +65,7 @@ SUBSYSTEM_DEF(vote)
// 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())
+ 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.
diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm
index aff2fec0d388..057d0d433638 100644
--- a/code/datums/votes/transfer_vote.dm
+++ b/code/datums/votes/transfer_vote.dm
@@ -1,6 +1,9 @@
#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 max(0, (world.time / (1 MINUTES) - 120) / 120)
+
/datum/vote/transfer_vote
name = "Transfer"
default_choices = list(
@@ -36,22 +39,16 @@
return TRUE
/datum/vote/transfer_vote/get_vote_result(list/non_voters)
- var/factor = 1
- switch(world.time / (1 HOURS))
- if(0 to 1)
- factor = 0.5
- if(1 to 2)
- factor = 0.8
- if(2 to 3)
- factor = 1
- if(3 to 4)
- factor = 1.5
- else
- factor = 2
- choices[CHOICE_TRANSFER] += round(length(non_voters) * factor)
+ 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)
+ . = ..()
+ . += "\n"
+ if(TRANSFER_FACTOR)
+ . += "Transfer option was boosted by [round(length(non_voters) * TRANSFER_FACTOR)] non-voters."
+
/datum/vote/transfer_vote/finalize_vote(winning_option)
if(winning_option == CHOICE_CONTINUE)
return
From f10eba003726e1ce00af06ad2352a998e2247e87 Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Thu, 25 Jan 2024 16:20:54 -0600
Subject: [PATCH 08/15] more clarification
---
code/datums/votes/transfer_vote.dm | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm
index 057d0d433638..5ef4c6bd749f 100644
--- a/code/datums/votes/transfer_vote.dm
+++ b/code/datums/votes/transfer_vote.dm
@@ -47,7 +47,7 @@
. = ..()
. += "\n"
if(TRANSFER_FACTOR)
- . += "Transfer option was boosted by [round(length(non_voters) * TRANSFER_FACTOR)] non-voters."
+ . += "Transfer option was boosted by [round(length(non_voters) * TRANSFER_FACTOR)] non-voters due to round length."
/datum/vote/transfer_vote/finalize_vote(winning_option)
if(winning_option == CHOICE_CONTINUE)
From 7031157c5f6f5f46431405742f3b01b00733ae54 Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Thu, 25 Jan 2024 16:21:11 -0600
Subject: [PATCH 09/15] forgot to undefine
---
code/datums/votes/transfer_vote.dm | 2 ++
1 file changed, 2 insertions(+)
diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm
index 5ef4c6bd749f..7be6ab9fed9e 100644
--- a/code/datums/votes/transfer_vote.dm
+++ b/code/datums/votes/transfer_vote.dm
@@ -59,5 +59,7 @@
CRASH("[type] wasn't passed a valid winning choice. (Got: [winning_option || "null"])")
+#undef TRANSFER_FACTOR
+
#undef CHOICE_TRANSFER
#undef CHOICE_CONTINUE
From e73fc407acb1b6fcb76d068ad8fd697eb21d0b56 Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Thu, 25 Jan 2024 16:25:36 -0600
Subject: [PATCH 10/15] final touches
---
code/datums/votes/transfer_vote.dm | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm
index 7be6ab9fed9e..1b0a15a00c83 100644
--- a/code/datums/votes/transfer_vote.dm
+++ b/code/datums/votes/transfer_vote.dm
@@ -45,9 +45,10 @@
/datum/vote/transfer_vote/get_winner_text(list/all_winners, real_winner, list/non_voters)
. = ..()
- . += "\n"
- if(TRANSFER_FACTOR)
- . += "Transfer option was boosted by [round(length(non_voters) * TRANSFER_FACTOR)] non-voters due to round length."
+ var/boost = round(length(non_voters) * TRANSFER_FACTOR)
+ if(boost)
+ . += "\n"
+ . += "Transfer option was boosted by [boost] non-voters due to round length."
/datum/vote/transfer_vote/finalize_vote(winning_option)
if(winning_option == CHOICE_CONTINUE)
From 6db8aabee3a911921356e34d8b3c25f124e65b9d Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Fri, 26 Jan 2024 12:40:12 -0600
Subject: [PATCH 11/15] readds nonvoter count and prettifies tsx file
---
code/datums/votes/_vote_datum.dm | 2 +
tgui/packages/tgui/interfaces/VotePanel.tsx | 87 ++++++++++++++-------
2 files changed, 62 insertions(+), 27 deletions(-)
diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm
index cc35d3d2f841..82f0cbf4c163 100644
--- a/code/datums/votes/_vote_datum.dm
+++ b/code/datums/votes/_vote_datum.dm
@@ -207,6 +207,8 @@
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
diff --git a/tgui/packages/tgui/interfaces/VotePanel.tsx b/tgui/packages/tgui/interfaces/VotePanel.tsx
index 53a2481d495e..eda355382768 100644
--- a/tgui/packages/tgui/interfaces/VotePanel.tsx
+++ b/tgui/packages/tgui/interfaces/VotePanel.tsx
@@ -1,5 +1,14 @@
import { BooleanLike } from 'common/react';
-import { Box, Icon, Stack, Button, Section, NoticeBox, LabeledList, Collapsible } from '../components';
+import {
+ Box,
+ Icon,
+ Stack,
+ Button,
+ Section,
+ NoticeBox,
+ LabeledList,
+ Collapsible,
+} from '../components';
import { Window } from '../layouts';
import { useBackend } from '../backend';
@@ -59,7 +68,11 @@ export const VotePanel = (props, context) => {
*/
let windowTitle = 'Vote';
if (currentVote) {
- windowTitle += ': ' + (currentVote.question || currentVote.vote.name).replace(/^\w/, (c) => c.toUpperCase());
+ windowTitle +=
+ ': ' +
+ (currentVote.question || currentVote.vote.name).replace(/^\w/, (c) =>
+ c.toUpperCase()
+ );
}
return (
@@ -90,7 +103,7 @@ const VoteOptions = (props, context) => {
- { possibleVotes.map(option => (
+ {possibleVotes.map((option) => (
{!!user.isLowerAdmin && option.config !== VoteConfig.None && (
{
color="red"
checked={option.config === VoteConfig.Enabled}
disabled={!user.isUpperAdmin}
- content={option.config === VoteConfig.Enabled ? 'Enabled' : 'Disabled'}
- onClick={() => act('toggleVote', {
- voteName: option.name,
- })} />
+ content={
+ option.config === VoteConfig.Enabled
+ ? 'Enabled'
+ : 'Disabled'
+ }
+ onClick={() =>
+ act('toggleVote', {
+ voteName: option.name,
+ })
+ }
+ />
)}
act('callVote', {
- voteName: option.name,
- })} />
+ onClick={() =>
+ act('callVote', {
+ voteName: option.name,
+ })
+ }
+ />
))}
@@ -127,7 +150,11 @@ const VotersList = (props, context) => {
return (
-
+
{data.voting.map((voter) => {
return {voter} ;
@@ -156,7 +183,7 @@ const ChoicesPanel = (props, context) => {
currentVote.choices.length !== 0 &&
currentVote.countMethod === VoteSystem.VOTE_SINGLE ? (
- {currentVote.choices.map(choice => (
+ {currentVote.choices.map((choice) => (
c.toUpperCase())}
@@ -166,19 +193,21 @@ const ChoicesPanel = (props, context) => {
disabled={user.singleSelection === choice.name}
onClick={() => {
act('voteSingle', { voteOption: choice.name });
- }}>
+ }}
+ >
Vote
- }>
- {user.singleSelection
- && choice.name === user.singleSelection && (
-
- )}
+ }
+ >
+ {user.singleSelection &&
+ choice.name === user.singleSelection && (
+
+ )}
{choice.votes} Votes
@@ -202,10 +231,12 @@ const ChoicesPanel = (props, context) => {
{
act('voteMulti', { voteOption: choice.name });
- }}>
+ }}
+ >
Vote
- }>
+ }
+ >
{user.multiSelection &&
user.multiSelection[user.ckey.concat(choice.name)] === 1 ? (
{
- Time Remaining:
+
+ Time Remaining:
{currentVote?.timeRemaining || 0}s
{!!user.isLowerAdmin && (
act('cancel')}>
+ onClick={() => act('cancel')}
+ >
Cancel Vote
)}
From 230f41070ec200f33b77bbb136d38e7724300140 Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Fri, 26 Jan 2024 14:30:00 -0600
Subject: [PATCH 12/15] restyles
---
code/datums/votes/_vote_datum.dm | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/code/datums/votes/_vote_datum.dm b/code/datums/votes/_vote_datum.dm
index 82f0cbf4c163..c34d4600d2b0 100644
--- a/code/datums/votes/_vote_datum.dm
+++ b/code/datums/votes/_vote_datum.dm
@@ -207,7 +207,7 @@
percentage_text = " 0%"
returned_text += "[percentage_text] | [span_bold(option)]: [choices[option]]"
- returned_text += "\n [span_bold("Non-Voters")]: [length(non_voters)]"
+ 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
From 91c1baf0686459eff770e8f43596884183dd6ce9 Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Fri, 26 Jan 2024 15:10:33 -0600
Subject: [PATCH 13/15] more adjustments
---
code/datums/votes/transfer_vote.dm | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm
index 1b0a15a00c83..00aa55ee2e4d 100644
--- a/code/datums/votes/transfer_vote.dm
+++ b/code/datums/votes/transfer_vote.dm
@@ -2,7 +2,7 @@
#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 max(0, (world.time / (1 MINUTES) - 120) / 120)
+#define TRANSFER_FACTOR clamp((world.time / (1 MINUTES) - 120) / 240, 0, 1)
/datum/vote/transfer_vote
name = "Transfer"
@@ -48,7 +48,7 @@
var/boost = round(length(non_voters) * TRANSFER_FACTOR)
if(boost)
. += "\n"
- . += "Transfer option was boosted by [boost] non-voters due to round length."
+ . += span_bold("Transfer option was boosted by [boost] non-voters ([round(TRANSFER_FACTOR, 0.1)]%) due to round length.")
/datum/vote/transfer_vote/finalize_vote(winning_option)
if(winning_option == CHOICE_CONTINUE)
From 3078f5a1b4e9c1b01caa41329a22c44073b37546 Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Fri, 26 Jan 2024 19:49:10 -0600
Subject: [PATCH 14/15] oops
---
code/datums/votes/transfer_vote.dm | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/code/datums/votes/transfer_vote.dm b/code/datums/votes/transfer_vote.dm
index 00aa55ee2e4d..650c8266f872 100644
--- a/code/datums/votes/transfer_vote.dm
+++ b/code/datums/votes/transfer_vote.dm
@@ -48,7 +48,7 @@
var/boost = round(length(non_voters) * TRANSFER_FACTOR)
if(boost)
. += "\n"
- . += span_bold("Transfer option was boosted by [boost] non-voters ([round(TRANSFER_FACTOR, 0.1)]%) due to round length.")
+ . += 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)
From fa40a55c6c6b386c2fc403b7ea64ee8370d50b93 Mon Sep 17 00:00:00 2001
From: Mark Suckerberg
Date: Sat, 10 Feb 2024 17:34:17 -0600
Subject: [PATCH 15/15] fixes angy linters
---
check_regex.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/check_regex.yaml b/check_regex.yaml
index df64dec9aae1..c8fd8f4e11cd 100644
--- a/check_regex.yaml
+++ b/check_regex.yaml
@@ -38,7 +38,7 @@ standards:
- exactly:
[
- 295,
+ 293,
"non-bitwise << uses",
'(?