diff --git a/modular_zubbers/code/modules/client/verbs/character_directory.dm b/modular_zubbers/code/modules/client/verbs/character_directory.dm
new file mode 100644
index 00000000000..9f8e1cf285d
--- /dev/null
+++ b/modular_zubbers/code/modules/client/verbs/character_directory.dm
@@ -0,0 +1,218 @@
+GLOBAL_DATUM(character_directory, /datum/character_directory)
+#define READ_PREFS(target, pref) (target.client.prefs.read_preference(/datum/preference/pref) || "Unset")
+
+//We want players to be able to decide whether they show up in the directory or not
+/datum/preference/toggle/show_in_directory
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ default_value = TRUE
+ savefile_key = "show_in_directory"
+ savefile_identifier = PREFERENCE_PLAYER
+
+//The advertisement that you show to people looking through the directory
+/datum/preference/text/character_ad
+ savefile_key = "character_ad"
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ maximum_value_length = MAX_FLAVOR_LEN
+
+//TGUI gets angry if you don't define a default on text preferences
+/datum/preference/text/character_ad/create_default_value()
+ return ""
+
+//Any text preference needs this for some reason
+/datum/preference/text/character_ad/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences)
+ return FALSE
+
+/datum/preference/choiced/attraction
+ savefile_key = "attraction"
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+
+/datum/preference/choiced/attraction/init_possible_values()
+ return list("Gay", "Lesbian", "Straight", "Skolio", "Bi", "Pan", "Poly", "Omni", "Ace", "Unset", "Check OOC")
+
+/datum/preference/choiced/attraction/create_default_value()
+ return "Unset"
+
+/datum/preference/choiced/attraction/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences)
+ return FALSE
+
+/datum/preference/choiced/display_gender
+ savefile_key = "display_gender"
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+
+/datum/preference/choiced/display_gender/init_possible_values()
+ return list("Male", "Female", "Null", "Plural", "Nonbinary", "Omni", "Trans", "Andro", "Gyno", "Fluid", "Unset", "Check OOC")
+
+/datum/preference/choiced/display_gender/create_default_value()
+ return "Unset"
+
+/datum/preference/choiced/display_gender/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences)
+ return FALSE
+//Can't believe Bubberstation invented attraction and gender in the year December 2023
+
+//Add a cooldown for the character directory to the client, primarily to stop server lag from refresh spam
+/client
+ COOLDOWN_DECLARE(char_directory_cooldown)
+
+//Make a verb to open the character directory
+/client/verb/show_character_directory()
+ set name = "Character Directory"
+ set category = "OOC"
+ set desc = "Shows a listing of all active characters, along with their associated OOC notes, flavor text, and more."
+
+ // This is primarily to stop malicious users from trying to lag the server by spamming this verb
+ if(!COOLDOWN_FINISHED(src, char_directory_cooldown))
+ to_chat(src, span_alert("Hold your horses! Its still refreshing!"))
+ return
+ COOLDOWN_START(src, char_directory_cooldown, 10)
+
+//Check if there's not already a character directory open; open a new one if one is not present
+ if(!GLOB.character_directory)
+ GLOB.character_directory = new
+ GLOB.character_directory.ui_interact(mob)
+
+// This is a global singleton. Keep in mind that all operations should occur on user, not src.
+/datum/character_directory
+
+/datum/character_directory/ui_state(mob/user)
+ return GLOB.always_state
+
+/datum/character_directory/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "ZubbersCharacterDirectory", "Character Directory")
+ ui.open()
+
+//We want this information to update any time the player updates their preferences, not just when the panel is refreshed
+/datum/character_directory/ui_data(mob/user)
+ . = ..()
+ var/list/data = .
+
+//Collect the user's own preferences for the top of the UI
+ if (user?.client?.prefs)
+ data["personalVisibility"] = READ_PREFS(user, toggle/show_in_directory)
+ data["personalAttraction"] = READ_PREFS(user, choiced/attraction)
+ data["personalGender"] = READ_PREFS(user, choiced/display_gender)
+ data["personalErpTag"] = READ_PREFS(user, choiced/erp_status)
+ data["personalVoreTag"] = READ_PREFS(user, choiced/erp_status_v)
+ data["personalHypnoTag"] = READ_PREFS(user, choiced/erp_status_hypno)
+ data["personalNonconTag"] = READ_PREFS(user, choiced/erp_status_nc)
+ data["prefsOnly"] = TRUE
+
+ data["canOrbit"] = isobserver(user)
+
+ return data
+
+/datum/character_directory/ui_static_data(mob/user)
+ . = ..()
+ var/list/data = .
+
+ var/list/directory_mobs = list()
+ //We want the directory to display only alive players, not observers or people in the lobby
+ for(var/mob/mob in GLOB.alive_player_list)
+ // These are the variables we're trying to display in the directory
+ var/name = null
+ var/species = null
+ var/ooc_notes = ""
+ var/flavor_text = ""
+ var/attraction = null
+ var/gender = null
+ var/erp = null
+ var/vore = null
+ var/hypno = null
+ var/noncon = null
+ var/character_ad = ""
+ var/exploitable = ""
+ var/ref = REF(mob)
+ //Just in case something we get is not a mob
+ if(!mob)
+ continue
+
+ //Different approach for humans and silicons
+ if(ishuman(mob))
+ var/mob/living/carbon/human/human = mob
+ //If someone is obscured without flavor text visible, we don't want them on the Directory.
+ if((human.wear_mask && (human.wear_mask.flags_inv & HIDEFACE) && READ_PREFS(human, toggle/obscurity_examine)) || (human.head && (human.head.flags_inv & HIDEFACE) && READ_PREFS(human, toggle/obscurity_examine)) || (HAS_TRAIT(human, TRAIT_UNKNOWN)))
+ continue
+ //Display custom species, otherwise show base species instead
+ species = (READ_PREFS(human, text/custom_species))
+ if(species == "Unset")
+ species = "[human.dna.species.name]"
+ //Load standard flavor text preference
+ flavor_text = READ_PREFS(human, text/flavor_text)
+ else if(issilicon(mob))
+ var/mob/living/silicon/silicon = mob
+ //If the target is a silicon, we want it to show its brain as its species
+ species = READ_PREFS(silicon, choiced/brain_type)
+ //Load silicon flavor text in place of normal flavor text
+ flavor_text = READ_PREFS(silicon, text/silicon_flavor_text)
+ //Don't show if they are not a human or a silicon
+ else continue
+ //List of all the shown ERP preferences in the Directory. If there is none, return "Unset"
+ attraction = READ_PREFS(mob, choiced/attraction)
+ gender = READ_PREFS(mob, choiced/display_gender)
+ erp = READ_PREFS(mob, choiced/erp_status)
+ vore = READ_PREFS(mob, choiced/erp_status_v)
+ hypno = READ_PREFS(mob, choiced/erp_status_hypno)
+ noncon = READ_PREFS(mob, choiced/erp_status_nc)
+ character_ad = READ_PREFS(mob, text/character_ad)
+ ooc_notes = READ_PREFS(mob, text/ooc_notes)
+ //If the user is an antagonist or Observer, we want them to be able to see exploitables in the Directory.
+ if(user.mind?.has_antag_datum(/datum/antagonist) || isobserver(user))
+ if(exploitable == EXPLOITABLE_DEFAULT_TEXT)
+ exploitable = "Unset"
+ else exploitable = READ_PREFS(mob, text/exploitable)
+ else exploitable = "Obscured"
+ //And finally, we want to get the mob's name, taking into account disguised names.
+ name = mob.real_name ? mob.name : mob.real_name
+
+ directory_mobs.Add(list(list(
+ "name" = name,
+ "species" = species,
+ "ooc_notes" = ooc_notes,
+ "attraction" = attraction,
+ "gender" = gender,
+ "erp" = erp,
+ "vore" = vore,
+ "hypno" = hypno,
+ "noncon" = noncon,
+ "exploitable" = exploitable,
+ "character_ad" = character_ad,
+ "flavor_text" = flavor_text,
+ "ref" = ref
+ )))
+
+ data["directory"] = directory_mobs
+
+ return data
+
+/datum/character_directory/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+
+ if(.)
+ return
+
+ var/mob/user = usr
+ if(!user)
+ return
+
+ switch(action)
+ if("refresh")
+ // This is primarily to stop malicious users from trying to lag the server by spamming this verb
+ if(!COOLDOWN_FINISHED(user.client, char_directory_cooldown))
+ to_chat(user, "Don't spam character directory refresh.")
+ return
+ COOLDOWN_START(user.client, char_directory_cooldown, 10)
+ update_static_data(user, ui)
+ return TRUE
+ if("orbit")
+ var/ref = params["ref"]
+ var/mob/dead/observer/ghost = user
+ var/atom/movable/poi = (locate(ref) in GLOB.mob_list)
+ if (poi == null)
+ return TRUE
+ ghost.ManualFollow(poi)
+ ghost.reset_perspective(null)
+ return TRUE
diff --git a/tgstation.dme b/tgstation.dme
index 26f924615c1..e8382266be7 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -8045,6 +8045,7 @@
#include "modular_zubbers\code\modules\cargo\packs\metalsheets.dm"
#include "modular_zubbers\code\modules\cargo\packs\security.dm"
#include "modular_zubbers\code\modules\cargo\packs\service.dm"
+#include "modular_zubbers\code\modules\client\verbs\character_directory.dm"
#include "modular_zubbers\code\modules\clothing\gloves\syndicate.dm"
#include "modular_zubbers\code\modules\clothing\head\helmet.dm"
#include "modular_zubbers\code\modules\clothing\head\jobs.dm"
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/bubbers/species_features.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/bubbers/species_features.tsx
new file mode 100644
index 00000000000..9c04981e99b
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/bubbers/species_features.tsx
@@ -0,0 +1,22 @@
+import { Feature, FeatureChoiced, FeatureDropdownInput, FeatureTextInput } from '../../base';
+
+export const character_ad: Feature = {
+ name: 'Character Advert',
+ description:
+ 'An advertisement for your character. Give information on how to approach for those interested, for either regular and erotic roleplay.',
+ component: FeatureTextInput,
+};
+
+export const attraction: FeatureChoiced = {
+ name: 'Character Attraction',
+ description:
+ 'What classifies what your character is attracted to. This is displayed in the Directory.',
+ component: FeatureDropdownInput,
+};
+
+export const display_gender: FeatureChoiced = {
+ name: 'Character Gender',
+ description:
+ 'What classifies as the gender for your character. This is displayed in the Directory.',
+ component: FeatureDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/bubber/showindirectory.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/bubber/showindirectory.tsx
new file mode 100644
index 00000000000..b18f4b29553
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/bubber/showindirectory.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInput, FeatureToggle } from '../../base';
+
+export const show_in_directory: FeatureToggle = {
+ name: 'Show in Directory',
+ category: 'ERP',
+ description: 'When enabled, character will be shown in Directory',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/ZubbersCharacterDirectory.js b/tgui/packages/tgui/interfaces/ZubbersCharacterDirectory.js
new file mode 100644
index 00000000000..49c0e97d60e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/ZubbersCharacterDirectory.js
@@ -0,0 +1,272 @@
+import { Fragment } from 'inferno';
+import { useBackend, useLocalState } from '../backend';
+import { Box, Button, Icon, LabeledList, Section, Table } from '../components';
+import { Window } from '../layouts';
+
+const erpTagColor = {
+ 'Unset': 'label',
+ 'Yes - Dom': 'red',
+ 'Yes - Sub': 'blue',
+ 'Yes - Switch': 'yellow',
+ 'Yes': 'yellow',
+ 'Check OOC': 'black',
+ 'Ask': 'black',
+ 'No': 'black',
+};
+
+export const ZubbersCharacterDirectory = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ const {
+ personalVisibility,
+ personalAttraction,
+ personalGender,
+ personalErpTag,
+ personalVoreTag,
+ personalHypnoTag,
+ personalNonconTag,
+ prefsOnly,
+ } = data;
+
+ const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
+
+ const [overwritePrefs, setOverwritePrefs] = useLocalState(
+ context,
+ 'overwritePrefs',
+ prefsOnly
+ );
+
+ return (
+
+
+ {(overlay && ) || (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+const ViewCharacter = (props, context) => {
+ const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
+
+ return (
+ setOverlay(null)}
+ />
+ }>
+
+
+
+
+
+
+
+
+ {overlay.character_ad || 'Unset.'}
+
+
+
+
+ {overlay.exploitable || 'Unset.'}
+
+
+
+
+ {overlay.ooc_notes || 'Unset.'}
+
+
+
+
+ {overlay.flavor_text || 'Unset.'}
+
+
+
+ );
+};
+
+const CharacterDirectoryList = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ const { directory, canOrbit } = data;
+
+ const [sortId, _setSortId] = useLocalState(context, 'sortId', 'name');
+ const [sortOrder, _setSortOrder] = useLocalState(
+ context,
+ 'sortOrder',
+ 'name'
+ );
+ const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
+
+ return (
+ act('refresh')} />
+ }>
+
+
+ Name
+ Species
+ Attraction
+ Gender
+ ERP
+ Vore
+ Hypno
+ Noncon
+
+ Advert
+
+
+ {directory
+ .sort((a, b) => {
+ const i = sortOrder ? 1 : -1;
+ return a[sortId].localeCompare(b[sortId]) * i;
+ })
+ .map((character, i) => (
+
+
+ {canOrbit ? (
+
+ {character.species}
+ {character.attraction}
+ {character.gender}
+ {character.erp}
+ {character.vore}
+ {character.hypno}
+ {character.noncon}
+
+
+
+ ))}
+
+
+ );
+};
+
+const SortButton = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ const { id, children } = props;
+
+ // Hey, same keys mean same data~
+ const [sortId, setSortId] = useLocalState(context, 'sortId', 'name');
+ const [sortOrder, setSortOrder] = useLocalState(context, 'sortOrder', 'name');
+
+ return (
+
+
+
+ );
+};