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).
This commit is contained in:
MrMelbert
2022-05-08 13:52:29 -05:00
committed by GitHub
parent 51ef5ecfcb
commit 6e098e2dba
10 changed files with 904 additions and 478 deletions

View File

@@ -176,7 +176,8 @@
integer = FALSE integer = FALSE
min_val = 0 min_val = 0
/// vote does not default to nochange/norestart. /// 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/flag/default_no_vote
/// Prevents dead people from voting. /// Prevents dead people from voting.

View File

@@ -395,24 +395,25 @@ GLOBAL_LIST_EMPTY(the_station_areas)
/datum/controller/subsystem/mapping/proc/mapvote() /datum/controller/subsystem/mapping/proc/mapvote()
if(map_voted || SSmapping.next_map_config) //If voted or set by other means. if(map_voted || SSmapping.next_map_config) //If voted or set by other means.
return return
if(SSvote.mode) //Theres already a vote running, default to rotation. if(SSvote.current_vote) //Theres already a vote running, default to rotation.
maprotate() maprotate()
SSvote.initiate_vote("map", "automatic map rotation") return
SSvote.initiate_vote(/datum/vote/map_vote, "automatic map rotation")
/datum/controller/subsystem/mapping/proc/changemap(datum/map_config/VM) /datum/controller/subsystem/mapping/proc/changemap(datum/map_config/change_to)
if(!VM.MakeNextMap()) if(!change_to.MakeNextMap())
next_map_config = load_default_map_config() next_map_config = load_default_map_config()
message_admins("Failed to set new map with next_map.json for [VM.map_name]! Using default as backup!") message_admins("Failed to set new map with next_map.json for [change_to.map_name]! Using default as backup!")
return return
if (VM.config_min_users > 0 && GLOB.clients.len < VM.config_min_users) if (change_to.config_min_users > 0 && GLOB.clients.len < change_to.config_min_users)
message_admins("[VM.map_name] was chosen for the next map, despite there being less current players than its set minimum population range!") message_admins("[change_to.map_name] was chosen for the next map, despite there being less current players than its set minimum population range!")
log_game("[VM.map_name] was chosen for the next map, despite there being less current players than its set minimum population range!") log_game("[change_to.map_name] was chosen for the next map, despite there being less current players than its set minimum population range!")
if (VM.config_max_users > 0 && GLOB.clients.len > VM.config_max_users) if (change_to.config_max_users > 0 && GLOB.clients.len > change_to.config_max_users)
message_admins("[VM.map_name] was chosen for the next map, despite there being more current players than its set maximum population range!") message_admins("[change_to.map_name] was chosen for the next map, despite there being more current players than its set maximum population range!")
log_game("[VM.map_name] was chosen for the next map, despite there being more current players than its set maximum population range!") log_game("[change_to.map_name] was chosen for the next map, despite there being more current players than its set maximum population range!")
next_map_config = VM next_map_config = change_to
return TRUE return TRUE
/datum/controller/subsystem/mapping/proc/preloadTemplates(path = "_maps/templates/") //see master controller setup /datum/controller/subsystem/mapping/proc/preloadTemplates(path = "_maps/templates/") //see master controller setup

View File

@@ -1,269 +1,264 @@
/// Define to mimic a span macro but for the purple font that vote specifically uses.
#define vote_font(text) ("<font color='purple'>" + text + "</font>")
SUBSYSTEM_DEF(vote) SUBSYSTEM_DEF(vote)
name = "Vote" name = "Vote"
wait = 10 wait = 1 SECONDS
flags = SS_KEEP_TIMING
flags = SS_KEEP_TIMING|SS_NO_INIT
runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT
var/list/choices = list() /// A list of all generated action buttons
var/list/choice_by_ckey = list() var/list/datum/action/generated_actions = list()
var/list/generated_actions = list() /// All votes that we can possible vote for.
var/initiator var/list/datum/vote/possible_votes = list()
var/mode /// The vote we're currently voting on.
var/question var/datum/vote/current_vote
var/started_time /// A list of all ckeys who have voted for the current vote.
var/time_remaining
var/list/voted = list() var/list/voted = list()
/// A list of all ckeys currently voting for the current vote.
var/list/voting = list() var/list/voting = list()
/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
possible_votes[vote.name] = vote
return ..()
// Called by master_controller // Called by master_controller
/datum/controller/subsystem/vote/fire() /datum/controller/subsystem/vote/fire()
if(!mode) if(!current_vote)
return return
time_remaining = round((started_time + CONFIG_GET(number/vote_period) - world.time)/10) current_vote.time_remaining = round((current_vote.started_time + CONFIG_GET(number/vote_period) - world.time) / 10)
if(time_remaining < 0) if(current_vote.time_remaining < 0)
result() process_vote_result()
SStgui.close_uis(src) SStgui.close_uis(src)
reset() reset()
/// Resets all of our vars after votes conclude / are cancelled.
/datum/controller/subsystem/vote/proc/reset() /datum/controller/subsystem/vote/proc/reset()
choices.Cut()
choice_by_ckey.Cut()
initiator = null
mode = null
question = null
time_remaining = 0
voted.Cut() voted.Cut()
voting.Cut() voting.Cut()
remove_action_buttons() current_vote?.reset()
current_vote = null
/datum/controller/subsystem/vote/proc/get_result() for(var/datum/action/vote/voting_action as anything in generated_actions)
//get the highest number of votes if(QDELETED(voting_action))
var/greatest_votes = 0 continue
var/total_votes = 0 voting_action.Remove(voting_action.owner)
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 == "map")
for (var/non_voter_ckey in non_voters)
var/client/C = non_voters[non_voter_ckey]
var/preferred_map = C.prefs.read_preference(/datum/preference/choiced/preferred_map)
if(isnull(global.config.defaultmap))
continue
if(!preferred_map)
preferred_map = global.config.defaultmap.map_name
choices[preferred_map] += 1
greatest_votes = max(greatest_votes, choices[preferred_map])
. = list()
if(greatest_votes)
for(var/option in choices)
if(choices[option] == greatest_votes)
. += option
return .
/datum/controller/subsystem/vote/proc/announce_result() generated_actions.Cut()
var/list/winners = get_result()
var/text
if(winners.len > 0)
if(question)
text += "<b>[question]</b>"
else
text += "<b>[capitalize(mode)] Vote</b>"
for(var/i in 1 to choices.len)
var/votes = choices[choices[i]]
if(!votes)
votes = 0
text += "\n<b>[choices[i]]:</b> [votes]"
if(mode != "custom")
if(winners.len > 1)
text = "\n<b>Vote Tied Between:</b>"
for(var/option in winners)
text += "\n\t[option]"
. = pick(winners)
text += "\n<b>Vote Result: [.]</b>"
else
text += "\n<b>Did not vote:</b> [GLOB.clients.len-voted.len]"
else
text += "<b>Vote Result: Inconclusive - No Votes!</b>"
log_vote(text)
remove_action_buttons()
to_chat(world, "\n<span class='infoplain'><font color='purple'>[text]</font></span>")
return .
/datum/controller/subsystem/vote/proc/result() SStgui.update_uis(src)
. = announce_result()
var/restart = FALSE
if(.)
switch(mode)
if("restart")
if(. == "Restart Round")
restart = TRUE
if("map")
SSmapping.changemap(global.config.maplist[.])
SSmapping.map_voted = TRUE
if(restart)
var/active_admins = FALSE
for(var/client/C in GLOB.admins + GLOB.deadmins)
if(!C.is_afk() && check_rights_for(C, R_SERVER))
active_admins = TRUE
break
if(!active_admins)
// No delay in case the restart is due to lag
SSticker.Reboot("Restart vote successful.", "restart vote", 1)
else
to_chat(world, span_boldannounce("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 . /**
* 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
/datum/controller/subsystem/vote/proc/submit_vote(vote)
if(!mode)
return FALSE
if(CONFIG_GET(flag/no_dead_vote) && usr.stat == DEAD && !usr.client.holder)
return FALSE
if(!vote || vote < 1 || vote > choices.len)
return FALSE
// If user has already voted, remove their specific vote // If user has already voted, remove their specific vote
if(usr.ckey in voted) if(voter.ckey in current_vote.choices_by_ckey)
choices[choices[choice_by_ckey[usr.ckey]]]-- var/their_old_vote = current_vote.choices_by_ckey[voter.ckey]
current_vote.choices[their_old_vote]--
else else
voted += usr.ckey voted += voter.ckey
choice_by_ckey[usr.ckey] = vote
choices[choices[vote]]++
return vote
/datum/controller/subsystem/vote/proc/initiate_vote(vote_type, initiator_key) current_vote.choices_by_ckey[voter.ckey] = their_vote
//Server is still intializing. 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(!MC_RUNNING(init_stage))
to_chat(usr, span_warning("Cannot start vote, server is not done initializing.")) if(vote_initiator)
to_chat(vote_initiator, span_warning("You cannot start vote now, the server is not done initializing."))
return FALSE return FALSE
var/lower_admin = FALSE
var/ckey = ckey(initiator_key)
if(GLOB.admin_datums[ckey])
lower_admin = TRUE
if(!mode) // Check if we have unlimited voting power.
if(started_time) // Admin started (or forced) voted will go through even if there's an ongoing vote,
var/next_allowed_time = (started_time + CONFIG_GET(number/vote_delay)) // if voting is on cooldown, or regardless if a vote is config disabled (in some cases)
if(mode) var/unlimited_vote_power = forced || !!GLOB.admin_datums[vote_initiator?.ckey]
to_chat(usr, span_warning("There is already a vote in progress! please wait for it to finish."))
return FALSE
if(next_allowed_time > world.time && !lower_admin)
to_chat(usr, 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
reset() if(current_vote && !unlimited_vote_power)
switch(vote_type) if(vote_initiator)
if("restart") to_chat(vote_initiator, span_warning("There is already a vote in progress! Please wait for it to finish."))
choices.Add("Restart Round","Continue Playing") return FALSE
if("map")
if(!lower_admin && SSmapping.map_voted)
to_chat(usr, span_warning("The next map has already been selected."))
return FALSE
// Randomizes the list so it isn't always METASTATION
var/list/maps = list()
for(var/map in global.config.maplist)
var/datum/map_config/VM = config.maplist[map]
if(!VM.votable || (VM.map_name in SSpersistence.blocked_maps))
continue
if (VM.config_min_users > 0 && GLOB.clients.len < VM.config_min_users)
continue
if (VM.config_max_users > 0 && GLOB.clients.len > VM.config_max_users)
continue
maps += VM.map_name
shuffle_inplace(maps)
for(var/valid_map in maps)
choices.Add(valid_map)
if("custom")
question = tgui_input_text(usr, "What is the vote for?", "Custom Vote")
if(!question)
return FALSE
for(var/i in 1 to 10)
var/option = tgui_input_text(usr, "Please enter an option or hit cancel to finish", "Options", max_length = MAX_NAME_LEN)
if(!option || mode || !usr.client)
break
choices.Add(capitalize(option))
else
return FALSE
mode = vote_type
initiator = initiator_key
started_time = world.time
var/text = "[capitalize(mode)] vote started by [initiator || "CentCom"]."
if(mode == "custom")
text += "\n[question]"
log_vote(text)
var/vp = CONFIG_GET(number/vote_period)
to_chat(world, "\n<span class='infoplain'><font color='purple'><b>[text]</b>\nType <b>vote</b> or click <a href='byond://winset?command=vote'>here</a> to place your votes.\nYou have [DisplayTimeText(vp)] to vote.</font></span>")
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
if(C.prefs.toggles & SOUND_ANNOUNCEMENTS)
SEND_SOUND(C, sound('sound/misc/bloop.ogg'))
return TRUE
return FALSE
/mob/verb/vote() // Get our actual datum
set category = "OOC" var/datum/vote/to_vote
set name = "Vote" // If we were passed a path: find the path in possible_votes
SSvote.ui_interact(usr) if(ispath(vote_type, /datum/vote))
var/datum/vote/vote_path = vote_type
to_vote = possible_votes[initial(vote_path.name)]
// 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 = 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 <b>vote</b> or click <a href='byond://winset?command=vote'>here</a> 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() /datum/controller/subsystem/vote/ui_state()
return GLOB.always_state return GLOB.always_state
/datum/controller/subsystem/vote/ui_interact(mob/user, datum/tgui/ui) /datum/controller/subsystem/vote/ui_interact(mob/user, datum/tgui/ui)
// Tracks who is voting // Tracks who is currently voting
if(!(user.client?.ckey in voting)) voting |= user.client?.ckey
voting += user.client?.ckey
ui = SStgui.try_update_ui(user, src, ui) ui = SStgui.try_update_ui(user, src, ui)
if(!ui) if(!ui)
ui = new(user, src, "Vote") ui = new(user, src, "VotePanel")
ui.open() ui.open()
/datum/controller/subsystem/vote/ui_data(mob/user) /datum/controller/subsystem/vote/ui_data(mob/user)
var/list/data = list( var/list/data = list()
"allow_vote_map" = CONFIG_GET(flag/allow_vote_map),
"allow_vote_restart" = CONFIG_GET(flag/allow_vote_restart), var/is_lower_admin = !!user.client?.holder
"choices" = list(), var/is_upper_admin = check_rights_for(user.client, R_ADMIN)
"lower_admin" = !!user.client?.holder,
"mode" = mode, data["user"] = list(
"question" = question, "isLowerAdmin" = is_lower_admin,
"selected_choice" = choice_by_ckey[user.client?.ckey], "isUpperAdmin" = is_upper_admin,
"time_remaining" = time_remaining, // What the current user has selected in any ongoing votes.
"upper_admin" = check_rights_for(user.client, R_ADMIN), "selectedChoice" = current_vote?.choices_by_ckey[user.client?.ckey],
"voting" = list(),
) )
if(!!user.client?.holder) data["voting"]= is_lower_admin ? voting : list()
data["voting"] = voting
for(var/key in choices) var/list/all_vote_data = list()
data["choices"] += list(list( for(var/vote_name in possible_votes)
"name" = key, var/datum/vote/vote = possible_votes[vote_name]
"votes" = choices[key] || 0 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 return data
@@ -272,66 +267,72 @@ SUBSYSTEM_DEF(vote)
if(.) if(.)
return return
var/upper_admin = FALSE var/mob/voter = usr
if(usr.client.holder)
if(check_rights_for(usr.client, R_ADMIN))
upper_admin = TRUE
switch(action) switch(action)
if("cancel") if("cancel")
if(usr.client.holder) if(!voter.client?.holder)
usr.log_message("[key_name_admin(usr)] cancelled a vote.", LOG_ADMIN) return
message_admins("[key_name_admin(usr)] has cancelled the current vote.")
reset()
if("toggle_restart")
if(usr.client.holder && upper_admin)
CONFIG_SET(flag/allow_vote_restart, !CONFIG_GET(flag/allow_vote_restart))
if("toggle_map")
if(usr.client.holder && upper_admin)
CONFIG_SET(flag/allow_vote_map, !CONFIG_GET(flag/allow_vote_map))
if("restart")
if(CONFIG_GET(flag/allow_vote_restart) || usr.client.holder)
initiate_vote("restart",usr.key)
if("map")
if(CONFIG_GET(flag/allow_vote_map) || usr.client.holder)
initiate_vote("map",usr.key)
if("custom")
if(usr.client.holder)
initiate_vote("custom",usr.key)
if("vote")
submit_vote(round(text2num(params["index"])))
return TRUE
/datum/controller/subsystem/vote/proc/remove_action_buttons() voter.log_message("[key_name_admin(voter)] cancelled a vote.", LOG_ADMIN)
for(var/v in generated_actions) message_admins("[key_name_admin(voter)] has cancelled the current vote.")
var/datum/action/vote/V = v reset()
if(!QDELETED(V)) return TRUE
V.remove_from_client()
V.Remove(V.owner) if("toggleVote")
generated_actions = list() 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) /datum/controller/subsystem/vote/ui_close(mob/user)
voting -= user.client?.ckey 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"
SSvote.ui_interact(usr)
/// Datum action given to mobs that allows players to vote on the current vote.
/datum/action/vote /datum/action/vote
name = "Vote!" name = "Vote!"
button_icon_state = "vote" button_icon_state = "vote"
/datum/action/vote/Trigger(trigger_flags)
if(owner)
owner.vote()
remove_from_client()
Remove(owner)
/datum/action/vote/IsAvailable() /datum/action/vote/IsAvailable()
return TRUE return TRUE // Democracy is always available to the free people
/datum/action/vote/proc/remove_from_client() /datum/action/vote/Trigger(trigger_flags)
if(!owner) . = ..()
if(!.)
return return
if(owner.client)
owner.client.player_details.player_actions -= src owner.vote()
else if(owner.ckey) Remove(owner)
var/datum/player_details/P = GLOB.player_details[owner.ckey]
if(P) // We also need to remove our action from the player actions when we're cleaning up.
P.player_actions -= src /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

View File

@@ -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/bloop.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

View File

@@ -0,0 +1,53 @@
/// 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 = 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]"
// 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

View File

@@ -0,0 +1,87 @@
/datum/vote/map_vote
name = "Map"
/datum/vote/map_vote/New()
. = ..()
default_choices = list()
// Fill in our default choices with all of the maps in our map config, if they are votable and not blocked.
var/list/maps = shuffle(global.config.maplist)
for(var/map in maps)
var/datum/map_config/possible_config = config.maplist[map]
if(!possible_config.votable || (possible_config.map_name in SSpersistence.blocked_maps))
continue
default_choices += possible_config.map_name
/datum/vote/map_vote/create_vote()
. = ..()
// Before we create a vote, remove all maps from our choices that are outside of our population range.
// Note that this can result in zero remaining choices for our vote, which is not ideal (but technically fine).
for(var/map in choices)
var/datum/map_config/possible_config = config.maplist[map]
if(possible_config.config_min_users > 0 && GLOB.clients.len < possible_config.config_min_users)
choices -= map
else if(possible_config.config_max_users > 0 && GLOB.clients.len > possible_config.config_max_users)
choices -= map
/datum/vote/map_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_map, !CONFIG_GET(flag/allow_vote_map))
return TRUE
/datum/vote/map_vote/is_config_enabled()
return CONFIG_GET(flag/allow_vote_map)
/datum/vote/map_vote/can_be_initiated(mob/by_who, forced = FALSE)
. = ..()
if(!.)
return FALSE
if(forced)
return TRUE
if(!CONFIG_GET(flag/allow_vote_map))
if(by_who)
to_chat(by_who, span_warning("Map voting is disabled."))
return FALSE
if(SSmapping.map_voted)
if(by_who)
to_chat(by_who, span_warning("The next map has already been selected."))
return FALSE
return TRUE
/datum/vote/map_vote/get_vote_result(list/non_voters)
// Even if we have default no vote off,
// if our default map is null for some reason, we shouldn't continue
if(CONFIG_GET(flag/default_no_vote) || isnull(global.config.defaultmap))
return ..()
for(var/non_voter_ckey in non_voters)
var/client/non_voter_client = non_voters[non_voter_ckey]
// Non-voters will have their preferred map voted for automatically.
var/their_preferred_map = non_voter_client?.prefs.read_preference(/datum/preference/choiced/preferred_map)
// If the non-voter's preferred map is null for some reason, we just use the default map.
var/voting_for = their_preferred_map || global.config.defaultmap.map_name
if(voting_for in choices)
choices[voting_for] += 1
return ..()
/datum/vote/map_vote/finalize_vote(winning_option)
var/datum/map_config/winning_map = global.config.maplist[winning_option]
if(!istype(winning_map))
CRASH("[type] wasn't passed a valid winning map choice. (Got: [winning_option || "null"] - [winning_map || "null"])")
SSmapping.changemap(winning_map)
SSmapping.map_voted = TRUE

View File

@@ -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

View File

@@ -1129,6 +1129,10 @@
#include "code\datums\status_effects\debuffs\drunk.dm" #include "code\datums\status_effects\debuffs\drunk.dm"
#include "code\datums\status_effects\debuffs\fire_stacks.dm" #include "code\datums\status_effects\debuffs\fire_stacks.dm"
#include "code\datums\status_effects\debuffs\speech_debuffs.dm" #include "code\datums\status_effects\debuffs\speech_debuffs.dm"
#include "code\datums\votes\_vote_datum.dm"
#include "code\datums\votes\custom_vote.dm"
#include "code\datums\votes\map_vote.dm"
#include "code\datums\votes\restart_vote.dm"
#include "code\datums\weather\weather.dm" #include "code\datums\weather\weather.dm"
#include "code\datums\weather\weather_types\ash_storm.dm" #include "code\datums\weather\weather_types\ash_storm.dm"
#include "code\datums\weather\weather_types\floor_is_lava.dm" #include "code\datums\weather\weather_types\floor_is_lava.dm"

View File

@@ -1,194 +0,0 @@
import { useBackend } from '../backend';
import { Box, Icon, Stack, Button, Section, NoticeBox, LabeledList, Collapsible } from '../components';
import { Window } from '../layouts';
export const Vote = (props, context) => {
const { data } = useBackend(context);
const { mode, question, lower_admin } = data;
/**
* Adds the voting type to title if there is an ongoing vote.
*/
let windowTitle = 'Vote';
if (mode) {
windowTitle += ': ' + (question || mode).replace(/^\w/, (c) => c.toUpperCase());
}
return (
<Window resizable title={windowTitle} width={400} height={500}>
<Window.Content>
<Stack fill vertical>
<Section title="Create Vote">
<VoteOptions />
{!!lower_admin && <VotersList />}
</Section>
<ChoicesPanel />
<TimePanel />
</Stack>
</Window.Content>
</Window>
);
};
/**
* 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 {
allow_vote_restart,
allow_vote_map,
lower_admin,
upper_admin,
} = data;
return (
<Stack.Item>
<Collapsible title="Start a Vote">
<Stack justify="space-between">
<Stack.Item>
<Stack vertical>
<Stack.Item>
{!!lower_admin && (
<Button.Checkbox
mr={!allow_vote_map ? 1 : 1.6}
color="red"
checked={!!allow_vote_map}
disabled={!upper_admin}
onClick={() => act('toggle_map')}>
{allow_vote_map ? 'Enabled' : 'Disabled'}
</Button.Checkbox>
)}
<Button disabled={!allow_vote_map} onClick={() => act('map')}>
Map
</Button>
</Stack.Item>
<Stack.Item>
{!!lower_admin && (
<Button.Checkbox
mr={!allow_vote_restart ? 1 : 1.6}
color="red"
checked={!!allow_vote_restart}
disabled={!upper_admin}
onClick={() => act('toggle_restart')}>
{allow_vote_restart ? 'Enabled' : 'Disabled'}
</Button.Checkbox>
)}
<Button
disabled={!allow_vote_restart}
onClick={() => act('restart')}>
Restart
</Button>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item>
{!!lower_admin && (
<Button disabled={!lower_admin} onClick={() => act('custom')}>
Create Custom Vote
</Button>
)}
</Stack.Item>
</Stack>
</Collapsible>
</Stack.Item>
);
};
/**
* View Voters by ckey. Admin only.
* @returns A collapsible list of voters
*/
const VotersList = (props, context) => {
const { data } = useBackend(context);
const { voting } = data;
return (
<Stack.Item>
<Collapsible title={`View Voters${voting.length ? `: ${voting.length}` : ""}`}>
<Section height={8} fill scrollable>
{voting.map((voter) => {
return <Box key={voter}>{voter}</Box>;
})}
</Section>
</Collapsible>
</Stack.Item>
);
};
/**
* 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 { choices, selected_choice } = data;
return (
<Stack.Item grow>
<Section fill scrollable title="Choices">
{choices.length !== 0 ? (
<LabeledList>
{choices.map((choice, i) => (
<Box key={choice.id}>
<LabeledList.Item
label={choice.name.replace(/^\w/, (c) => c.toUpperCase())}
textAlign="right"
buttons={
<Button
disabled={i === selected_choice - 1}
onClick={() => {
act('vote', { index: i + 1 });
}}>
Vote
</Button>
}>
{i === selected_choice - 1 && (
<Icon
alignSelf="right"
mr={2}
color="green"
name="vote-yea"
/>
)}
{choice.votes} Votes
</LabeledList.Item>
<LabeledList.Divider />
</Box>
))}
</LabeledList>
) : (
<NoticeBox>No choices available!</NoticeBox>
)}
</Section>
</Stack.Item>
);
};
/**
* 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 { lower_admin, time_remaining } = data;
return (
<Stack.Item mt={1}>
<Section>
<Stack justify="space-between">
<Box fontSize={1.5}>Time Remaining: {time_remaining || 0}s</Box>
{!!lower_admin && (
<Button
color="red"
disabled={!lower_admin}
onClick={() => act('cancel')}>
Cancel Vote
</Button>
)}
</Stack>
</Section>
</Stack.Item>
);
};

View File

@@ -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<Data>(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 (
<Window resizable title={windowTitle} width={400} height={500}>
<Window.Content>
<Stack fill vertical>
<Section title="Create Vote">
<VoteOptions />
{!!user.isLowerAdmin && currentVote && <VotersList />}
</Section>
<ChoicesPanel />
<TimePanel />
</Stack>
</Window.Content>
</Window>
);
};
/**
* 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<Data>(context);
const { possibleVotes, user } = data;
return (
<Stack.Item>
<Collapsible title="Start a Vote">
<Stack vertical justify="space-between">
{ possibleVotes.map(option => (
<Stack.Item key={option.name}>
{!!user.isLowerAdmin && option.config !== VoteConfig.None && (
<Button.Checkbox
mr={option.config === VoteConfig.Disabled ? 1 : 1.6}
color="red"
checked={option.config === VoteConfig.Enabled}
disabled={!user.isUpperAdmin}
content={option.config === VoteConfig.Enabled ? 'Enabled' : 'Disabled'}
onClick={() => act('toggleVote', {
voteName: option.name,
})} />
)}
<Button
disabled={!option.canBeInitiated}
content={option.name}
onClick={() => act('callVote', {
voteName: option.name,
})} />
</Stack.Item>
))}
</Stack>
</Collapsible>
</Stack.Item>
);
};
/**
* View Voters by ckey. Admin only.
* @returns A collapsible list of voters
*/
const VotersList = (props, context) => {
const { data } = useBackend<Data>(context);
return (
<Stack.Item>
<Collapsible title={`View Voters${data.voting.length ? `: ${data.voting.length}` : ""}`}>
<Section height={8} fill scrollable>
{data.voting.map((voter) => {
return <Box key={voter}>{voter}</Box>;
})}
</Section>
</Collapsible>
</Stack.Item>
);
};
/**
* 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<Data>(context);
const { currentVote, user } = data;
return (
<Stack.Item grow>
<Section fill scrollable title="Choices">
{currentVote && currentVote.choices.length !== 0 ? (
<LabeledList>
{currentVote.choices.map(choice => (
<Box key={choice.name}>
<LabeledList.Item
label={choice.name.replace(/^\w/, (c) => c.toUpperCase())}
textAlign="right"
buttons={
<Button
disabled={user.selectedChoice === choice.name}
onClick={() => {
act('vote', { voteOption: choice.name });
}}>
Vote
</Button>
}>
{user.selectedChoice
&& choice.name === user.selectedChoice && (
<Icon
alignSelf="right"
mr={2}
color="green"
name="vote-yea"
/>
)}
{choice.votes} Votes
</LabeledList.Item>
<LabeledList.Divider />
</Box>
))}
</LabeledList>
) : (
<NoticeBox>{currentVote ? "No choices available!" : "No vote active!"}</NoticeBox>
)}
</Section>
</Stack.Item>
);
};
/**
* 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<Data>(context);
const { currentVote, user } = data;
return (
<Stack.Item mt={1}>
<Section>
<Stack justify="space-between">
<Box fontSize={1.5}>Time Remaining:&nbsp;
{currentVote?.timeRemaining || 0}s
</Box>
{!!user.isLowerAdmin && (
<Button
color="red"
disabled={!user.isLowerAdmin || !currentVote}
onClick={() => act('cancel')}>
Cancel Vote
</Button>
)}
</Stack>
</Section>
</Stack.Item>
);
};