diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 93785d89633..7a47b38b210 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -20,7 +20,8 @@ GLOBAL_PROTECT(admin_verbs_default) /client/proc/stop_sounds, /client/proc/mark_datum_mapview, /client/proc/debugstatpanel, - /client/proc/fix_air /*resets air in designated radius to its default atmos composition*/ + /client/proc/fix_air, /*resets air in designated radius to its default atmos composition*/ + /client/proc/requests ) GLOBAL_LIST_INIT(admin_verbs_admin, world.AVerbsAdmin()) GLOBAL_PROTECT(admin_verbs_admin) diff --git a/code/modules/admin/verbs/pray.dm b/code/modules/admin/verbs/pray.dm index c7670bc5288..4d70fecdafd 100644 --- a/code/modules/admin/verbs/pray.dm +++ b/code/modules/admin/verbs/pray.dm @@ -40,22 +40,20 @@ prayer_type = "SPIRITUAL PRAYER" var/msg_tmp = msg + GLOB.requests.pray(usr.client, msg, usr.job == "Chaplain") msg = span_adminnotice("[icon2html(cross, GLOB.admins)][prayer_type][deity ? " (to [deity])" : ""]: [ADMIN_FULLMONTY(src)] [ADMIN_SC(src)]: [msg]") - for(var/client/C in GLOB.admins) if(C.prefs.chat_toggles & CHAT_PRAYER) to_chat(C, msg, confidential = TRUE) - if(C.prefs.toggles & SOUND_PRAYERS) - if(usr.job == "Chaplain") - SEND_SOUND(C, sound('sound/effects/pray.ogg')) to_chat(usr, span_info("You pray to the gods: \"[msg_tmp]\""), confidential = TRUE) SSblackbox.record_feedback("tally", "admin_verb", 1, "Prayer") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! - //log_admin("HELP: [key_name(src)]: [msg]") + /// Used by communications consoles to message CentCom /proc/message_centcom(text, mob/sender) var/msg = copytext_char(sanitize(text), 1, MAX_MESSAGE_LEN) + GLOB.requests.message_centcom(sender.client, msg) msg = span_adminnotice("CENTCOM:[ADMIN_FULLMONTY(sender)] [ADMIN_CENTCOM_REPLY(sender)]: [msg]") to_chat(GLOB.admins, msg, confidential = TRUE) for(var/obj/machinery/computer/communications/console in GLOB.machines) @@ -64,6 +62,7 @@ /// Used by communications consoles to message the Syndicate /proc/message_syndicate(text, mob/sender) var/msg = copytext_char(sanitize(text), 1, MAX_MESSAGE_LEN) + GLOB.requests.message_syndicate(sender.client, msg) msg = span_adminnotice("SYNDICATE:[ADMIN_FULLMONTY(sender)] [ADMIN_SYNDICATE_REPLY(sender)]: [msg]") to_chat(GLOB.admins, msg, confidential = TRUE) for(var/obj/machinery/computer/communications/console in GLOB.machines) @@ -72,6 +71,7 @@ /// Used by communications consoles to request the nuclear launch codes /proc/nuke_request(text, mob/sender) var/msg = copytext_char(sanitize(text), 1, MAX_MESSAGE_LEN) + GLOB.requests.nuke_request(sender.client, msg) msg = span_adminnotice("NUKE CODE REQUEST:[ADMIN_FULLMONTY(sender)] [ADMIN_CENTCOM_REPLY(sender)] [ADMIN_SET_SD_CODE]: [msg]") to_chat(GLOB.admins, msg, confidential = TRUE) for(var/obj/machinery/computer/communications/console in GLOB.machines) diff --git a/code/modules/admin/verbs/requests.dm b/code/modules/admin/verbs/requests.dm new file mode 100644 index 00000000000..2f7d1d0fbd6 --- /dev/null +++ b/code/modules/admin/verbs/requests.dm @@ -0,0 +1,7 @@ +/// Verb for opening the requests manager panel +/client/proc/requests() + set name = "Requests Manager" + set desc = "Open the request manager panel to view all requests during this round" + set category = "Admin.Game" + SSblackbox.record_feedback("tally", "admin_verb", 1, "Request Manager") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! + GLOB.requests.ui_interact(usr) diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index 87c886f758c..8a2141d7a30 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -215,6 +215,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( GLOB.ahelp_tickets.ClientLogin(src) GLOB.interviews.client_login(src) + GLOB.requests.client_login(src) var/connecting_admin = FALSE //because de-admined admins connecting should be treated like admins. //Admin Authorisation var/datum/admins/admin_datum = GLOB.admin_datums[ckey] @@ -494,6 +495,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( log_access("Logout: [key_name(src)]") GLOB.ahelp_tickets.ClientLogout(src) GLOB.interviews.client_logout(src) + GLOB.requests.client_logout(src) SSserver_maint.UpdateHubStatus() if(credits) QDEL_LIST(credits) diff --git a/code/modules/requests/request.dm b/code/modules/requests/request.dm new file mode 100644 index 00000000000..1acace4f969 --- /dev/null +++ b/code/modules/requests/request.dm @@ -0,0 +1,43 @@ +/// Requests from prayers +#define REQUEST_PRAYER "request_prayer" +/// Requests for Centcom +#define REQUEST_CENTCOM "request_centcom" +/// Requests for the Syndicate +#define REQUEST_SYNDICATE "request_syndicate" +/// Requests for the nuke code +#define REQUEST_NUKE "request_nuke" + +/** + * # Request + * + * A representation of an in-game request, such as a prayer. + */ +/datum/request + /// Unique ID of the request + var/id + /// Atomic ID for increment unique request IDs + var/static/atomic_id = 0 + /// The type of request + var/req_type + /// The owner of the request, the player who created it + var/client/owner + /// The ckey of the owner, used for re-binding variables on login + var/owner_ckey + /// The name of the owner, in format /, assigned at time of request creation + var/owner_name + /// The message associated with the request + var/message + /// When the request was created + var/timestamp + +/datum/request/New(client/requestee, type, request) + if (!requestee) + qdel(src) + return + id = ++atomic_id + owner = requestee + owner_ckey = owner.ckey + req_type = type + message = request + timestamp = world.time + owner_name = key_name(requestee, FALSE) diff --git a/code/modules/requests/request_manager.dm b/code/modules/requests/request_manager.dm new file mode 100644 index 00000000000..de8fc5e0f63 --- /dev/null +++ b/code/modules/requests/request_manager.dm @@ -0,0 +1,213 @@ +GLOBAL_DATUM_INIT(requests, /datum/request_manager, new) + +/** + * # Request Manager + * + * Handles all player requests (prayers, centcom requests, syndicate requests) + * that occur in the duration of a round. + */ +/datum/request_manager + /// Associative list of ckey -> list of requests + var/list/requests = list() + /// List where requests can be accessed by ID + var/list/requests_by_id = list() + +/datum/request_manager/Destroy(force, ...) + QDEL_LIST(requests) + 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 any requests + * + * Arguments: + * * C - The client who is logging in + */ +/datum/request_manager/proc/client_login(client/C) + if (!requests[C.ckey]) + return + for (var/datum/request/request as anything in requests[C.ckey]) + request.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 any requests + * + * Arguments: + * * C - The client who is logging out + */ +/datum/request_manager/proc/client_logout(client/C) + if (!requests[C.ckey]) + return + for (var/datum/request/request as anything in requests[C.ckey]) + request.owner = null + +/** + * Creates a request for a prayer, and notifies admins who have the sound notifications enabled when appropriate + * + * Arguments: + * * C - The client who is praying + * * message - The prayer + * * is_chaplain - Boolean operator describing if the prayer is from a chaplain + */ +/datum/request_manager/proc/pray(client/C, message, is_chaplain) + request_for_client(C, REQUEST_PRAYER, message) + for(var/client/admin in GLOB.admins) + if(is_chaplain && admin.prefs.chat_toggles & CHAT_PRAYER && admin.prefs.toggles & SOUND_PRAYERS) + SEND_SOUND(admin, sound('sound/effects/pray.ogg')) + +/** + * Creates a request for a Centcom message + * + * Arguments: + * * C - The client who is sending the request + * * message - The message + */ +/datum/request_manager/proc/message_centcom(client/C, message) + request_for_client(C, REQUEST_CENTCOM, message) + +/** + * Creates a request for a Syndicate message + * + * Arguments: + * * C - The client who is sending the request + * * message - The message + */ +/datum/request_manager/proc/message_syndicate(client/C, message) + request_for_client(C, REQUEST_SYNDICATE, message) + +/** + * Creates a request for the nuclear self destruct codes + * + * Arguments: + * * C - The client who is sending the request + * * message - The message + */ +/datum/request_manager/proc/nuke_request(client/C, message) + request_for_client(C, REQUEST_NUKE, message) + +/** + * Creates a request and registers the request with all necessary internal tracking lists + * + * Arguments: + * * C - The client who is sending the request + * * type - The type of request, see defines + * * message - The message + */ +/datum/request_manager/proc/request_for_client(client/C, type, message) + var/datum/request/request = new(C, type, message) + if (!requests[C.ckey]) + requests[C.ckey] = list() + requests[C.ckey] += request + requests_by_id.len++ + requests_by_id[request.id] = request + +/datum/request_manager/ui_interact(mob/user, datum/tgui/ui = null) + ui = SStgui.try_update_ui(user, src, ui) + if (!ui) + ui = new(user, src, "RequestManager") + ui.open() + +/datum/request_manager/ui_state(mob/user) + return GLOB.admin_state + +/datum/request_manager/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + if (..()) + return + + // Only admins should be sending actions + if (!check_rights(R_ADMIN)) + to_chat(usr, "You do not have permission to do this, you require +ADMIN", confidential = TRUE) + return + + // Get the request this relates to + var/id = params["id"] != null ? text2num(params["id"]) : null + if (!id) + to_chat(usr, "Failed to find a request ID in your action, please report this", confidential = TRUE) + CRASH("Received an action without a request ID, this shouldn't happen!") + var/datum/request/request = !id ? null : requests_by_id[id] + + switch(action) + if ("pp") + var/mob/M = request.owner?.mob + usr.client.holder.show_player_panel(M) + return TRUE + if ("vv") + var/mob/M = request.owner?.mob + usr.client.debug_variables(M) + return TRUE + if ("sm") + var/mob/M = request.owner?.mob + usr.client.cmd_admin_subtle_message(M) + return TRUE + if ("flw") + var/mob/M = request.owner?.mob + usr.client.admin_follow(M) + return TRUE + if ("tp") + if(!SSticker.HasRoundStarted()) + tgui_alert(usr,"The game hasn't started yet!") + return TRUE + var/mob/M = request.owner?.mob + if(!ismob(M)) + var/datum/mind/D = M + if(!istype(D)) + to_chat(usr, "This can only be used on instances of type /mob and /mind", confidential = TRUE) + return TRUE + else + D.traitor_panel() + return TRUE + else + usr.client.holder.show_traitor_panel(M) + return TRUE + if ("logs") + var/mob/M = request.owner?.mob + if(!ismob(M)) + to_chat(usr, "This can only be used on instances of type /mob.", confidential = TRUE) + return TRUE + show_individual_logging_panel(M, null, null) + return TRUE + if ("smite") + if(!check_rights(R_FUN)) + to_chat(usr, "Insufficient permissions to smite, you require +FUN", confidential = TRUE) + return TRUE + var/mob/living/carbon/human/H = request.owner?.mob + if (!H || !istype(H)) + to_chat(usr, "This can only be used on instances of type /mob/living/carbon/human", confidential = TRUE) + return TRUE + usr.client.smite(H) + return TRUE + if ("rply") + if (request.req_type == REQUEST_PRAYER) + to_chat(usr, "Cannot reply to a prayer", confidential = TRUE) + return TRUE + var/mob/M = request.owner?.mob + usr.client.admin_headset_message(M, request.req_type == REQUEST_SYNDICATE ? RADIO_CHANNEL_SYNDICATE : RADIO_CHANNEL_CENTCOM) + return TRUE + if ("setcode") + if (request.req_type != REQUEST_NUKE) + to_chat(usr, "You cannot set the nuke code for a non-nuke-code-request request!", confidential = TRUE) + return TRUE + var/code = random_nukecode() + for(var/obj/machinery/nuclearbomb/selfdestruct/SD in GLOB.nuke_list) + SD.r_code = code + message_admins("[key_name_admin(usr)] has set the self-destruct code to \"[code]\".") + return TRUE + +/datum/request_manager/ui_data(mob/user) + . = list( + "requests" = list() + ) + for (var/ckey in requests) + for (var/datum/request/request as anything in requests[ckey]) + var/list/data = list( + "id" = request.id, + "req_type" = request.req_type, + "owner" = request.owner ? "[REF(request.owner)]" : null, + "owner_ckey" = request.owner_ckey, + "owner_name" = request.owner_name, + "message" = request.message, + "timestamp" = request.timestamp, + "timestamp_str" = gameTimestamp(wtime = request.timestamp) + ) + .["requests"] += list(data) diff --git a/tgstation.dme b/tgstation.dme index 9d3a52fcfaa..c83f226b4c0 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -1654,6 +1654,7 @@ #include "code\modules\admin\verbs\possess.dm" #include "code\modules\admin\verbs\pray.dm" #include "code\modules\admin\verbs\reestablish_db_connection.dm" +#include "code\modules\admin\verbs\requests.dm" #include "code\modules\admin\verbs\secrets.dm" #include "code\modules\admin\verbs\selectequipment.dm" #include "code\modules\admin\verbs\server.dm" @@ -3340,6 +3341,8 @@ #include "code\modules\religion\religion_sects.dm" #include "code\modules\religion\religion_structures.dm" #include "code\modules\religion\rites.dm" +#include "code\modules\requests\request.dm" +#include "code\modules\requests\request_manager.dm" #include "code\modules\research\bepis.dm" #include "code\modules\research\designs.dm" #include "code\modules\research\destructive_analyzer.dm" diff --git a/tgui/packages/tgui/interfaces/RequestManager.js b/tgui/packages/tgui/interfaces/RequestManager.js new file mode 100644 index 00000000000..49dff93ee11 --- /dev/null +++ b/tgui/packages/tgui/interfaces/RequestManager.js @@ -0,0 +1,177 @@ +/** + * @file + * @copyright 2021 bobbahbrown (https://github.com/bobbahbrown) + * @license MIT + */ + +import { decodeHtmlEntities } from 'common/string'; +import { useBackend, useLocalState } from '../backend'; +import { Button, Input, Section, Table } from '../components'; +import { Popper } from '../components/Popper'; +import { Window } from '../layouts'; + +export const RequestManager = (props, context) => { + const { act, data } = useBackend(context); + const { requests } = data; + const [filteredTypes, _] = useLocalState( + context, + 'filteredTypes', + Object.fromEntries( + Object.entries(displayTypeMap).map(([type, _]) => [type, true]) + ) + ); + const [searchText, setSearchText] = useLocalState(context, 'searchText'); + + // Handle filtering + let displayedRequests = requests.filter( + (request) => filteredTypes[request.req_type] + ); + if (searchText) { + const filterText = searchText.toLowerCase(); + displayedRequests = displayedRequests.filter( + (request) => + decodeHtmlEntities(request.message) + .toLowerCase() + .includes(filterText) + || request.owner_name.toLowerCase().includes(filterText) + ); + } + + return ( + + +
+ setSearchText(value)} + placeholder={'Search...'} + mr={1} + /> + + + }> + {displayedRequests.map((request) => ( +
+
+

+ + {request.owner_name} + {request.owner === null && ' [DC]'} + + + {request.timestamp_str} + +

+
+ + {decodeHtmlEntities(request.message)} +
+
+ {request.owner !== null && } +
+ ))} +
+
+
+ ); +}; + +const displayTypeMap = { + 'request_prayer': 'PRAYER', + 'request_centcom': 'CENTCOM', + 'request_syndicate': 'SYNDICATE', + 'request_nuke': 'NUKE CODE', +}; + +const RequestType = (props) => { + const { requestType } = props; + + return ( + + {displayTypeMap[requestType]}: + + ); +}; + +const RequestControls = (props, context) => { + const { act, _ } = useBackend(context); + const { request } = props; + + return ( +
+ + + + + + + + {request.req_type !== 'request_prayer' && ( + + )} + {request.req_type === 'request_nuke' && ( + + )} +
+ ); +}; + +const FilterPanel = (_, context) => { + const [filterVisible, setFilterVisible] = useLocalState( + context, + 'filterVisible', + false + ); + const [filteredTypes, setFilteredTypes] = useLocalState( + context, + 'filteredTypes', + Object.fromEntries( + Object.entries(displayTypeMap).map(([type, _]) => [type, true]) + ) + ); + + return ( + + + {Object.keys(displayTypeMap).map((type) => { + return ( + + + + + + { + filteredTypes[type] = !filteredTypes[type]; + setFilteredTypes(filteredTypes); + }} + my={0.25} + /> + + + ); + })} +
+ + }> + +
+ ); +}; diff --git a/tgui/packages/tgui/styles/interfaces/RequestManager.scss b/tgui/packages/tgui/styles/interfaces/RequestManager.scss new file mode 100644 index 00000000000..8e113c4fb6e --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/RequestManager.scss @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2021 bobbahbrown (https://github.com/bobbahbrown) + * SPDX-License-Identifier: MIT + */ + +@use '../base.scss'; +@use '../colors.scss'; +@use '../functions.scss'; + +$color-prayer: colors.bg(colors.$purple) !default; +$color-centcom: colors.bg(colors.$yellow) !default; +$color-syndicate: colors.bg(colors.$red) !default; +$color-nuke: colors.bg(colors.$yellow) !default; +$color-muted: colors.bg(colors.$label) !default; +$color-header: colors.bg(colors.$label) !default; +$background-color: base.$color-bg-section !default; +$text-color: base.$color-fg !default; + +.RequestManager__request_centcom, +.RequestManager__request_nuke, +.RequestManager__request_prayer, +.RequestManager__request_syndicate { + margin-right: 0.5rem; +} + +.RequestManager__request_prayer { + color: $color-prayer; +} + +.RequestManager__request_centcom { + color: $color-centcom; +} + +.RequestManager__request_syndicate { + color: $color-syndicate; +} + +.RequestManager__request_nuke { + color: $color-nuke; +} + +.RequestManager__header { + line-height: 1.375rem; + min-height: 1.375rem; + color: $color-muted; + white-space: break-spaces; + word-wrap: pre-wrap; + vertical-align: baseline; + margin-bottom: 0; + padding-bottom: 0.1rem; +} + +.RequestManager__headerText { + margin-right: 0.5rem; + font-size: 1rem; + font-weight: bold; + color: $color-header; +} + +.RequestManager__timestamp { + font-size: 0.75rem; +} + +.RequestManager__row:first-of-type { + margin-top: 0rem; +} + +.RequestManager__row { + padding-top: 0.125rem; + padding-bottom: 0.125rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + margin-top: 0.75rem; + min-height: 2.75rem; +} + +.RequestManager__row:hover { + background-color: rgba(0, 0, 0, 0.25); +} + +.RequestManager__row:hover .RequestManager__controlsContainer { + opacity: 1; +} + +.RequestManager__controlsContainer { + opacity: 0; + position: absolute; + right: 3px; + top: 3px; +} + +.RequestManager__row { + position: relative; +} + +.RequestManager__filterPanel { + margin: 0.5rem; + padding: 0.5rem; + background-color: functions.fake-alpha($background-color, base.$color-bg); + background-color: $background-color; + color: $text-color +} + +.RequestManager__filterPanel tr:not(:last-of-type) { + margin-bottom: 0.5rem; +} diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index e9b2c86e1eb..1008e421025 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -56,6 +56,7 @@ @include meta.load-css('./interfaces/Safe.scss'); @include meta.load-css('./interfaces/TachyonArray.scss'); @include meta.load-css('./interfaces/Techweb.scss'); +@include meta.load-css('./interfaces/RequestManager.scss'); // Layouts @include meta.load-css('./layouts/Layout.scss');