From 4e48e1379d1fd814358c7bbd9cffb9b28553e53f Mon Sep 17 00:00:00 2001 From: Bobbahbrown Date: Sat, 24 Oct 2020 22:10:06 -0300 Subject: [PATCH] Interview System / Soft Panic Bunker (#54465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About The Pull Request Ports and improves my interview system that has been previously used in the summer ball and toolbox tournament events. Allows for a 'softer' panic bunker, wherein players who fall below the required living time limit can still join the server and be restricted to filling out a questionnaire. Upon completing the questionnaire, the player may be allowed into the server by an administrator. If the application is approved, they get a notification that they will be reconnected and upon reconnecting will have all verbs as they usually would. If the application is denied the user is put on a cooldown after which they may submit a new questionnaire. Players who are being interviewed (herein interviewees) have no verbs other than those required for the stat panel to function, as well as a verb to pull up the interview panel. Interviews do not persist through restarts, and the ability to join that is granted by an accepted interview is only valid for the duration of that round. Open interviews are listed under a new 'interviews' tab for admins, which is VERY similar to the existing tickets tab. Below is what a player who is flagged as an interviewee will see when they join the server. They can do nothing but respond to the questionnaire or leave. image This is what an administrator sees after an interview is submitted, they will also see a corresponding message within their chatbox, and an age-old BWOINK when an interview is submitted. image The interviews tab, which is similar to the tickets menu. You can open the interview manager panel to view all active (including non-submitted) interviews, queued (submitted) interviews, and closed interviews. image FAQ: What happens if someone submits an interview when no admins are on? It's treated like adminhelps are, the message gets sent to TGS to be dispatched off to configured end-points (like Discord or IRC), and the user is notified that their interview was handled this way. Can you configure the questions? Yes, in config/ there is now a interviews.txt file in which the welcome message and the individual questions can be set and modified. Can this be turned on and off during a round? Yes, it can be toggled like the panic bunker. It requires the panic bunker to be raised in order to function. Can interviewees have further questions asked to them? Yes, if you admin-pm them, which is possible using regular means or a conveniently placed button on the interview UI, they will be able to respond to the message. Technical details To use the interview system you must have the panic bunker enabled, this is an additional setting for the panic bunker. It can be set through the PANIC_BUNKER_INTERVIEW setting in config.txt, or alternatively enabled in-game as prompted during the panic bunker toggling process. It also can be toggled on its own using a verb added for this purpose, Toggle PB Interviews found under the server tab. These new actions are included in the logging for the panic bunker. I have also added a reporting stat to the world topic status keyword, which now reports if the interview system is on using the keyword interviews. As mentioned above, for server operators, configure the questions and welcome message in config/interviews.txt. Note to maintainers and those with big brains I had to add a call to init_verbs on the stat panel window being ready because seemingly a race condition exists wherein the add_verb of the 'view my interview' verb doesn't cause a refresh of the tabs (and therefore doesn't show the 'Interview' tab) when running in dream daemon but running it directly from visual studio code properly shows the tab. Adding a init_verbs call directly after adding the verb didn't seem to help. A note for downstreams If you don't use the HTML stat panel (which may not be a bad thing) then you will have to do some conversion from the HTML stat panel stuff used here to the old style stat panels. It's pretty trivial, but just be aware of that. You can see how I used to use the old stat panels in my PR from the summer ball, here, which should be helpful. Why It's Good For The Game This allows for a softer version of the panic bunker which impedes the flow of malicious players while allowing genuine players a chance to enter a round to gain enough time to not be affected by the panic bunker's restrictions. Changelog 🆑 bobbahbrown add: Added the interview system, a 'soft' panic bunker which lets players who would normally be blocked from joining be interviewed by admins to be selectively allowed to play. /🆑 --- code/__DEFINES/statpanel.dm | 8 + .../controllers/configuration/config_entry.dm | 13 ++ .../configuration/entries/general.dm | 4 +- .../configuration/entries/interview.dm | 3 + code/controllers/subsystem/statpanel.dm | 30 +++ code/datums/world_topic.dm | 1 + code/modules/admin/admin_verbs.dm | 2 + code/modules/admin/topic.dm | 12 + code/modules/admin/verbs/adminpm.dm | 1 - code/modules/admin/verbs/panicbunker.dm | 20 +- code/modules/client/client_defines.dm | 3 + code/modules/client/client_procs.dm | 53 +++-- code/modules/interview/interview.dm | 162 +++++++++++++ code/modules/interview/interview_manager.dm | 218 ++++++++++++++++++ code/modules/mob/dead/new_player/login.dm | 19 +- .../modules/mob/dead/new_player/new_player.dm | 33 +++ code/modules/tgui/states/new_player.dm | 13 ++ config/config.txt | 5 + config/interviews.txt | 9 + html/statbrowser.html | 76 ++++++ tgstation.dme | 5 + tgui/docs/component-reference.md | 1 + tgui/packages/tgui/components/TextArea.js | 4 +- tgui/packages/tgui/interfaces/Interview.js | 91 ++++++++ .../tgui/interfaces/InterviewManager.js | 49 ++++ tgui/packages/tgui/layouts/Window.js | 7 +- tgui/packages/tgui/public/tgui.bundle.js | 1 + tgui/public/tgui-common.chunk.js | 2 +- tgui/public/tgui-panel.bundle.js | 2 +- tgui/public/tgui.bundle.js | 2 +- 30 files changed, 810 insertions(+), 39 deletions(-) create mode 100644 code/__DEFINES/statpanel.dm create mode 100644 code/controllers/configuration/entries/interview.dm create mode 100644 code/modules/interview/interview.dm create mode 100644 code/modules/interview/interview_manager.dm create mode 100644 code/modules/tgui/states/new_player.dm create mode 100644 config/interviews.txt create mode 100644 tgui/packages/tgui/interfaces/Interview.js create mode 100644 tgui/packages/tgui/interfaces/InterviewManager.js create mode 100644 tgui/packages/tgui/public/tgui.bundle.js diff --git a/code/__DEFINES/statpanel.dm b/code/__DEFINES/statpanel.dm new file mode 100644 index 00000000000..7988b9b5c83 --- /dev/null +++ b/code/__DEFINES/statpanel.dm @@ -0,0 +1,8 @@ +/// Bare minimum required verbs for stat panel operation +GLOBAL_LIST_INIT(stat_panel_verbs, list( + /client/verb/set_tab, + /client/verb/send_tabs, + /client/verb/remove_tabs, + /client/verb/reset_tabs, + /client/verb/panel_ready +)) diff --git a/code/controllers/configuration/config_entry.dm b/code/controllers/configuration/config_entry.dm index ac1c768a2d1..41417a2ed30 100644 --- a/code/controllers/configuration/config_entry.dm +++ b/code/controllers/configuration/config_entry.dm @@ -118,6 +118,19 @@ config_entry_value = text2num(trim(str_val)) != 0 return TRUE +/// List config entry, used for configuring a list of strings +/datum/config_entry/str_list + abstract_type = /datum/config_entry/str_list + config_entry_value = list() + dupes_allowed = TRUE + +/datum/config_entry/str_list/ValidateAndSet(str_val) + if (!VASProcCallGuard(str_val)) + return FALSE + str_val = trim(str_val) + if (str_val != "") + config_entry_value += str_val + /datum/config_entry/number_list abstract_type = /datum/config_entry/number_list config_entry_value = list() diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 3d0646da4c7..02915377581 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -314,6 +314,9 @@ /datum/config_entry/number/panic_bunker_living // living time in minutes that a player needs to pass the panic bunker +/// Flag for requiring players who would otherwise be denied access by the panic bunker to complete a written interview +/datum/config_entry/flag/panic_bunker_interview + /datum/config_entry/string/panic_bunker_message config_entry_value = "Sorry but the server is currently not accepting connections from never before seen players." @@ -496,4 +499,3 @@ /datum/config_entry/string/centcom_ban_db // URL for the CentCom Galactic Ban DB API /datum/config_entry/string/centcom_source_whitelist - diff --git a/code/controllers/configuration/entries/interview.dm b/code/controllers/configuration/entries/interview.dm new file mode 100644 index 00000000000..b18f64d724d --- /dev/null +++ b/code/controllers/configuration/entries/interview.dm @@ -0,0 +1,3 @@ +/datum/config_entry/str_list/interview_questions + +/datum/config_entry/string/interview_welcome_msg diff --git a/code/controllers/subsystem/statpanel.dm b/code/controllers/subsystem/statpanel.dm index 09bed51c271..ddc5cb30931 100644 --- a/code/controllers/subsystem/statpanel.dm +++ b/code/controllers/subsystem/statpanel.dm @@ -54,6 +54,35 @@ SUBSYSTEM_DEF(statpanels) if(target.stat_tab == "Tickets") var/list/ahelp_tickets = GLOB.ahelp_tickets.stat_entry() target << output("[url_encode(json_encode(ahelp_tickets))];", "statbrowser:update_tickets") + if(target.stat_tab == "Interviews") + var/datum/interview_manager/m = GLOB.interviews + + // get open interview count + var/dc = 0 + for (var/ckey in m.open_interviews) + var/datum/interview/I = m.open_interviews[ckey] + if (I && !I.owner) + dc++ + var/stat_string = "([m.open_interviews.len - dc] online / [dc] disconnected)" + + // Prepare each queued interview + var/list/queued = list() + for (var/datum/interview/I in m.interview_queue) + queued += list(list( + "ref" = REF(I), + "status" = "\[[I.pos_in_queue]\]: [I.owner_ckey][!I.owner ? " (DC)": ""] \[INT-[I.id]\]" + )) + + var/list/data = list( + "status" = list( + "Active:" = "[m.open_interviews.len] [stat_string]", + "Queued:" = "[m.interview_queue.len]", + "Closed:" = "[m.closed_interviews.len]"), + "interviews" = queued + ) + + // Push update + target << output("[url_encode(json_encode(data))];", "statbrowser:update_interviews") if(!length(GLOB.sdql2_queries) && ("SDQL2" in target.panel_tabs)) target << output("", "statbrowser:remove_sdql2") else if(length(GLOB.sdql2_queries) && (target.stat_tab == "SDQL2" || !("SDQL2" in target.panel_tabs))) @@ -170,3 +199,4 @@ SUBSYSTEM_DEF(statpanels) set hidden = TRUE statbrowser_ready = TRUE + init_verbs() diff --git a/code/datums/world_topic.dm b/code/datums/world_topic.dm index 7bde5574b71..baae7c6bba1 100644 --- a/code/datums/world_topic.dm +++ b/code/datums/world_topic.dm @@ -183,6 +183,7 @@ .["extreme_popcap"] = CONFIG_GET(number/extreme_popcap) || 0 .["popcap"] = max(CONFIG_GET(number/soft_popcap), CONFIG_GET(number/hard_popcap), CONFIG_GET(number/extreme_popcap)) //generalized field for this concept for use across ss13 codebases .["bunkered"] = CONFIG_GET(flag/panic_bunker) || FALSE + .["interviews"] = CONFIG_GET(flag/panic_bunker_interview) || FALSE if(SSshuttle?.emergency) .["shuttle_mode"] = SSshuttle.emergency.mode // Shuttle status, see /__DEFINES/stat.dm diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 55328ef4cc6..d5349bf05fd 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -127,6 +127,7 @@ GLOBAL_PROTECT(admin_verbs_server) /client/proc/forcerandomrotate, /client/proc/adminchangemap, /client/proc/panicbunker, + /client/proc/toggle_interviews, /client/proc/toggle_hub, /client/proc/toggle_cdn ) @@ -250,6 +251,7 @@ GLOBAL_LIST_INIT(admin_verbs_hideable, list( /proc/release, /client/proc/reload_admins, /client/proc/panicbunker, + /client/proc/toggle_interviews, /client/proc/admin_change_sec_level, /client/proc/toggle_nuke, /client/proc/cmd_display_del_log, diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm index b471aba555a..16cd23326a5 100644 --- a/code/modules/admin/topic.dm +++ b/code/modules/admin/topic.dm @@ -2308,6 +2308,18 @@ var/obj/item/nuclear_challenge/button = locate(href_list["force_war"]) button.force_war() + else if (href_list["interview"]) + if(!check_rights(R_ADMIN)) + return + var/datum/interview/I = locate(href_list["interview"]) + if (I) + I.ui_interact(usr) + + else if (href_list["interview_man"]) + if(!check_rights(R_ADMIN)) + return + GLOB.interviews.ui_interact(usr) + /datum/admins/proc/HandleCMode() if(!check_rights(R_ADMIN)) return diff --git a/code/modules/admin/verbs/adminpm.dm b/code/modules/admin/verbs/adminpm.dm index 200ccff0da5..234f10e0897 100644 --- a/code/modules/admin/verbs/adminpm.dm +++ b/code/modules/admin/verbs/adminpm.dm @@ -274,7 +274,6 @@ if(!already_logged) //Reply to an existing ticket SSblackbox.LogAhelp(recipient.current_ticket.id, "Reply", msg, recipient.ckey, src.ckey) - //always play non-admin recipients the adminhelp sound SEND_SOUND(recipient, sound('sound/effects/adminhelp.ogg')) diff --git a/code/modules/admin/verbs/panicbunker.dm b/code/modules/admin/verbs/panicbunker.dm index 846e5f9530a..c0e229ef6e7 100644 --- a/code/modules/admin/verbs/panicbunker.dm +++ b/code/modules/admin/verbs/panicbunker.dm @@ -6,6 +6,7 @@ return var/new_pb = !CONFIG_GET(flag/panic_bunker) + var/interview = CONFIG_GET(flag/panic_bunker_interview) var/time_rec = 0 var/message = "" if(new_pb) @@ -14,10 +15,23 @@ message = replacetext(message, "%minutes%", time_rec) CONFIG_SET(number/panic_bunker_living, time_rec) CONFIG_SET(string/panic_bunker_message, message) - + + var/interview_sys = alert(src, "Should the interview system be enabled? (Allows players to connect under the hour limit and force them to be manually approved to play)", "Enable interviews?", "Enable", "Disable") + interview = interview_sys == "Enable" + CONFIG_SET(flag/panic_bunker_interview, interview) CONFIG_SET(flag/panic_bunker, new_pb) - log_admin("[key_name(usr)] has toggled the Panic Bunker, it is now [new_pb ? "on and set to [time_rec] with a message of [message]" : "off"]") - message_admins("[key_name_admin(usr)] has toggled the Panic Bunker, it is now [new_pb ? "enabled with a living minutes requirement of [time_rec]" : "disabled"].") + log_admin("[key_name(usr)] has toggled the Panic Bunker, it is now [new_pb ? "on and set to [time_rec] with a message of [message]. The interview system is [interview ? "enabled" : "disabled"]" : "off"].") + message_admins("[key_name_admin(usr)] has toggled the Panic Bunker, it is now [new_pb ? "enabled with a living minutes requirement of [time_rec]. The interview system is [interview ? "enabled" : "disabled"]" : "disabled"].") if (new_pb && !SSdbcore.Connect()) message_admins("The Database is not connected! Panic bunker will not work until the connection is reestablished.") SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Panic Bunker", "[new_pb ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! + +/client/proc/toggle_interviews() + set category = "Server" + set name = "Toggle PB Interviews" + if (!CONFIG_GET(flag/panic_bunker)) + to_chat(usr, "NOTE: The panic bunker is not enabled, so this change will not effect anything until it is enabled.", confidential = TRUE) + var/new_interview = !CONFIG_GET(flag/panic_bunker_interview) + CONFIG_SET(flag/panic_bunker_interview, new_interview) + log_admin("[key_name(usr)] has toggled the Panic Bunker's interview system, it is now [new_interview ? "enabled" : "disabled"].") + message_admins("[key_name(usr)] has toggled the Panic Bunker's interview system, it is now [new_interview ? "enabled" : "disabled"].") diff --git a/code/modules/client/client_defines.dm b/code/modules/client/client_defines.dm index fe9c1c4277c..4b3de98c4b7 100644 --- a/code/modules/client/client_defines.dm +++ b/code/modules/client/client_defines.dm @@ -204,3 +204,6 @@ var/next_move_dir_add /// On next move, subtract this dir from the move that would otherwise be done var/next_move_dir_sub + + /// If the client is currently under the restrictions of the interview system + var/interviewee = FALSE diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index a742d5a996d..3a8654bcdbc 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -212,6 +212,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( tgui_panel = new(src) GLOB.ahelp_tickets.ClientLogin(src) + GLOB.interviews.client_login(src) var/connecting_admin = FALSE //because de-admined admins connecting should be treated like admins. //Admin Authorisation holder = GLOB.admin_datums[ckey] @@ -426,26 +427,8 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( if(!tooltips) tooltips = new /datum/tooltip(src) - var/list/topmenus = GLOB.menulist[/datum/verbs/menu] - for (var/thing in topmenus) - var/datum/verbs/menu/topmenu = thing - var/topmenuname = "[topmenu]" - if (topmenuname == "[topmenu.type]") - var/list/tree = splittext(topmenuname, "/") - topmenuname = tree[tree.len] - winset(src, "[topmenu.type]", "parent=menu;name=[url_encode(topmenuname)]") - var/list/entries = topmenu.Generate_list(src) - for (var/child in entries) - winset(src, "[child]", "[entries[child]]") - if (!ispath(child, /datum/verbs/menu)) - var/procpath/verbpath = child - if (verbpath.name[1] != "@") - new child(src) - - for (var/thing in prefs.menuoptions) - var/datum/verbs/menu/menuitem = GLOB.menulist[thing] - if (menuitem) - menuitem.Load_checked(src) + if (!interviewee) + initialize_menus() view_size = new(src, getScreenSize(prefs.widescreenpref)) view_size.resetFormat() @@ -467,6 +450,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( GLOB.directory -= ckey log_access("Logout: [key_name(src)]") GLOB.ahelp_tickets.ClientLogout(src) + GLOB.interviews.client_logout(src) SSserver_maint.UpdateHubStatus() if(credits) QDEL_LIST(credits) @@ -552,7 +536,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( var/living_recs = CONFIG_GET(number/panic_bunker_living) //Relies on pref existing, but this proc is only called after that occurs, so we're fine. var/minutes = get_exp_living(pure_numeric = TRUE) - if(minutes <= living_recs) + if(minutes <= living_recs && !CONFIG_GET(flag/panic_bunker_interview)) var/reject_message = "Failed Login: [key] - Account attempting to connect during panic bunker, but they do not have the required living time [minutes]/[living_recs]" log_access(reject_message) message_admins("[reject_message]") @@ -868,6 +852,8 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( ..() /client/proc/add_verbs_from_config() + if (interviewee) + return if(CONFIG_GET(flag/see_own_notes)) add_verb(src, /client/proc/self_notes) if(CONFIG_GET(flag/use_exp_tracking)) @@ -1058,3 +1044,28 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( if(statbrowser_ready) return to_chat(src, "Statpanel failed to load, click here to reload the panel ") + +/** + * Initializes dropdown menus on client + */ +/client/proc/initialize_menus() + var/list/topmenus = GLOB.menulist[/datum/verbs/menu] + for (var/thing in topmenus) + var/datum/verbs/menu/topmenu = thing + var/topmenuname = "[topmenu]" + if (topmenuname == "[topmenu.type]") + var/list/tree = splittext(topmenuname, "/") + topmenuname = tree[tree.len] + winset(src, "[topmenu.type]", "parent=menu;name=[url_encode(topmenuname)]") + var/list/entries = topmenu.Generate_list(src) + for (var/child in entries) + winset(src, "[child]", "[entries[child]]") + if (!ispath(child, /datum/verbs/menu)) + var/procpath/verbpath = child + if (verbpath.name[1] != "@") + new child(src) + + for (var/thing in prefs.menuoptions) + var/datum/verbs/menu/menuitem = GLOB.menulist[thing] + if (menuitem) + menuitem.Load_checked(src) diff --git a/code/modules/interview/interview.dm b/code/modules/interview/interview.dm new file mode 100644 index 00000000000..59076d057ec --- /dev/null +++ b/code/modules/interview/interview.dm @@ -0,0 +1,162 @@ +/// State when an interview has been approved +#define INTERVIEW_APPROVED "interview_approved" +/// State when an interview as been denied +#define INTERVIEW_DENIED "interview_denied" +/// State when an interview has had no action on it yet +#define INTERVIEW_PENDING "interview_pending" + +/** + * Represents a new-player interview form + * + * Represents a new-player interview form, enabled by configuration to require + * players with low playtime to request access to the server. To do so, they will + * out a brief questionnaire, and are otherwise unable to do anything while they + * wait for a response. + */ +/datum/interview + /// Unique ID of the interview + var/id + /// Atomic ID for incrementing unique IDs + var/static/atomic_id = 0 + /// The /client who owns this interview, the intiator + var/client/owner + /// The Ckey of the owner, used for when a client could disconnect + var/owner_ckey + /// The welcome message shown at the top of the interview panel + var/welcome_message + /// The questions to display on the questionnaire of the interview + var/list/questions + /// The stored responses, will be filled as the questionnaire is answered + var/list/responses = list() + /// Boolean operator controlling if the questionnaire's contents can be edited + var/read_only = FALSE + /// Integer that contains the current position in the interview queue, used for rendering + var/pos_in_queue + /// Contains the state of the form, used for rendering and sanity checking + var/status = INTERVIEW_PENDING + +/datum/interview/New(client/interviewee) + if(!interviewee) + qdel(src) + return + id = ++atomic_id + owner = interviewee + owner_ckey = owner.ckey + questions = CONFIG_GET(str_list/interview_questions) + responses.len = questions.len + welcome_message = CONFIG_GET(string/interview_welcome_msg) + +/** + * Approves the interview, forces reconnect of owner if relevant. + * + * Approves the interview, and if relevant will force the owner to reconnect so that they have the proper + * verbs returned to them. + * Arguments: + * * approved_by - The user who approved the interview, used for logging + */ +/datum/interview/proc/approve(client/approved_by) + status = INTERVIEW_APPROVED + read_only = TRUE + GLOB.interviews.approved_ckeys |= owner_ckey + GLOB.interviews.close_interview(src) + log_admin_private("[key_name(approved_by)] has approved interview #[id] for [owner_ckey][!owner ? "(DC)": ""].") + message_admins("[key_name(approved_by)] has approved interview #[id] for [owner_ckey][!owner ? "(DC)": ""].") + if (owner) + SEND_SOUND(owner, sound('sound/effects/adminhelp.ogg')) + to_chat(owner, "-- Interview Update --" \ + + "\nYour interview was approved, you will now be reconnected in 5 seconds.", confidential = TRUE) + addtimer(CALLBACK(src, .proc/reconnect_owner), 50) + +/** + * Denies the interview and adds the owner to the cooldown for new interviews. + * + * Arguments: + * * denied_by - The user who denied the interview, used for logging + */ +/datum/interview/proc/deny(client/denied_by) + status = INTERVIEW_DENIED + read_only = TRUE + GLOB.interviews.close_interview(src) + GLOB.interviews.cooldown_ckeys |= owner_ckey + log_admin_private("[key_name(denied_by)] has denied interview #[id] for [owner_ckey][!owner ? "(DC)": ""].") + message_admins("[key_name(denied_by)] has denied interview #[id] for [owner_ckey][!owner ? "(DC)": ""].") + addtimer(CALLBACK(GLOB.interviews, /datum/interview_manager.proc/release_from_cooldown, owner_ckey), 180) + if (owner) + SEND_SOUND(owner, sound('sound/effects/adminhelp.ogg')) + to_chat(owner, "-- Interview Update --" \ + + "\nUnfortunately your interview was denied. Please try submitting another questionnaire." \ + + " You may do this in three minutes.", confidential = TRUE) + +/** + * Forces client to reconnect, used in the callback from approval + */ +/datum/interview/proc/reconnect_owner() + if (!owner) + return + winset(owner, null, "command=.reconnect") + +/** + * Verb for opening the existing interview, or if relevant creating a new interview if possible. + */ +/mob/dead/new_player/proc/open_interview() + set name = "Open Interview" + set category = "Interview" + var/mob/dead/new_player/M = usr + if (M?.client?.interviewee) + var/datum/interview/I = GLOB.interviews.interview_for_client(M.client) + if (I) // we can be returned nothing if the user is on cooldown + I.ui_interact(M) + else + to_chat(usr, "You are on cooldown for interviews. Please" \ + + " wait at least 3 minutes before starting a new questionnaire.", confidential = TRUE) + +/datum/interview/ui_interact(mob/user, datum/tgui/ui = null) + ui = SStgui.try_update_ui(user, src, ui) + if (!ui) + ui = new(user, src, "Interview") + ui.open() + +/datum/interview/ui_state(mob/user) + return GLOB.new_player_state + +/datum/interview/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + if (..()) + return + switch(action) + if ("update_answer") + if (!read_only) + responses[text2num(params["qidx"])] = copytext_char(params["answer"], 1, 501) // byond indexing moment + . = TRUE + if ("submit") + if (!read_only) + read_only = TRUE + GLOB.interviews.enqueue(src) + . = TRUE + if ("approve") + if (usr.client?.holder && status == INTERVIEW_PENDING) + src.approve(usr) + . = TRUE + if ("deny") + if (usr.client?.holder && status == INTERVIEW_PENDING) + src.deny(usr) + . = TRUE + if ("adminpm") + if (usr.client?.holder && owner) + usr.client.cmd_admin_pm(owner, null) + +/datum/interview/ui_data(mob/user) + . = list( + "welcome_message" = welcome_message, + "questions" = list(), + "read_only" = read_only, + "queue_pos" = pos_in_queue, + "is_admin" = !!(user?.client && user.client.holder), + "status" = status, + "connected" = !!owner) + for (var/i in 1 to questions.len) + var/list/data = list( + "qidx" = i, + "question" = questions[i], + "response" = responses.len < i ? null : responses[i] + ) + .["questions"] += list(data) diff --git a/code/modules/interview/interview_manager.dm b/code/modules/interview/interview_manager.dm new file mode 100644 index 00000000000..2aabe91f960 --- /dev/null +++ b/code/modules/interview/interview_manager.dm @@ -0,0 +1,218 @@ +GLOBAL_DATUM_INIT(interviews, /datum/interview_manager, new) + +/** + * # Interview Manager + * + * Handles all interviews in the duration of a round, includes the primary functionality for + * handling the interview queue. + */ +/datum/interview_manager + /// The interviews that are currently "open", those that are not submitted as well as those that are waiting review + var/list/open_interviews = list() + /// The queue of interviews to be processed (submitted interviews) + var/list/interview_queue = list() + /// All closed interviews + var/list/closed_interviews = list() + /// Ckeys which are allowed to bypass the time-based allowlist + var/list/approved_ckeys = list() + /// Ckeys which are currently in the cooldown system, they will be unable to create new interviews + var/list/cooldown_ckeys = list() + +/datum/interview_manager/Destroy(force, ...) + QDEL_LIST(open_interviews) + QDEL_LIST(interview_queue) + QDEL_LIST(closed_interviews) + QDEL_LIST(approved_ckeys) + QDEL_LIST(cooldown_ckeys) + return ..() + +/** + * Used in the new client pipeline to catch when clients are reconnecting and need to have their + * reference re-assigned to the 'owner' variable of an interview + * + * Arguments: + * * C - The client who is logging in + */ +/datum/interview_manager/proc/client_login(client/C) + for(var/ckey in open_interviews) + var/datum/interview/I = open_interviews[ckey] + if (I && !I.owner && C.ckey == I.owner_ckey) + I.owner = C + +/** + * Used in the destroy client pipeline to catch when clients are disconnecting and need to have their + * reference nulled on the 'owner' variable of an interview + * + * Arguments: + * * C - The client who is logging out + */ +/datum/interview_manager/proc/client_logout(client/C) + for(var/ckey in open_interviews) + var/datum/interview/I = open_interviews[ckey] + if (I?.owner && C.ckey == I.owner_ckey) + I.owner = null + +/** + * Attempts to return an interview for a given client, using an existing interview if found, otherwise + * a new interview is created; if the user is on cooldown then it will return null. + * + * Arguments: + * * C - The client to get the interview for + */ +/datum/interview_manager/proc/interview_for_client(client/C) + if (!C) + return + if (open_interviews[C.ckey]) + return open_interviews[C.ckey] + else if (!(C.ckey in cooldown_ckeys)) + log_admin_private("New interview created for [key_name(C)].") + open_interviews[C.ckey] = new /datum/interview(C) + return open_interviews[C.ckey] + +/** + * Attempts to return an interview for a provided ID, will return null if no matching interview is found + * + * Arguments: + * * id - The ID of the interview to find + */ +/datum/interview_manager/proc/interview_by_id(id) + if (!id) + return + for (var/ckey in open_interviews) + var/datum/interview/I = open_interviews[ckey] + if (I?.id == id) + return I + for (var/datum/interview/I in closed_interviews) + if (I.id == id) + return I + +/** + * Enqueues an interview in the interview queue, and notifies admins of the new interview to be + * reviewed. + * + * Arguments: + * * to_queue - The interview to enqueue + */ +/datum/interview_manager/proc/enqueue(datum/interview/to_queue) + if (!to_queue || (to_queue in interview_queue)) + return + to_queue.pos_in_queue = interview_queue.len + 1 + interview_queue |= to_queue + + // Notify admins + var/ckey = to_queue.owner_ckey + log_admin_private("Interview for [ckey] has been enqueued for review. Current position in queue: [to_queue.pos_in_queue]") + var/admins_present = send2tgs_adminless_only("panic-bunker-interview", "Interview for [ckey] enqueued for review. Current position in queue: [to_queue.pos_in_queue]") + if (admins_present <= 0 && to_queue.owner) + to_chat(to_queue.owner, "No active admins are online, your interview's submission was sent through TGS to admins who are available. This may use IRC or Discord.") + for(var/client/X in GLOB.admins) + if(X.prefs.toggles & SOUND_ADMINHELP) + SEND_SOUND(X, sound('sound/effects/adminhelp.ogg')) + window_flash(X, ignorepref = TRUE) + to_chat(X, "Interview for [ckey] enqueued for review. Current position in queue: [to_queue.pos_in_queue]", confidential = TRUE) + +/** + * Removes a ckey from the cooldown list, used for enforcing cooldown after an interview is denied. + * + * Arguments: + * * ckey - The ckey to remove from the cooldown list + */ +/datum/interview_manager/proc/release_from_cooldown(ckey) + cooldown_ckeys -= ckey + +/** + * Dequeues the first interview from the interview queue, and updates the queue positions of any relevant + * interviews that follow it. + */ +/datum/interview_manager/proc/dequeue() + if (interview_queue.len == 0) + return + + // Get the first interview off the front of the queue + var/datum/interview/to_return = interview_queue[1] + interview_queue -= to_return + + // Decrement any remaining interview queue positions + for(var/datum/interview/i in interview_queue) + i.pos_in_queue-- + + return to_return + +/** + * Dequeues an interview from the interview queue if present, and updates the queue positions of + * any relevant interviews that follow it. + * + * Arguments: + * * to_dequeue - The interview to dequeue + */ +/datum/interview_manager/proc/dequeue_specific(datum/interview/to_dequeue) + if (!to_dequeue) + return + + // Decrement all interviews in queue past the interview being removed + var/found = FALSE + for (var/datum/interview/i in interview_queue) + if (found) + i.pos_in_queue-- + if (i == to_dequeue) + found = TRUE + + interview_queue -= to_dequeue + +/** + * Closes an interview, removing it from the queued interviews as well as adding it to the closed + * interviews list. + * + * Arguments: + * * to_close - The interview to dequeue + */ +/datum/interview_manager/proc/close_interview(datum/interview/to_close) + if (!to_close) + return + dequeue_specific(to_close) + if (open_interviews[to_close.owner_ckey]) + open_interviews -= to_close.owner_ckey + closed_interviews += to_close + +/datum/interview_manager/ui_interact(mob/user, datum/tgui/ui = null) + ui = SStgui.try_update_ui(user, src, ui) + if (!ui) + ui = new(user, src, "InterviewManager") + ui.open() + +/datum/interview_manager/ui_state(mob/user) + return GLOB.admin_state + +/datum/interview_manager/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + if (..()) + return + switch(action) + if ("open") + var/datum/interview/I = interview_by_id(text2num(params["id"])) + if (I) + I.ui_interact(usr) + + +/datum/interview_manager/ui_data(mob/user) + . = list( + "open_interviews" = list(), + "closed_interviews" = list()) + for (var/ckey in open_interviews) + var/datum/interview/I = open_interviews[ckey] + if (I) + var/list/data = list( + "id" = I.id, + "ckey" = I.owner_ckey, + "status" = I.status, + "queued" = I.pos_in_queue && I.status == INTERVIEW_PENDING, + "disconnected" = !I.owner + ) + .["open_interviews"] += list(data) + for (var/datum/interview/I in closed_interviews) + var/list/data = list( + "id" = I.id, + "ckey" = I.owner_ckey, + "status" = I.status, + "disconnected" = !I.owner + ) + .["closed_interviews"] += list(data) diff --git a/code/modules/mob/dead/new_player/login.dm b/code/modules/mob/dead/new_player/login.dm index 2cc7ae74708..19aa1525057 100644 --- a/code/modules/mob/dead/new_player/login.dm +++ b/code/modules/mob/dead/new_player/login.dm @@ -26,13 +26,18 @@ sight |= SEE_TURFS - new_player_panel() client.playtitlemusic() + + // Check if user should be added to interview queue + if (!client.holder && CONFIG_GET(flag/panic_bunker) && CONFIG_GET(flag/panic_bunker_interview) && !(client.ckey in GLOB.interviews.approved_ckeys)) + var/required_living_minutes = CONFIG_GET(number/panic_bunker_living) + var/living_minutes = client.get_exp_living(TRUE) + if (required_living_minutes > living_minutes) + client.interviewee = TRUE + register_for_interview() + return + + new_player_panel() if(SSticker.current_state < GAME_STATE_SETTING_UP) var/tl = SSticker.GetTimeLeft() - var/postfix - if(tl > 0) - postfix = "in about [DisplayTimeText(tl)]" - else - postfix = "soon" - to_chat(src, "Please set up your character and select \"Ready\". The game will start [postfix].") + to_chat(src, "Please set up your character and select \"Ready\". The game will start [tl > 0 ? "in about [DisplayTimeText(tl)]" : "soon"].") diff --git a/code/modules/mob/dead/new_player/new_player.dm b/code/modules/mob/dead/new_player/new_player.dm index 9b00008a9d0..f4cb367b301 100644 --- a/code/modules/mob/dead/new_player/new_player.dm +++ b/code/modules/mob/dead/new_player/new_player.dm @@ -44,6 +44,9 @@ * This proc generates the panel that opens to all newly joining players, allowing them to join, observe, view polls, view the current crew manifest, and open the character customization menu. */ /mob/dead/new_player/proc/new_player_panel() + if (client?.interviewee) + return + var/datum/asset/asset_datum = get_asset_datum(/datum/asset/simple/lobby) asset_datum.send(client) var/list/output = list("

Setup Character

") @@ -113,6 +116,9 @@ if(!client) return + if(client.interviewee) + return FALSE + //Determines Relevent Population Cap var/relevant_cap var/hpc = CONFIG_GET(number/hard_popcap) @@ -521,3 +527,30 @@ return FALSE //This is the only case someone should actually be completely blocked from antag rolling as well return TRUE + +/** + * Prepares a client for the interview system, and provides them with a new interview + * + * This proc will both prepare the user by removing all verbs from them, as well as + * giving them the interview form and forcing it to appear. + */ +/mob/dead/new_player/proc/register_for_interview() + // First we detain them by removing all the verbs they have on client + for (var/v in client.verbs) + var/procpath/verb_path = v + if (!(verb_path in GLOB.stat_panel_verbs)) + remove_verb(client, verb_path) + + // Then remove those on their mob as well + for (var/v in verbs) + var/procpath/verb_path = v + if (!(verb_path in GLOB.stat_panel_verbs)) + remove_verb(src, verb_path) + + // Then we create the interview form and show it to the client + var/datum/interview/I = GLOB.interviews.interview_for_client(client) + if (I) + I.ui_interact(src) + + // Add verb for re-opening the interview panel, and re-init the verbs for the stat panel + add_verb(src, /mob/dead/new_player/proc/open_interview) diff --git a/code/modules/tgui/states/new_player.dm b/code/modules/tgui/states/new_player.dm new file mode 100644 index 00000000000..cf6f83ed3a1 --- /dev/null +++ b/code/modules/tgui/states/new_player.dm @@ -0,0 +1,13 @@ +/** + * tgui state: new_player_state + * + * Checks that the user is a new_player, or if user is an admin + */ + +GLOBAL_DATUM_INIT(new_player_state, /datum/ui_state/new_player_state, new) + +/datum/ui_state/new_player_state/can_use_topic(src_object, mob/user) + if(isnewplayer(user) || check_rights_for(user.client, R_ADMIN)) + return UI_INTERACTIVE + return UI_CLOSE + diff --git a/config/config.txt b/config/config.txt index 512318d913e..8d6692210e1 100644 --- a/config/config.txt +++ b/config/config.txt @@ -5,6 +5,7 @@ $include dbconfig.txt $include comms.txt $include antag_rep.txt $include resources.txt +$include interviews.txt # You can use the @ character at the beginning of a config option to lock it from being edited in-game # Example usage: @@ -342,6 +343,10 @@ NOTIFY_NEW_PLAYER_ACCOUNT_AGE 1 ## Requires database #PANIC_BUNKER +## Uncomment to enable the interview system for players who are below the living hour requirement instead of explicitly blocking them from the server +## Note this requires the PANIC_BUNKER to be enabled to do anything. +#PANIC_BUNKER_INTERVIEW + ## If a player connects during a bunker with less then or this amount of living time (Minutes), we deny the connection #PANIC_BUNKER_LIVING 90 diff --git a/config/interviews.txt b/config/interviews.txt new file mode 100644 index 00000000000..5bd80b81546 --- /dev/null +++ b/config/interviews.txt @@ -0,0 +1,9 @@ +# Interview welcome message displayed at the top of all interview questionnaires +# Should help to describe why the questionnaire is being given to the interviewee +INTERVIEW_WELCOME_MSG Welcome to our server. As you have not played here before, or played very little, we'll need you to answer a few questions below. After you submit your answers they will be reviewed and you may be asked further questions before being allowed to play. Please be patient as there may be others ahead of you. + +# Interview questions are listed here, in the order that they will be displayed in-game. +INTERVIEW_QUESTIONS Why have you joined the server today? +INTERVIEW_QUESTIONS Have you played space-station 13 before? If so, on what servers? +INTERVIEW_QUESTIONS Do you know anybody on the server today? If so, who? +INTERVIEW_QUESTIONS Do you have any additional comments? diff --git a/html/statbrowser.html b/html/statbrowser.html index d065a7cc937..76f4c16db3f 100644 --- a/html/statbrowser.html +++ b/html/statbrowser.html @@ -174,6 +174,10 @@ -ms-interpolation-mode: nearest-neighbor; image-rendering: pixelated; } + + .interview_panel_controls, .interview_panel_stats { + margin-bottom: 10px; + } @@ -300,6 +304,7 @@ var spell_tabs = []; var verb_tabs = []; var verbs = [["", ""]]; // list with a list inside var tickets = []; +var interviewManager = {status: "", interviews: []}; var sdql2 = []; var permanent_tabs = []; // tabs that won't be cleared by wipes var turfcontents = []; @@ -619,6 +624,8 @@ function tab_change(tab) { draw_debug(); } else if(tab == "Tickets") { draw_tickets(); + } else if(tab == "Interviews") { + draw_interviews(); } else if(tab == "SDQL2") { draw_sdql2(); }else if(tab == turfname) { @@ -743,6 +750,15 @@ function update_tickets(T){ if(current_tab == "Tickets") draw_tickets(); } +function update_interviews(I){ + interviewManager = JSON.parse(I); + if(!verb_tabs.includes("Interviews")) { + verb_tabs.push("Interviews"); + addPermanentTab("Interviews"); + } + if(current_tab == "Interviews") + draw_interviews(); +} function update_sdql2(S) { sdql2 = JSON.parse(S); if(sdql2.length > 0 && !verb_tabs.includes("SDQL2")) { @@ -772,18 +788,31 @@ function remove_tickets() { } checkStatusTab(); } + +function remove_interviews() { + if(tickets) { + tickets = []; + removePermanentTab("Interviews"); + if(current_tab == "Interviews") + tab_change("Status"); + } + checkStatusTab(); +} + // removes MC, Tickets and MC tabs. function remove_admin_tabs() { href_token = null; remove_mc(); remove_tickets(); remove_sdql2(); + remove_interviews(); } function add_admin_tabs(ht) { href_token = ht; addPermanentTab("MC"); addPermanentTab("Tickets"); + addPermanentTab("Interviews"); } function create_listedturf(TN) { remove_listedturf(); // remove the last one if we had one @@ -923,6 +952,53 @@ function draw_tickets() { document.getElementById("statcontent").appendChild(table); } +function draw_interviews() { + statcontentdiv[textContentKey] = ""; + var body = document.createElement("div"); + var manDiv = document.createElement("div"); + manDiv.className = "interview_panel_controls" + var manLink = document.createElement("a"); + manLink[textContentKey] = "Open Interview Manager Panel"; + manLink.href = "?_src_=holder;admin_token=" + href_token + ";interview_man=1;statpanel_item_click=1"; + manDiv.appendChild(manLink); + body.appendChild(manDiv); + + // List interview stats + var statsDiv = document.createElement("table"); + statsDiv.className="interview_panel_stats"; + for (var key in interviewManager.status) { + var d = document.createElement("div"); + var tr = document.createElement("tr"); + var stat_name = document.createElement("td"); + var stat_text = document.createElement("td"); + stat_name[textContentKey] = key; + stat_text[textContentKey] = interviewManager.status[key]; + tr.appendChild(stat_name); + tr.appendChild(stat_text); + statsDiv.appendChild(tr); + } + body.appendChild(statsDiv); + document.getElementById("statcontent").appendChild(body); + + // List interviews if any are open + var table = document.createElement("table"); + table.className = "interview_panel_table"; + if(!interviewManager) + return; + for(var i = 0; i < interviewManager.interviews.length; i++) { + var part = interviewManager.interviews[i]; + var tr = document.createElement("tr"); + var td = document.createElement("td"); + var a = document.createElement("a"); + a[textContentKey] = part["status"]; + a.href = "?_src_=holder;admin_token=" + href_token + ";interview=" + part["ref"] + ";statpanel_item_click=1"; + td.appendChild(a); + tr.appendChild(td); + table.appendChild(tr); + } + document.getElementById("statcontent").appendChild(table); +} + function draw_spells(cat) { statcontentdiv[textContentKey] = ""; var table = document.createElement("table"); diff --git a/tgstation.dme b/tgstation.dme index 360962086bb..fa4e108b84a 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -112,6 +112,7 @@ #include "code\__DEFINES\spaceman_dmm.dm" #include "code\__DEFINES\stat.dm" #include "code\__DEFINES\stat_tracking.dm" +#include "code\__DEFINES\statpanel.dm" #include "code\__DEFINES\status_effects.dm" #include "code\__DEFINES\storage.dm" #include "code\__DEFINES\subsystems.dm" @@ -262,6 +263,7 @@ #include "code\controllers\configuration\entries\dbconfig.dm" #include "code\controllers\configuration\entries\game_options.dm" #include "code\controllers\configuration\entries\general.dm" +#include "code\controllers\configuration\entries\interview.dm" #include "code\controllers\configuration\entries\resources.dm" #include "code\controllers\subsystem\achievements.dm" #include "code\controllers\subsystem\adjacent_air.dm" @@ -2049,6 +2051,8 @@ #include "code\modules\instruments\songs\editor.dm" #include "code\modules\instruments\songs\play_legacy.dm" #include "code\modules\instruments\songs\play_synthesized.dm" +#include "code\modules\interview\interview.dm" +#include "code\modules\interview\interview_manager.dm" #include "code\modules\jobs\access.dm" #include "code\modules\jobs\job_exp.dm" #include "code\modules\jobs\jobs.dm" @@ -3146,6 +3150,7 @@ #include "code\modules\tgui\states\human_adjacent.dm" #include "code\modules\tgui\states\inventory.dm" #include "code\modules\tgui\states\language_menu.dm" +#include "code\modules\tgui\states\new_player.dm" #include "code\modules\tgui\states\not_incapacitated.dm" #include "code\modules\tgui\states\notcontained.dm" #include "code\modules\tgui\states\observer.dm" diff --git a/tgui/docs/component-reference.md b/tgui/docs/component-reference.md index 288d236b60d..ee6be6e9ee5 100644 --- a/tgui/docs/component-reference.md +++ b/tgui/docs/component-reference.md @@ -1010,6 +1010,7 @@ Example: - For a list of themes, see `packages/tgui/styles/themes`. - `title: string` - Window title. - `resizable: boolean` - Controls resizability of the window. +- `noClose: boolean` - Controls the ability to close the window. - `children: any` - Child elements, which are rendered directly inside the window. If you use a [Dimmer](#dimmer) or [Modal](#modal) in your UI, they should be put as direct childs of a Window, otherwise you should be diff --git a/tgui/packages/tgui/components/TextArea.js b/tgui/packages/tgui/components/TextArea.js index d9d752912d3..00e1605a3b3 100644 --- a/tgui/packages/tgui/components/TextArea.js +++ b/tgui/packages/tgui/components/TextArea.js @@ -135,6 +135,7 @@ export class TextArea extends Component { onBlur, onEnter, value, + maxLength, placeholder, ...boxProps } = this.props; @@ -161,7 +162,8 @@ export class TextArea extends Component { onKeyPress={this.handleKeyPress} onInput={this.handleOnInput} onFocus={this.handleFocus} - onBlur={this.handleBlur} /> + onBlur={this.handleBlur} + maxLength={maxLength} /> ); } diff --git a/tgui/packages/tgui/interfaces/Interview.js b/tgui/packages/tgui/interfaces/Interview.js new file mode 100644 index 00000000000..c69de41c695 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Interview.js @@ -0,0 +1,91 @@ +import { Button, TextArea, Section, BlockQuote, NoticeBox } from '../components'; +import { Window } from '../layouts'; +import { useBackend } from '../backend'; + +export const Interview = (props, context) => { + const { act, data } = useBackend(context); + const { + welcome_message, + questions, + read_only, + queue_pos, + is_admin, + status, + connected, + } = data; + + const rendered_status = status => { + switch (status) { + case "interview_approved": + return (This interview was approved.); + case "interview_denied": + return (This interview was denied.); + default: + return ( + Your answers have been submitted. You are position {queue_pos} in + queue.); + } + }; + + return ( + + + {(!read_only && ( +
+

+ {welcome_message} +

+
)) || rendered_status(status)} +
+