diff --git a/code/modules/admin/verbs/admin.dm b/code/modules/admin/verbs/admin.dm
index 3aaab5bae58fc..86bbd15ff3571 100644
--- a/code/modules/admin/verbs/admin.dm
+++ b/code/modules/admin/verbs/admin.dm
@@ -42,12 +42,9 @@ ADMIN_VERB(cmd_admin_check_player_exp, R_ADMIN, "Player Playtime", "View player
to_chat(user, span_warning("Tracking is disabled in the server configuration file."), confidential = TRUE)
return
- var/list/msg = list()
- msg += "
Playtime ReportPlaytime:
"
- for(var/client/client in sort_list(GLOB.clients, GLOBAL_PROC_REF(cmp_playtime_asc)))
- msg += "- [ADMIN_PP(client.mob)] [key_name_admin(client)]: " + client.get_exp_living() + "
"
- msg += "
"
- user << browse(msg.Join(), "window=Player_playtime_check")
+ // SPLURT EDIT START
+ new /datum/player_playtime(usr)
+ // SPLURT EDIT END
/client/proc/trigger_centcom_recall()
if(!check_rights(R_ADMIN))
diff --git a/modular_zzplurt/code/datums/components/crafting/crafting.dm b/modular_zzplurt/code/datums/components/crafting/crafting.dm
new file mode 100644
index 0000000000000..ea99b2e3a3657
--- /dev/null
+++ b/modular_zzplurt/code/datums/components/crafting/crafting.dm
@@ -0,0 +1,17 @@
+/datum/component/personal_crafting/ui_act(action, params)
+ . = ..()
+ if(.)
+ return
+
+ switch(action)
+ if("make", "make_mass")
+ var/datum/crafting_recipe/crafting_recipe = locate(params["recipe"]) in (mode ? GLOB.cooking_recipes : GLOB.crafting_recipes)
+
+ if (istype(crafting_recipe, /datum/crafting_recipe/improv_explosive) || istype(crafting_recipe, /datum/crafting_recipe/molotov) || istype(crafting_recipe, /datum/crafting_recipe/chemical_payload) || istype(crafting_recipe, /datum/crafting_recipe/chemical_payload2))
+ var/client/client = usr.client
+ if (CONFIG_GET(flag/use_exp_tracking) && client && client.get_exp_living(TRUE) < 8 HOURS) // Player with less than 8 hours playtime is making an IED or molotov cocktail.
+ if(client.next_ied_grief_warning < world.time)
+ var/turf/T = get_turf(usr)
+ client.next_ied_grief_warning = world.time + 15 MINUTES // Wait 15 minutes before alerting admins again
+ message_admins("[span_adminhelp("ANTI-GRIEF:")] New player [ADMIN_LOOKUPFLW(usr)] has crafted an IED or Molotov at [ADMIN_VERBOSEJMP(T)].")
+ client.crafted_ied = TRUE
diff --git a/modular_zzplurt/code/game/objects/items/devices/transfer_valve.dm b/modular_zzplurt/code/game/objects/items/devices/transfer_valve.dm
new file mode 100644
index 0000000000000..2bae09b88b051
--- /dev/null
+++ b/modular_zzplurt/code/game/objects/items/devices/transfer_valve.dm
@@ -0,0 +1,11 @@
+/obj/item/transfer_valve/attack_hand(mob/user, list/modifiers)
+ . = ..()
+
+ var/client/client = user.client
+ if (CONFIG_GET(flag/use_exp_tracking) && client && client.get_exp_living(TRUE) < 8 HOURS) // Player with less than 8 hours playtime has touched a bomb valve.
+ if(client.next_valve_grief_warning < world.time)
+ var/turf/T = get_turf(src)
+ client.next_valve_grief_warning = world.time + 15 MINUTES // Wait 15 minutes before alerting admins again
+ message_admins("[span_adminhelp("ANTI-GRIEF:")] New player [ADMIN_LOOKUPFLW(user)] touched \a [src] at [ADMIN_VERBOSEJMP(T)].")
+ client.touched_transfer_valve = TRUE
+
diff --git a/modular_zzplurt/code/modules/admin/playtimes.dm b/modular_zzplurt/code/modules/admin/playtimes.dm
new file mode 100644
index 0000000000000..1ef4bb970640b
--- /dev/null
+++ b/modular_zzplurt/code/modules/admin/playtimes.dm
@@ -0,0 +1,123 @@
+/client
+ var/datum/player_playtime/playtime_menu
+ var/next_valve_grief_warning = 0
+ var/next_chem_grief_warning = 0
+ var/next_canister_grief_warning = 0
+ var/next_ied_grief_warning = 0
+ var/next_circuit_grief_warning = 0
+ var/touched_transfer_valve = FALSE
+ var/used_chem_dispenser = FALSE
+ var/touched_canister = FALSE
+ var/crafted_ied = FALSE
+ var/touched_circuit = FALSE
+ var/uses_vpn = FALSE
+
+/datum/player_playtime/New(mob/viewer)
+ ui_interact(viewer)
+
+/datum/player_playtime/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "PlayerPlaytimes", "Player Playtimes")
+ ui.open()
+
+/datum/player_playtime/ui_state(mob/user)
+ return GLOB.admin_state
+
+/datum/player_playtime/ui_data(mob/user)
+ var/list/data = list()
+
+ var/list/clients = list()
+ for(var/client/C in GLOB.clients)
+ var/list/client = list()
+
+ client["ckey"] = C.ckey
+ client["playtime_hours"] = C.get_exp_living()
+ client["flags"] = check_flags(C)
+
+ var/mob/M = C.mob
+ client["observer"] = isobserver(M)
+ client["ingame"] = !isnewplayer(M)
+ client["name"] = M.real_name
+ var/nnpa = CONFIG_GET(number/notify_new_player_age)
+ if(nnpa >= 0)
+ if(C.account_age >= 0 && (C.account_age < CONFIG_GET(number/notify_new_player_age)))
+ client["new_account"] = "New BYOND account [C.account_age] day[(C.account_age==1?"":"s")] old, created on [C.account_join_date]"
+
+ clients += list(client)
+
+ clients = sort_list(clients, GLOBAL_PROC_REF(cmp_playtime_asc))
+ data["clients"] = clients
+ return data
+
+/datum/player_playtime/ui_act(action, params)
+ if(..())
+ return
+
+ switch(action)
+ if("view_playtime")
+ var/mob/target = get_mob_by_ckey(params["ckey"])
+ usr.client.holder.cmd_show_exp_panel(target.client)
+ if("admin_pm")
+ usr.client.cmd_admin_pm(params["ckey"])
+ if("player_panel")
+ var/mob/target = get_mob_by_ckey(params["ckey"])
+ SSadmin_verbs.dynamic_invoke_verb(usr.client, /datum/admin_verb/show_player_panel, target)
+ if("view_variables")
+ var/mob/target = get_mob_by_ckey(params["ckey"])
+ usr.client.debug_variables(target)
+ if("observe")
+ if(!isobserver(usr) && !check_rights(R_ADMIN))
+ return
+
+ var/mob/target = get_mob_by_key(params["ckey"])
+ if(!target)
+ to_chat(usr, span_notice("Player not found."))
+ return
+
+ var/client/C = usr.client
+ if(!isobserver(usr))
+ SSadmin_verbs.dynamic_invoke_verb(usr.client, /datum/admin_verb/admin_ghost)
+ var/mob/dead/observer/A = C.mob
+ A.ManualFollow(target)
+
+/datum/player_playtime/proc/check_flags(client/C)
+ var/list/flags = list()
+
+ if (C.touched_transfer_valve)
+ var/list/flag = list()
+ flag["icon"] = "bomb"
+ flag["tooltip"] = "This player touched a Transfer Valve."
+ flags += list(flag)
+
+ if (C.used_chem_dispenser)
+ var/list/flag = list()
+ flag["icon"] = "flask"
+ flag["tooltip"] = "This player used a Chem Dispenser."
+ flags += list(flag)
+
+ if (C.touched_canister)
+ var/list/flag = list()
+ flag["icon"] = "spray-can"
+ flag["tooltip"] = "This player touched a gas canister."
+ flags += list(flag)
+
+ if (C.crafted_ied)
+ var/list/flag = list()
+ flag["icon"] = "hammer"
+ flag["tooltip"] = "This player crafted an IED or Molotov."
+ flags += list(flag)
+
+ if (C.touched_circuit)
+ var/list/flag = list()
+ flag["icon"] = "code-branch"
+ flag["tooltip"] = "This player touched an integrated circuit."
+ flags += list(flag)
+
+ if(C.uses_vpn)
+ var/list/flag = list()
+ flag["icon"] = "wifi"
+ flag["tooltip"] = "This player is [round(C.ip_intel*100, 0.01)]% likely to be using a Proxy/VPN"
+ flags += list(flag)
+
+ return flags
diff --git a/modular_zzplurt/code/modules/atmospherics/machinery/portable/canister.dm b/modular_zzplurt/code/modules/atmospherics/machinery/portable/canister.dm
new file mode 100644
index 0000000000000..569149cb2016a
--- /dev/null
+++ b/modular_zzplurt/code/modules/atmospherics/machinery/portable/canister.dm
@@ -0,0 +1,10 @@
+/obj/machinery/portable_atmospherics/canister/ui_interact(mob/user, datum/tgui/ui)
+ . = ..()
+
+ var/client/client = user.client
+ if (CONFIG_GET(flag/use_exp_tracking) && client && client.get_exp_living(TRUE) < 8 HOURS) // Player with less than 8 hours playtime is interacting with this canister.
+ if(client.next_canister_grief_warning < world.time)
+ var/turf/T = get_turf(src)
+ client.next_canister_grief_warning = world.time + 15 MINUTES // Wait 15 minutes before alerting admins again
+ message_admins("[span_adminhelp("ANTI-GRIEF:")] New player [ADMIN_LOOKUPFLW(user)] has touched \a [src] at [ADMIN_VERBOSEJMP(T)].")
+ client.touched_canister = TRUE
diff --git a/modular_zzplurt/code/modules/reagents/chemistry/machinery/chem_dispenser.dm b/modular_zzplurt/code/modules/reagents/chemistry/machinery/chem_dispenser.dm
new file mode 100644
index 0000000000000..5b3b50341f596
--- /dev/null
+++ b/modular_zzplurt/code/modules/reagents/chemistry/machinery/chem_dispenser.dm
@@ -0,0 +1,11 @@
+/obj/machinery/chem_dispenser/ui_interact(mob/user, datum/tgui/ui)
+ . = ..()
+
+ var/client/client = user.client
+ if (CONFIG_GET(flag/use_exp_tracking) && client && client.get_exp_living(TRUE) < 8 HOURS) // Player with less than 8 hours playtime is using this machine.
+ if(client.next_chem_grief_warning < world.time)
+ if(!istype(src, /obj/machinery/chem_dispenser/drinks) && !istype(src, /obj/machinery/chem_dispenser/mutagen) && !istype(src, /obj/machinery/chem_dispenser/mutagensaltpeter) && !istype(src, /obj/machinery/chem_dispenser/abductor)) // These types aren't used for grief
+ var/turf/T = get_turf(src)
+ client.next_chem_grief_warning = world.time + 15 MINUTES // Wait 15 minutes before alerting admins again
+ message_admins("[span_adminhelp("ANTI-GRIEF:")] New player [ADMIN_LOOKUPFLW(user)] used \a [src] at [ADMIN_VERBOSEJMP(T)].")
+ client.used_chem_dispenser = TRUE
diff --git a/modular_zzplurt/code/modules/wiremod/core/integrated_circuit.dm b/modular_zzplurt/code/modules/wiremod/core/integrated_circuit.dm
new file mode 100644
index 0000000000000..9fdefe58240f3
--- /dev/null
+++ b/modular_zzplurt/code/modules/wiremod/core/integrated_circuit.dm
@@ -0,0 +1,10 @@
+/obj/item/integrated_circuit/ui_interact(mob/user, datum/tgui/ui)
+ . = ..()
+
+ var/client/client = user.client
+ if (CONFIG_GET(flag/use_exp_tracking) && client && client.get_exp_living(TRUE) < 8 HOURS) // Player with less than 8 hours playtime is using this machine.
+ if(client.next_circuit_grief_warning < world.time)
+ var/turf/T = get_turf(src)
+ client.next_circuit_grief_warning = world.time + 15 MINUTES // Wait 15 minutes before alerting admins again
+ message_admins("[span_adminhelp("ANTI-GRIEF:")] New player [ADMIN_LOOKUPFLW(user)] has touched \a [src] at [ADMIN_VERBOSEJMP(T)].")
+ client.touched_circuit = TRUE
diff --git a/tgstation.dme b/tgstation.dme
index 57eac54201ee4..001b53f70cbff 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -9104,8 +9104,12 @@
#include "modular_zzplurt\code\_globalvars\mobs.dm"
#include "modular_zzplurt\code\controllers\configuration\entries\discord.dm"
#include "modular_zzplurt\code\controllers\subsystem\discord.dm"
+#include "modular_zzplurt\code\datums\components\crafting\crafting.dm"
+#include "modular_zzplurt\code\game\objects\items\devices\transfer_valve.dm"
#include "modular_zzplurt\code\modules\admin\player_panel.dm"
+#include "modular_zzplurt\code\modules\admin\playtimes.dm"
#include "modular_zzplurt\code\modules\admin\transform.dm"
+#include "modular_zzplurt\code\modules\atmospherics\machinery\portable\canister.dm"
#include "modular_zzplurt\code\modules\client\client_procs.dm"
#include "modular_zzplurt\code\modules\client\preferences\player_panel.dm"
#include "modular_zzplurt\code\modules\client\verbs\looc.dm"
@@ -9117,4 +9121,6 @@
#include "modular_zzplurt\code\modules\mob\dead\new_player\new_player.dm"
#include "modular_zzplurt\code\modules\mob\living\living.dm"
#include "modular_zzplurt\code\modules\mob\living\living_defines.dm"
+#include "modular_zzplurt\code\modules\reagents\chemistry\machinery\chem_dispenser.dm"
+#include "modular_zzplurt\code\modules\wiremod\core\integrated_circuit.dm"
// END_INCLUDE
diff --git a/tgui/packages/tgui/interfaces/PlayerPlaytimes.tsx b/tgui/packages/tgui/interfaces/PlayerPlaytimes.tsx
new file mode 100644
index 0000000000000..0db81a27f019f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PlayerPlaytimes.tsx
@@ -0,0 +1,156 @@
+import { useBackend } from '../backend';
+import { Box, Button, Icon, Section, Table, Tooltip } from '../components';
+import { Window } from '../layouts';
+
+type Data = {
+ clients: {
+ ckey: string;
+ name: string;
+ observer: boolean;
+ ingame: boolean;
+ new_account: string;
+ playtime_hours: number;
+ flags: {
+ icon: string;
+ tooltip: string;
+ }[];
+ }[];
+};
+
+export const PlayerPlaytimes = () => {
+ const { act, data } = useBackend();
+ const { clients } = data;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Ckey
+
+
+ Real Name
+
+
+ Flags
+
+
+ Actions
+
+
+ {clients.map((client) => (
+
+
+
+
+
+
+
+ {!!client.new_account && (
+
+
+
+ )}{' '}
+ {client.ckey}
+
+
+
+
+ {!client.ingame ? (
+ (At lobby)
+ ) : (
+ !!client.observer && (
+
+
+
+ )
+ )}{' '}
+ {client.name}
+
+
+
+
+ {client.flags.map((flag) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+};