Files
Bubberstation/code/controllers/subsystem/polling.dm
SkyratBot d1fdafa185 [MIRROR] Valentines Day Rework (Better Late Than Never) (#26567)
* Valentines Day Rework (Better Late Than Never) (#81499)

## About The Pull Request

Big changes:

- Participation is Valentines day requires consent, as consent is
important.
- When the event triggers, all valid players are automatically signed up
to get a random date. However if you're uninterested, you can opt out of
getting a date.
- This uses the same system as ghost role polling, so it's a
non-obstrusive screen alert + chat box entry.

- AIs are now given a zeroth law to protect their date.
   - This does not override existing zeroth laws (for malf ais).
- This zeroth is law is worded in a way such that they are not
effectively malf AIs. Their other laws still apply, but not for
situations pertaining to their date.

- Cyborgs are desynced from AIs and are given similar zeroth laws to
protect their date.

![image](https://github.com/tgstation/tgstation/assets/51863163/0d1cca3e-f483-484c-90a8-9bb5492e2c69)

Small changes:

- Valentines cards are now paper. Meaning you can write on them, stamp
them, or yes, burn them.

- Third wheeling is more codified than before. Third wheels get their
own antag datum type.

- The antag panel listing in roundend takes up significantly less room
for each date. Additionally, dates are now paired up with each other.

- Adds implementations for getting pronouns from mind datums.

![image](https://github.com/tgstation/tgstation/assets/51863163/2107e7d2-6197-4f64-9245-54037ca6c0ec)

## Why It's Good For The Game

It's 2024 and our Valentines day is sooo 2012.

I'm a big fan of Valentines Day, personally - it gives me the
opportunity to mess around with another player that I probably would not
otherwise mess around with, getting into shenanigans I would not
otherwise.

But as the years have gone by it's gotten pretty lackluster. Some people
like it as much as I do, but others ditch it entirely and ignore the
objectives.

And if you get paired with someone ignoring it, well, now you're out of
luck!

This is something I'm aiming to rectify by making it opt-in when it
triggers rather than forced. All the people participating will be
guaranteed to get someone who cares about the event as much, which makes
it more fun.

As for the silicon changes, there's been lots of confusion around
silicons and their dates, so I thought I'd fix it here as well.

Also, better late than never?

## Changelog

🆑 Melbert
add: Valentines Day now polls all players for candidates when it
triggers rather than forcing all players to be a Valentine. Consent is
important.
add: Valentine silicons now gain special laws pertaining to their date.
qol: Valentines Cards are now paper, so you can write on them, stamp
them, or burn them.
qol: Valentine's roundend report no longer takes up a massive amount of
space and also no longer sound so, so weird.
/🆑

* Valentines Day Rework (Better Late Than Never)

---------

Co-authored-by: MrMelbert <51863163+MrMelbert@users.noreply.github.com>
2024-02-19 01:36:47 +01:00

275 lines
12 KiB
Plaintext

SUBSYSTEM_DEF(polling)
name = "Polling"
flags = SS_BACKGROUND | SS_NO_INIT
wait = 1 SECONDS
runlevels = RUNLEVEL_GAME
/// List of polls currently ongoing, to be checked on next fire()
var/list/datum/candidate_poll/currently_polling
/// Number of polls performed since the start
var/total_polls = 0
/datum/controller/subsystem/polling/fire()
if(!currently_polling) // if polls_active is TRUE then this shouldn't happen, but still..
currently_polling = list()
for(var/datum/candidate_poll/running_poll as anything in currently_polling)
if(running_poll.time_left() <= 0)
polling_finished(running_poll)
/**
* Starts a poll.
*
* Arguments
* * question: Optional, The question to ask the candidates. If null, a default question will be used. ("Do you want to play as role?")
* * role: Optional, An antag role (IE, ROLE_TRAITOR) to pass, it won't show to any candidates who don't have it in their preferences.
* * check_jobban: Optional, What jobban role / flag to check, it won't show to any candidates who have this jobban.
* * poll_time: How long the poll will last.
* * ignore_category: Optional, A poll category. If a candidate has this category in their ignore list, they won't be polled.
* * flash_window: If TRUE, the candidate's window will flash when they're polled.
* * list/group: A list of candidates to poll.
* * pic_source: Optional, An /atom or an /image to display on the poll alert.
* * role_name_text: Optional, A string to display in logging / the (default) question. If null, the role name will be used.
* * list/custom_response_messages: Optional, A list of strings to use as responses to the poll. If null, the default responses will be used. see __DEFINES/polls.dm for valid keys to use.
* * start_signed_up: If TRUE, all candidates will start signed up for the poll, making it opt-out rather than opt-in.
*
* Returns a list of all mobs who signed up for the poll.
*/
/datum/controller/subsystem/polling/proc/poll_candidates(
question,
role,
check_jobban,
poll_time = 30 SECONDS,
ignore_category = null,
flash_window = TRUE,
list/group = null,
pic_source,
role_name_text,
list/custom_response_messages,
start_signed_up = FALSE,
)
RETURN_TYPE(/list/mob)
if(group.len == 0)
return list()
if(role && !role_name_text)
role_name_text = role
if(role_name_text && !question)
question = "Do you want to play as [full_capitalize(role_name_text)]?"
if(!question)
question = "Do you want to play as a special role?"
log_game("Polling candidates [role_name_text ? "for [role_name_text]" : "\"[question]\""] for [DisplayTimeText(poll_time)] seconds")
// Start firing
total_polls++
var/jumpable = isatom(pic_source) ? pic_source : null
var/datum/candidate_poll/new_poll = new(role_name_text, question, poll_time, ignore_category, jumpable, custom_response_messages)
LAZYADD(currently_polling, new_poll)
var/category = "[new_poll.poll_key]_poll_alert"
for(var/mob/candidate_mob as anything in group)
if(!candidate_mob.client)
continue
// Universal opt-out for all players if it's for a role.
if(role && (!candidate_mob.client.prefs.read_preference(/datum/preference/toggle/ghost_roles)))
continue
// Opt-out for admins whom are currently adminned.
if(role && (!candidate_mob.client.prefs.read_preference(/datum/preference/toggle/ghost_roles_as_admin)) && candidate_mob.client.holder)
continue
if(role && !is_eligible(candidate_mob, role, check_jobban, ignore_category))
continue
if(start_signed_up)
new_poll.sign_up(candidate_mob, TRUE)
if(flash_window)
window_flash(candidate_mob.client)
// If we somehow send two polls for the same mob type, but with a duration on the second one shorter than the time left on the first one,
// we need to keep the first one's timeout rather than use the shorter one
var/atom/movable/screen/alert/poll_alert/current_alert = LAZYACCESS(candidate_mob.alerts, category)
var/alert_time = poll_time
var/datum/candidate_poll/alert_poll = new_poll
if(current_alert && current_alert.timeout > (world.time + poll_time - world.tick_lag))
alert_time = current_alert.timeout - world.time + world.tick_lag
alert_poll = current_alert.poll
// Send them an on-screen alert
var/atom/movable/screen/alert/poll_alert/poll_alert_button = candidate_mob.throw_alert(category, /atom/movable/screen/alert/poll_alert, timeout_override = alert_time, no_anim = TRUE)
if(!poll_alert_button)
continue
new_poll.alert_buttons += poll_alert_button
new_poll.RegisterSignal(poll_alert_button, COMSIG_QDELETING, TYPE_PROC_REF(/datum/candidate_poll, clear_alert_ref))
poll_alert_button.icon = ui_style2icon(candidate_mob.client?.prefs?.read_preference(/datum/preference/choiced/ui_style))
poll_alert_button.desc = "[question]"
poll_alert_button.show_time_left = TRUE
poll_alert_button.poll = alert_poll
poll_alert_button.set_role_overlay()
poll_alert_button.update_stacks_overlay()
poll_alert_button.update_candidates_number_overlay()
poll_alert_button.update_signed_up_overlay()
// Sign up inheritance and stacking
for(var/datum/candidate_poll/other_poll as anything in currently_polling)
if(new_poll == other_poll || new_poll.poll_key != other_poll.poll_key)
continue
// If there's already a poll for an identical mob type ongoing and the client is signed up for it, sign them up for this one
if((candidate_mob in other_poll.signed_up) && new_poll.sign_up(candidate_mob, TRUE))
break
// Image to display
var/image/poll_image
if(pic_source)
if(!ispath(pic_source))
var/atom/the_pic_source = pic_source
var/old_layer = the_pic_source.layer
var/old_plane = the_pic_source.plane
the_pic_source.plane = poll_alert_button.plane
the_pic_source.layer = FLOAT_LAYER
poll_alert_button.add_overlay(the_pic_source)
the_pic_source.layer = old_layer
the_pic_source.plane = old_plane
else
poll_image = image(pic_source, layer = FLOAT_LAYER)
else
// Just use a generic image
poll_image = image('icons/effects/effects.dmi', icon_state = "static", layer = FLOAT_LAYER)
if(poll_image)
poll_image.plane = poll_alert_button.plane
poll_alert_button.add_overlay(poll_image)
// Chat message
var/act_jump = ""
if(isatom(pic_source) && isobserver(candidate_mob))
act_jump = "<a href='?src=[REF(poll_alert_button)];jump=1'>\[Teleport\]</a>"
var/act_signup = "<a href='?src=[REF(poll_alert_button)];signup=1'>\[[start_signed_up ? "Opt out" : "Sign Up"]\]</a>"
var/act_never = ""
if(ignore_category)
act_never = "<a href='?src=[REF(poll_alert_button)];never=1'>\[Never For This Round\]</a>"
if(!duplicate_message_check(alert_poll)) //Only notify people once. They'll notice if there are multiple and we don't want to spam people.
SEND_SOUND(candidate_mob, 'sound/misc/notice2.ogg')
to_chat(candidate_mob, span_boldnotice(examine_block("Now looking for candidates [role_name_text ? "to play as \an [role_name_text]." : "\"[question]\""] [act_jump] [act_signup] [act_never]")))
// Start processing it so it updates visually the timer
START_PROCESSING(SSprocessing, poll_alert_button)
// Sleep until the time is up
UNTIL(new_poll.finished)
return new_poll.signed_up
/datum/controller/subsystem/polling/proc/poll_ghost_candidates(question, role, check_jobban, poll_time = 30 SECONDS, ignore_category = null, flashwindow = TRUE, pic_source, role_name_text)
var/list/candidates = list()
if(!(GLOB.ghost_role_flags & GHOSTROLE_STATION_SENTIENCE))
return candidates
for(var/mob/dead/observer/ghost_player in GLOB.player_list)
candidates += ghost_player
return poll_candidates(question, role, check_jobban, poll_time, ignore_category, flashwindow, candidates, pic_source, role_name_text)
/datum/controller/subsystem/polling/proc/poll_ghost_candidates_for_mob(question, role, check_jobban, poll_time = 30 SECONDS, mob/target_mob, ignore_category = null, flashwindow = TRUE, pic_source, role_name_text)
var/static/list/mob/currently_polling_mobs = list()
if(currently_polling_mobs.Find(target_mob))
return list()
currently_polling_mobs += target_mob
var/list/possible_candidates = poll_ghost_candidates(question, role, check_jobban, poll_time, ignore_category, flashwindow, pic_source, role_name_text)
currently_polling_mobs -= target_mob
if(!target_mob || QDELETED(target_mob) || !target_mob.loc)
return list()
return possible_candidates
/datum/controller/subsystem/polling/proc/poll_ghost_candidates_for_mobs(question, role, check_jobban, poll_time = 30 SECONDS, list/mobs, ignore_category = null, flashwindow = TRUE, pic_source, role_name_text)
var/list/candidate_list = poll_ghost_candidates(question, role, check_jobban, poll_time, ignore_category, flashwindow, pic_source, role_name_text)
for(var/mob/potential_mob as anything in mobs)
if(QDELETED(potential_mob) || !potential_mob.loc)
mobs -= potential_mob
if(!length(mobs))
return list()
return candidate_list
/datum/controller/subsystem/polling/proc/is_eligible(mob/potential_candidate, role, check_jobban, the_ignore_category)
if(isnull(potential_candidate.key) || isnull(potential_candidate.client))
return FALSE
if(the_ignore_category)
if(potential_candidate.ckey in GLOB.poll_ignore[the_ignore_category])
return FALSE
if(role)
if(!(role in potential_candidate.client.prefs.be_special))
return FALSE
var/required_time = GLOB.special_roles[role] || 0
if(potential_candidate.client && potential_candidate.client.get_remaining_days(required_time) > 0)
return FALSE
if(check_jobban)
if(is_banned_from(potential_candidate.ckey, list(check_jobban, ROLE_SYNDICATE)))
return FALSE
//SKYRAT EDIT ADDITION BEGIN
if(is_banned_from(potential_candidate.ckey, BAN_GHOST_TAKEOVER) || is_banned_from(potential_candidate.ckey, BAN_ANTAGONIST))
to_chat(potential_candidate, "There was a ghost prompt for: [role], unfortunately you are banned from ghost takeovers.")
return FALSE
//SKYRAT EDIT END
return TRUE
/datum/controller/subsystem/polling/proc/polling_finished(datum/candidate_poll/finishing_poll)
currently_polling -= finishing_poll
// Trim players who aren't eligible anymore
var/length_pre_trim = length(finishing_poll.signed_up)
finishing_poll.trim_candidates()
log_game("Candidate poll [finishing_poll.role ? "for [finishing_poll.role]" : "\"[finishing_poll.question]\""] finished. [length_pre_trim] players signed up, [length(finishing_poll.signed_up)] after trimming")
finishing_poll.finished = TRUE
// Take care of updating the remaining screen alerts if a similar poll is found, or deleting them.
if(length(finishing_poll.alert_buttons))
for(var/atom/movable/screen/alert/poll_alert/alert as anything in finishing_poll.alert_buttons)
if(duplicate_message_check(finishing_poll))
alert.update_stacks_overlay()
else
alert.owner.clear_alert("[finishing_poll.poll_key]_poll_alert")
//More than enough time for the the `UNTIL()` stopping loop in `poll_candidates()` to be over, and the results to be turned in.
QDEL_IN(finishing_poll, 0.5 SECONDS)
/datum/controller/subsystem/polling/stat_entry(msg)
msg += "Active: [length(currently_polling)] | Total: [total_polls]"
var/datum/candidate_poll/soonest_to_complete = get_next_poll_to_finish()
if(soonest_to_complete)
msg += " | Next: [DisplayTimeText(soonest_to_complete.time_left())] ([length(soonest_to_complete.signed_up)] candidates)"
return ..()
///Is there a multiple of the given event type running right now?
/datum/controller/subsystem/polling/proc/duplicate_message_check(datum/candidate_poll/poll_to_check)
for(var/datum/candidate_poll/running_poll as anything in currently_polling)
if((running_poll.poll_key == poll_to_check.poll_key && running_poll != poll_to_check) && running_poll.time_left() > 0)
return TRUE
return FALSE
/datum/controller/subsystem/polling/proc/get_next_poll_to_finish()
var/lowest_time_left = INFINITY
var/next_poll_to_finish
for(var/datum/candidate_poll/poll as anything in currently_polling)
var/time_left = poll.time_left()
if(time_left >= lowest_time_left)
continue
lowest_time_left = time_left
next_poll_to_finish = poll
if(isnull(next_poll_to_finish))
return FALSE
return next_poll_to_finish